remove api service
This commit is contained in:
parent
987a83e760
commit
b13037b882
|
@ -1,37 +0,0 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ContextKey string
|
||||
|
||||
const (
|
||||
MovieKey = ContextKey("movie")
|
||||
)
|
||||
|
||||
func Index(w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"message":"emdb index"}`)
|
||||
}
|
||||
|
||||
func Error(w http.ResponseWriter, status int, message string, err error, logger *slog.Logger) {
|
||||
logger.Error(message, "error", err)
|
||||
|
||||
w.WriteHeader(status)
|
||||
|
||||
var resBody []byte
|
||||
res := struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}{
|
||||
Message: message,
|
||||
Error: err.Error(),
|
||||
}
|
||||
resBody, _ = json.Marshal(res)
|
||||
|
||||
fmt.Fprint(w, string(resBody))
|
||||
}
|
|
@ -1,120 +0,0 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.ewintr.nl/emdb/job"
|
||||
)
|
||||
|
||||
type JobAPI struct {
|
||||
jq *job.JobQueue
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewJobAPI(jq *job.JobQueue, logger *slog.Logger) *JobAPI {
|
||||
return &JobAPI{
|
||||
jq: jq,
|
||||
logger: logger.With("api", "admin"),
|
||||
}
|
||||
}
|
||||
|
||||
func (jobAPI *JobAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
logger := jobAPI.logger.With("method", "serveHTTP")
|
||||
|
||||
subPath, _ := ShiftPath(r.URL.Path)
|
||||
switch {
|
||||
case r.Method == http.MethodPost && subPath == "":
|
||||
jobAPI.Add(w, r)
|
||||
case r.Method == http.MethodGet && subPath == "":
|
||||
jobAPI.List(w, r)
|
||||
case r.Method == http.MethodGet && subPath == "next-ai":
|
||||
jobAPI.NextAI(w, r)
|
||||
case r.Method == http.MethodDelete && subPath != "":
|
||||
jobAPI.Delete(w, r, subPath)
|
||||
case r.Method == http.MethodDelete && subPath == "":
|
||||
jobAPI.DeleteAll(w, r)
|
||||
default:
|
||||
Error(w, http.StatusNotFound, "unregistered path", fmt.Errorf("method %q with subpath %q was not registered in /movie", r.Method, subPath), logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (jobAPI *JobAPI) Add(w http.ResponseWriter, r *http.Request) {
|
||||
logger := jobAPI.logger.With("method", "add")
|
||||
|
||||
var j job.Job
|
||||
if err := json.NewDecoder(r.Body).Decode(&j); err != nil {
|
||||
Error(w, http.StatusBadRequest, "could not decode job", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := jobAPI.jq.Add(j.ActionID, j.Action); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not add job", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(j); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not encode job", err, logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (jobAPI *JobAPI) List(w http.ResponseWriter, r *http.Request) {
|
||||
logger := jobAPI.logger.With("method", "list")
|
||||
|
||||
jobs, err := jobAPI.jq.List()
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not list jobs", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(jobs); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not encode jobs", err, logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (jobAPI *JobAPI) NextAI(w http.ResponseWriter, r *http.Request) {
|
||||
logger := jobAPI.logger.With("method", "nextai")
|
||||
|
||||
j, err := jobAPI.jq.Next(job.TypeAI)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
logger.Info("no ai jobs found")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case err != nil:
|
||||
Error(w, http.StatusInternalServerError, "could not get next ai job", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(j); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not encode job", err, logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (jobAPI *JobAPI) Delete(w http.ResponseWriter, r *http.Request, id string) {
|
||||
logger := jobAPI.logger.With("method", "delete")
|
||||
|
||||
if err := jobAPI.jq.Delete(id); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not delete job", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (jobAPI *JobAPI) DeleteAll(w http.ResponseWriter, r *http.Request) {
|
||||
logger := jobAPI.logger.With("method", "deleteall")
|
||||
|
||||
if err := jobAPI.jq.DeleteAll(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not delete all jobs", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
|
@ -1,159 +0,0 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.ewintr.nl/emdb/job"
|
||||
"code.ewintr.nl/emdb/storage"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type MovieAPI struct {
|
||||
apis APIIndex
|
||||
repo *storage.MovieRepository
|
||||
jq *job.JobQueue
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewMovieAPI(apis APIIndex, repo *storage.MovieRepository, jq *job.JobQueue, logger *slog.Logger) *MovieAPI {
|
||||
return &MovieAPI{
|
||||
apis: apis,
|
||||
repo: repo,
|
||||
logger: logger.With("api", "movie"),
|
||||
}
|
||||
}
|
||||
|
||||
func (movieAPI *MovieAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
logger := movieAPI.logger.With("method", "serveHTTP")
|
||||
|
||||
head, tail := ShiftPath(r.URL.Path)
|
||||
subHead, subTail := ShiftPath(tail)
|
||||
for aPath, api := range movieAPI.apis {
|
||||
if head != "" && subHead == fmt.Sprintf("%s", aPath) {
|
||||
r.URL.Path = subTail
|
||||
r = r.Clone(context.WithValue(r.Context(), MovieKey, head))
|
||||
api.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodGet && head != "":
|
||||
movieAPI.Read(w, r, head)
|
||||
case r.Method == http.MethodPut && head != "":
|
||||
movieAPI.Store(w, r, head)
|
||||
case r.Method == http.MethodPost && head == "":
|
||||
movieAPI.Store(w, r, "")
|
||||
case r.Method == http.MethodDelete && head != "":
|
||||
movieAPI.Delete(w, r, head)
|
||||
case r.Method == http.MethodGet && head == "":
|
||||
movieAPI.List(w, r)
|
||||
default:
|
||||
Error(w, http.StatusNotFound, "unregistered path", fmt.Errorf("method %q with subpath %q was not registered in /movie", r.Method, head), logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (movieAPI *MovieAPI) Read(w http.ResponseWriter, r *http.Request, movieID string) {
|
||||
logger := movieAPI.logger.With("method", "read")
|
||||
|
||||
m, err := movieAPI.repo.FindOne(movieID)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprint(w, `{"message":"not found"}`)
|
||||
return
|
||||
case err != nil:
|
||||
Error(w, http.StatusInternalServerError, "could not get movie", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
resJson, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not marshal response", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprint(w, string(resJson))
|
||||
}
|
||||
|
||||
func (movieAPI *MovieAPI) Store(w http.ResponseWriter, r *http.Request, urlID string) {
|
||||
logger := movieAPI.logger.With("method", "create")
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "could not read body", err, logger)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var m storage.Movie
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
Error(w, http.StatusBadRequest, "could not unmarshal request body", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case urlID == "" && m.ID == "":
|
||||
m.ID = uuid.New().String()
|
||||
case urlID != "" && m.ID == "":
|
||||
m.ID = urlID
|
||||
case urlID != "" && m.ID != "" && urlID != m.ID:
|
||||
Error(w, http.StatusBadRequest, "id in path does not match id in body", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := movieAPI.repo.Store(m); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not store movie", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
resBody, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not marshal movie", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprint(w, string(resBody))
|
||||
}
|
||||
|
||||
func (movieAPI *MovieAPI) Delete(w http.ResponseWriter, r *http.Request, urlID string) {
|
||||
logger := movieAPI.logger.With("method", "delete")
|
||||
|
||||
err := movieAPI.repo.Delete(urlID)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprint(w, `{"message":"not found"}`)
|
||||
return
|
||||
case err != nil:
|
||||
Error(w, http.StatusInternalServerError, "could not delete movie", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (movieAPI *MovieAPI) List(w http.ResponseWriter, r *http.Request) {
|
||||
logger := movieAPI.logger.With("method", "list")
|
||||
|
||||
movies, err := movieAPI.repo.FindAll()
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not get movies", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
resBody, err := json.Marshal(movies)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not marshal movies", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprint(w, string(resBody))
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.ewintr.nl/emdb/storage"
|
||||
)
|
||||
|
||||
type MovieReviewAPI struct {
|
||||
repo *storage.ReviewRepository
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewMovieReviewAPI(repo *storage.ReviewRepository, logger *slog.Logger) *MovieReviewAPI {
|
||||
return &MovieReviewAPI{
|
||||
repo: repo,
|
||||
logger: logger.With("api", "moviereview"),
|
||||
}
|
||||
}
|
||||
|
||||
func (reviewAPI *MovieReviewAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
logger := reviewAPI.logger.With("method", "serveHTTP")
|
||||
|
||||
subPath, _ := ShiftPath(r.URL.Path)
|
||||
switch {
|
||||
case r.Method == http.MethodGet && subPath == "":
|
||||
reviewAPI.List(w, r)
|
||||
default:
|
||||
Error(w, http.StatusNotFound, "unregistered path", fmt.Errorf("method %q with subpath %q was not registered in /review", r.Method, subPath), logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (reviewAPI *MovieReviewAPI) List(w http.ResponseWriter, r *http.Request) {
|
||||
logger := reviewAPI.logger.With("method", "list")
|
||||
|
||||
movieID := r.Context().Value(MovieKey).(string)
|
||||
reviews, err := reviewAPI.repo.FindByMovieID(movieID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not get reviews", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(reviews); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not encode reviews", err, logger)
|
||||
return
|
||||
}
|
||||
}
|
|
@ -1,159 +0,0 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.ewintr.nl/emdb/storage"
|
||||
)
|
||||
|
||||
type ReviewAPI struct {
|
||||
repo *storage.ReviewRepository
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewReviewAPI(repo *storage.ReviewRepository, logger *slog.Logger) *ReviewAPI {
|
||||
return &ReviewAPI{
|
||||
repo: repo,
|
||||
logger: logger.With("api", "review"),
|
||||
}
|
||||
}
|
||||
|
||||
func (reviewAPI *ReviewAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
logger := reviewAPI.logger.With("method", "serveHTTP")
|
||||
|
||||
subPath, subTrail := ShiftPath(r.URL.Path)
|
||||
subSubPath, _ := ShiftPath(subTrail)
|
||||
switch {
|
||||
case r.Method == http.MethodGet && subPath == "":
|
||||
reviewAPI.List(w, r)
|
||||
case r.Method == http.MethodGet && subPath == "unrated" && subSubPath == "":
|
||||
reviewAPI.ListUnrated(w, r)
|
||||
case r.Method == http.MethodGet && subPath == "unrated" && subSubPath == "next":
|
||||
reviewAPI.NextUnrated(w, r)
|
||||
case r.Method == http.MethodGet && subPath == "no-titles" && subSubPath == "":
|
||||
reviewAPI.ListNoTitles(w, r)
|
||||
case r.Method == http.MethodGet && subPath == "no-titles" && subSubPath == "next":
|
||||
reviewAPI.NextNoTitles(w, r)
|
||||
case r.Method == http.MethodGet && subPath != "":
|
||||
reviewAPI.Get(w, r, subPath)
|
||||
case r.Method == http.MethodPut && subPath != "":
|
||||
reviewAPI.Store(w, r, subPath)
|
||||
default:
|
||||
Error(w, http.StatusNotFound, "unregistered path", fmt.Errorf("method %q with subpath %q was not registered in /review", r.Method, subPath), logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (reviewAPI *ReviewAPI) Get(w http.ResponseWriter, r *http.Request, id string) {
|
||||
logger := reviewAPI.logger.With("method", "get")
|
||||
|
||||
review, err := reviewAPI.repo.FindOne(id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not get review", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(review); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not encode review", err, logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (reviewAPI *ReviewAPI) List(w http.ResponseWriter, r *http.Request) {
|
||||
logger := reviewAPI.logger.With("method", "list")
|
||||
|
||||
reviews, err := reviewAPI.repo.FindAll()
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not get reviews", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(reviews); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not encode reviews", err, logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (reviewAPI *ReviewAPI) ListUnrated(w http.ResponseWriter, r *http.Request) {
|
||||
logger := reviewAPI.logger.With("method", "listUnrated")
|
||||
|
||||
reviews, err := reviewAPI.repo.FindUnrated()
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not get reviews", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(reviews); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not encode reviews", err, logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (reviewAPI *ReviewAPI) NextUnrated(w http.ResponseWriter, r *http.Request) {
|
||||
logger := reviewAPI.logger.With("method", "nextUnrated")
|
||||
|
||||
review, err := reviewAPI.repo.FindNextUnrated()
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not get review", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(review); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not encode review", err, logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (reviewAPI *ReviewAPI) ListNoTitles(w http.ResponseWriter, r *http.Request) {
|
||||
logger := reviewAPI.logger.With("method", "listNoTitles")
|
||||
|
||||
reviews, err := reviewAPI.repo.FindNoTitles()
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not get reviews", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(reviews); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not encode reviews", err, logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (reviewAPI *ReviewAPI) NextNoTitles(w http.ResponseWriter, r *http.Request) {
|
||||
logger := reviewAPI.logger.With("method", "nextNoTitles")
|
||||
|
||||
review, err := reviewAPI.repo.FindNextNoTitles()
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not get review", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(review); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not encode review", err, logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (reviewAPI *ReviewAPI) Store(w http.ResponseWriter, r *http.Request, id string) {
|
||||
logger := reviewAPI.logger.With("method", "store")
|
||||
|
||||
var review storage.Review
|
||||
if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
|
||||
Error(w, http.StatusBadRequest, "could not decode review", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
if id != review.ID {
|
||||
Error(w, http.StatusBadRequest, "id in path does not match id in body", fmt.Errorf("id in path %q does not match id in body %q", id, review.ID), logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := reviewAPI.repo.Store(review); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not store review", err, logger)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
|
@ -1,91 +0,0 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type APIIndex map[string]http.Handler
|
||||
|
||||
type Server struct {
|
||||
apiKey string
|
||||
apis map[string]http.Handler
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewServer(apiKey string, apis map[string]http.Handler, logger *slog.Logger) *Server {
|
||||
return &Server{
|
||||
apiKey: apiKey,
|
||||
apis: apis,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rec := httptest.NewRecorder() // records the response to be able to mix writing headers and content
|
||||
|
||||
// cors
|
||||
rec.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
rec.Header().Add("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
rec.Header().Add("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
rec.WriteHeader(http.StatusOK)
|
||||
returnResponse(w, rec, r, s.logger)
|
||||
return
|
||||
}
|
||||
|
||||
logger := s.logger.With("path", r.URL.Path)
|
||||
rec.Header().Add("Content-Type", "application/json")
|
||||
|
||||
// authenticate
|
||||
if key := r.Header.Get("Authorization"); s.apiKey != "localOnly" && key != s.apiKey {
|
||||
Error(rec, http.StatusUnauthorized, "unauthorized", fmt.Errorf("invalid api key"), logger)
|
||||
logger.Info("unauthorized", "key", key)
|
||||
returnResponse(w, rec, r, logger)
|
||||
return
|
||||
}
|
||||
|
||||
// route to internal
|
||||
head, tail := ShiftPath(r.URL.Path)
|
||||
if len(head) == 0 {
|
||||
Index(rec)
|
||||
returnResponse(w, rec, r, logger)
|
||||
return
|
||||
}
|
||||
api, ok := s.apis[head]
|
||||
if !ok {
|
||||
Error(rec, http.StatusNotFound, "Not found", fmt.Errorf("%s is not a valid path", r.URL.Path), logger)
|
||||
returnResponse(w, rec, r, logger)
|
||||
return
|
||||
}
|
||||
|
||||
r.URL.Path = tail
|
||||
api.ServeHTTP(rec, r)
|
||||
returnResponse(w, rec, r, logger)
|
||||
}
|
||||
|
||||
func returnResponse(w http.ResponseWriter, rec *httptest.ResponseRecorder, r *http.Request, logger *slog.Logger) {
|
||||
for k, v := range rec.Header() {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.WriteHeader(rec.Code)
|
||||
w.Write(rec.Body.Bytes())
|
||||
logger.Info("request served", "method", r.Method, "status", rec.Code)
|
||||
}
|
||||
|
||||
// ShiftPath splits off the first component of p, which will be cleaned of
|
||||
// relative components before processing. head will never contain a slash and
|
||||
// tail will always be a rooted path without trailing slash.
|
||||
// See https://blog.merovius.de/posts/2017-06-18-how-not-to-use-an-http-router/
|
||||
func ShiftPath(p string) (string, string) {
|
||||
p = path.Clean("/" + p)
|
||||
i := strings.Index(p[1:], "/") + 1
|
||||
if i <= 0 {
|
||||
return p[1:], "/"
|
||||
}
|
||||
return p[1:i], p[i:]
|
||||
}
|
|
@ -1,224 +0,0 @@
|
|||
package moviestore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type sqliteMigration string
|
||||
|
||||
var sqliteMigrations = []sqliteMigration{
|
||||
`CREATE TABLE movie (
|
||||
"id" TEXT UNIQUE NOT NULL,
|
||||
"imdb_id" TEXT NOT NULL DEFAULT "",
|
||||
"title" TEXT NOT NULL DEFAULT "",
|
||||
"english_title" TEXT NOT NULL DEFAULT "",
|
||||
"year" INTEGER NOT NULL DEFAULT 0,
|
||||
"directors" TEXT NOT NULL DEFAULT "",
|
||||
"watched_on" TEXT NOT NULL DEFAULT "",
|
||||
"rating" INTEGER NOT NULL DEFAULT 0,
|
||||
"comment" TEXT NOT NULL DEFAULT ""
|
||||
)`,
|
||||
`CREATE TABLE system ("latest_sync" INTEGER)`,
|
||||
`INSERT INTO system (latest_sync) VALUES (0)`,
|
||||
`ALTER TABLE movie ADD COLUMN tmdb_id INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE movie ADD COLUMN summary TEXT NOT NULL DEFAULT ""`,
|
||||
`BEGIN TRANSACTION;
|
||||
CREATE TABLE movie_new (
|
||||
"id" TEXT UNIQUE NOT NULL,
|
||||
"imdb_id" TEXT UNIQUE NOT NULL DEFAULT "",
|
||||
"tmdb_id" INTEGER UNIQUE NOT NULL DEFAULT 0,
|
||||
"title" TEXT NOT NULL DEFAULT "",
|
||||
"english_title" TEXT NOT NULL DEFAULT "",
|
||||
"year" INTEGER NOT NULL DEFAULT 0,
|
||||
"directors" TEXT NOT NULL DEFAULT "",
|
||||
"summary" TEXT NOT NULL DEFAULT "",
|
||||
"watched_on" TEXT NOT NULL DEFAULT "",
|
||||
"rating" INTEGER NOT NULL DEFAULT 0,
|
||||
"comment" TEXT NOT NULL DEFAULT ""
|
||||
);
|
||||
INSERT INTO movie_new (id, imdb_id, tmdb_id, title, english_title, year, directors, summary, watched_on, rating, comment)
|
||||
SELECT id, imdb_id, tmdb_id, title, english_title, year, directors, summary, watched_on, rating, comment FROM movie;
|
||||
DROP TABLE movie;
|
||||
ALTER TABLE movie_new RENAME TO movie;
|
||||
COMMIT`,
|
||||
`CREATE TABLE review (
|
||||
"id" TEXT UNIQUE NOT NULL,
|
||||
"movie_id" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL DEFAULT "",
|
||||
"url" TEXT NOT NULL DEFAULT "",
|
||||
"review" TEXT NOT NULL DEFAULT ""
|
||||
)`,
|
||||
`CREATE TABLE job_queue (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"movie_id" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL DEFAULT "",
|
||||
"status" TEXT NOT NULL DEFAULT ""
|
||||
)`,
|
||||
`PRAGMA journal_mode=WAL`,
|
||||
`INSERT INTO job_queue (movie_id, action, status)
|
||||
SELECT id, 'fetch-imdb-reviews', 'todo'
|
||||
FROM movie`,
|
||||
`AlTER TABLE review ADD COLUMN "references" TEXT NOT NULL DEFAULT ""`,
|
||||
`ALTER TABLE review ADD COLUMN "quality" INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE review DROP COLUMN "references"`,
|
||||
`ALTER TABLE review ADD COLUMN "mentions" TEXT NOT NULL DEFAULT ""`,
|
||||
`ALTER TABLE review ADD COLUMN "movie_rating" INTEGER NOT NULL DEFAULT 0`,
|
||||
`BEGIN TRANSACTION;
|
||||
CREATE TABLE job_queue_new (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"movie_id" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL DEFAULT "",
|
||||
"status" TEXT NOT NULL DEFAULT "",
|
||||
"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
INSERT INTO job_queue_new (id, movie_id, action, status)
|
||||
SELECT id, movie_id, action, status FROM job_queue;
|
||||
DROP TABLE job_queue;
|
||||
ALTER TABLE job_queue_new RENAME TO job_queue;
|
||||
COMMIT`,
|
||||
`CREATE TRIGGER set_timestamp_after_insert
|
||||
AFTER INSERT ON job_queue
|
||||
BEGIN
|
||||
UPDATE job_queue SET created_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE rowid = new.rowid;
|
||||
END;
|
||||
CREATE TRIGGER set_timestamp_after_update
|
||||
AFTER UPDATE ON job_queue
|
||||
BEGIN
|
||||
UPDATE job_queue SET updated_at = CURRENT_TIMESTAMP WHERE rowid = old.rowid;
|
||||
END;`,
|
||||
`ALTER TABLE review ADD COLUMN "mentioned_titles" JSON`,
|
||||
`ALTER TABLE review DROP COLUMN "mentions"`,
|
||||
`ALTER TABLE review DROP COLUMN "mentioned_titles"`,
|
||||
`ALTER TABLE review ADD COLUMN "mentioned_titles" JSON NOT NULL Default '{}'`,
|
||||
`BEGIN TRANSACTION;
|
||||
CREATE TABLE job_queue_new (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"action_id" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL DEFAULT "",
|
||||
"status" TEXT NOT NULL DEFAULT "",
|
||||
"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
INSERT INTO job_queue_new (id, action_id, action, status)
|
||||
SELECT id, movie_id, action, status FROM job_queue;
|
||||
DROP TABLE job_queue;
|
||||
ALTER TABLE job_queue_new RENAME TO job_queue;
|
||||
COMMIT`,
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidConfiguration = errors.New("invalid configuration")
|
||||
ErrIncompatibleSQLMigration = errors.New("incompatible migration")
|
||||
ErrNotEnoughSQLMigrations = errors.New("already more migrations than wanted")
|
||||
ErrSqliteFailure = errors.New("sqlite returned an error")
|
||||
)
|
||||
|
||||
type SQLite struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLite(dbPath string) (*SQLite, error) {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return &SQLite{}, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(fmt.Sprintf("PRAGMA busy_timeout=%d;", 5*time.Second))
|
||||
|
||||
s := &SQLite{
|
||||
db: db,
|
||||
}
|
||||
|
||||
//if err := s.migrate(sqliteMigrations); err != nil {
|
||||
// return &SQLite{}, err
|
||||
//}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) Exec(query string, args ...any) (sql.Result, error) {
|
||||
return s.db.Exec(query, args...)
|
||||
}
|
||||
|
||||
func (s *SQLite) QueryRow(query string, args ...any) *sql.Row {
|
||||
return s.db.QueryRow(query, args...)
|
||||
}
|
||||
|
||||
func (s *SQLite) Query(query string, args ...any) (*sql.Rows, error) {
|
||||
return s.db.Query(query, args...)
|
||||
}
|
||||
|
||||
//func (s *SQLite) migrate(wanted []sqliteMigration) error {
|
||||
// // admin table
|
||||
// if _, err := s.db.Exec(`
|
||||
//CREATE TABLE IF NOT EXISTS migration
|
||||
//("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "query" TEXT)
|
||||
//`); err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// // find existing
|
||||
// rows, err := s.db.Query(`SELECT query FROM migration ORDER BY id`)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
|
||||
// }
|
||||
//
|
||||
// existing := []sqliteMigration{}
|
||||
// for rows.Next() {
|
||||
// var query string
|
||||
// if err := rows.Scan(&query); err != nil {
|
||||
// return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
|
||||
// }
|
||||
// existing = append(existing, sqliteMigration(query))
|
||||
// }
|
||||
// rows.Close()
|
||||
//
|
||||
// // compare
|
||||
// missing, err := compareMigrations(wanted, existing)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
|
||||
// }
|
||||
//
|
||||
// // execute missing
|
||||
// for _, query := range missing {
|
||||
// if _, err := s.db.Exec(string(query)); err != nil {
|
||||
// return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
|
||||
// }
|
||||
//
|
||||
// // register
|
||||
// if _, err := s.db.Exec(`
|
||||
//INSERT INTO migration
|
||||
//(query) VALUES (?)
|
||||
//`, query); err != nil {
|
||||
// return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
//}
|
||||
//
|
||||
//func compareMigrations(wanted, existing []sqliteMigration) ([]sqliteMigration, error) {
|
||||
// needed := []sqliteMigration{}
|
||||
// if len(wanted) < len(existing) {
|
||||
// return []sqliteMigration{}, ErrNotEnoughSQLMigrations
|
||||
// }
|
||||
//
|
||||
// for i, want := range wanted {
|
||||
// switch {
|
||||
// case i >= len(existing):
|
||||
// needed = append(needed, want)
|
||||
// case want == existing[i]:
|
||||
// // do nothing
|
||||
// case want != existing[i]:
|
||||
// return []sqliteMigration{}, fmt.Errorf("%w: %v", ErrIncompatibleSQLMigration, want)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return needed, nil
|
||||
//}
|
|
@ -1,56 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"code.ewintr.nl/emdb/client"
|
||||
"code.ewintr.nl/emdb/cmd/api-service/handler"
|
||||
"code.ewintr.nl/emdb/cmd/api-service/moviestore"
|
||||
job2 "code.ewintr.nl/emdb/job"
|
||||
"code.ewintr.nl/emdb/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
port = flag.Int("port", 8085, "port to listen on")
|
||||
dbPath = flag.String("dbpath", "test.db", "path to sqlite db")
|
||||
apiKey = flag.String("apikey", "hoi", "api key to use")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger.Info("starting server", "port", *port, "dbPath", *dbPath)
|
||||
|
||||
db, err := moviestore.NewSQLite(*dbPath)
|
||||
if err != nil {
|
||||
fmt.Printf("could not create new sqlite repo: %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
jobQueue := job2.NewJobQueue(db, logger)
|
||||
worker := job2.NewWorker(jobQueue, storage.NewMovieRepository(db), storage.NewReviewRepository(db), client.NewIMDB(), logger)
|
||||
go worker.Run()
|
||||
|
||||
apis := handler.APIIndex{
|
||||
"job": handler.NewJobAPI(jobQueue, logger),
|
||||
"movie": handler.NewMovieAPI(handler.APIIndex{
|
||||
"review": handler.NewMovieReviewAPI(storage.NewReviewRepository(db), logger),
|
||||
}, storage.NewMovieRepository(db), jobQueue, logger),
|
||||
"review": handler.NewReviewAPI(storage.NewReviewRepository(db), logger),
|
||||
}
|
||||
|
||||
go http.ListenAndServe(fmt.Sprintf(":%d", *port), handler.NewServer(*apiKey, apis, logger))
|
||||
logger.Info("server started")
|
||||
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-c
|
||||
|
||||
logger.Info("server stopped")
|
||||
}
|
|
@ -4,7 +4,6 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.ewintr.nl/emdb/cmd/api-service/moviestore"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
@ -53,7 +52,7 @@ SET
|
|||
rating = EXCLUDED.rating,
|
||||
comment = EXCLUDED.comment;`,
|
||||
m.ID, m.TMDBID, m.IMDBID, m.Title, m.EnglishTitle, m.Year, directors, m.Summary, m.WatchedOn, m.Rating, m.Comment); err != nil {
|
||||
return fmt.Errorf("%w: %v", moviestore.ErrSqliteFailure, err)
|
||||
return fmt.Errorf("%w: %v", ErrPostgresqlFailure, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -61,7 +60,7 @@ SET
|
|||
|
||||
func (mr *MovieRepository) Delete(id string) error {
|
||||
if _, err := mr.db.Exec(`DELETE FROM movie WHERE id=$1`, id); err != nil {
|
||||
return fmt.Errorf("%w: %v", moviestore.ErrSqliteFailure, err)
|
||||
return fmt.Errorf("%w: %v", ErrPostgresqlFailure, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -81,7 +80,7 @@ WHERE id=$1`, id)
|
|||
}
|
||||
var directors string
|
||||
if err := row.Scan(&m.ID, &m.TMDBID, &m.IMDBID, &m.Title, &m.EnglishTitle, &m.Year, &directors, &m.Summary, &m.WatchedOn, &m.Rating, &m.Comment); err != nil {
|
||||
return Movie{}, fmt.Errorf("%w: %w", moviestore.ErrSqliteFailure, err)
|
||||
return Movie{}, fmt.Errorf("%w: %w", ErrPostgresqlFailure, err)
|
||||
}
|
||||
m.Directors = strings.Split(directors, ",")
|
||||
|
||||
|
@ -93,7 +92,7 @@ func (mr *MovieRepository) FindAll() ([]Movie, error) {
|
|||
SELECT id, tmdb_id, imdb_id, title, english_title, year, directors, summary, watched_on, rating, comment
|
||||
FROM movie`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", moviestore.ErrSqliteFailure, err)
|
||||
return nil, fmt.Errorf("%w: %v", ErrPostgresqlFailure, err)
|
||||
}
|
||||
|
||||
movies := make([]Movie, 0)
|
||||
|
@ -102,7 +101,7 @@ FROM movie`)
|
|||
m := Movie{}
|
||||
var directors string
|
||||
if err := rows.Scan(&m.ID, &m.TMDBID, &m.IMDBID, &m.Title, &m.EnglishTitle, &m.Year, &directors, &m.Summary, &m.WatchedOn, &m.Rating, &m.Comment); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", moviestore.ErrSqliteFailure, err)
|
||||
return nil, fmt.Errorf("%w: %v", ErrPostgresqlFailure, err)
|
||||
}
|
||||
m.Directors = strings.Split(directors, ",")
|
||||
movies = append(movies, m)
|
||||
|
|
|
@ -2,12 +2,18 @@ package storage
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.ewintr.nl/emdb/cmd/api-service/moviestore"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPostgresqlFailure = errors.New("postgresql failure")
|
||||
ErrNotEnoughSQLMigrations = errors.New("not enough sql migrations")
|
||||
ErrIncompatibleSQLMigration = errors.New("incompatible sql migration")
|
||||
)
|
||||
|
||||
type migration string
|
||||
|
||||
var migrations = []migration{
|
||||
|
@ -95,14 +101,14 @@ CREATE TABLE IF NOT EXISTS migration
|
|||
// find existing
|
||||
rows, err := pg.db.Query(`SELECT query FROM migration ORDER BY id`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", moviestore.ErrSqliteFailure, err)
|
||||
return fmt.Errorf("%w: %v", ErrPostgresqlFailure, err)
|
||||
}
|
||||
|
||||
existing := []migration{}
|
||||
for rows.Next() {
|
||||
var query string
|
||||
if err := rows.Scan(&query); err != nil {
|
||||
return fmt.Errorf("%w: %v", moviestore.ErrSqliteFailure, err)
|
||||
return fmt.Errorf("%w: %v", ErrPostgresqlFailure, err)
|
||||
}
|
||||
existing = append(existing, migration(query))
|
||||
}
|
||||
|
@ -111,13 +117,13 @@ CREATE TABLE IF NOT EXISTS migration
|
|||
// compare
|
||||
missing, err := compareMigrations(wanted, existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", moviestore.ErrSqliteFailure, err)
|
||||
return fmt.Errorf("%w: %v", ErrPostgresqlFailure, err)
|
||||
}
|
||||
|
||||
// execute missing
|
||||
for _, query := range missing {
|
||||
if _, err := pg.db.Exec(string(query)); err != nil {
|
||||
return fmt.Errorf("%w: %v", moviestore.ErrSqliteFailure, err)
|
||||
return fmt.Errorf("%w: %v", ErrPostgresqlFailure, err)
|
||||
}
|
||||
|
||||
// register
|
||||
|
@ -125,7 +131,7 @@ CREATE TABLE IF NOT EXISTS migration
|
|||
INSERT INTO migration
|
||||
(query) VALUES ($1)
|
||||
`, query); err != nil {
|
||||
return fmt.Errorf("%w: %v", moviestore.ErrSqliteFailure, err)
|
||||
return fmt.Errorf("%w: %v", ErrPostgresqlFailure, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -147,7 +153,7 @@ func (pg *Postgres) Query(query string, args ...any) (*sql.Rows, error) {
|
|||
func compareMigrations(wanted, existing []migration) ([]migration, error) {
|
||||
needed := []migration{}
|
||||
if len(wanted) < len(existing) {
|
||||
return []migration{}, moviestore.ErrNotEnoughSQLMigrations
|
||||
return []migration{}, ErrNotEnoughSQLMigrations
|
||||
}
|
||||
|
||||
for i, want := range wanted {
|
||||
|
@ -157,7 +163,7 @@ func compareMigrations(wanted, existing []migration) ([]migration, error) {
|
|||
case want == existing[i]:
|
||||
// do nothing
|
||||
case want != existing[i]:
|
||||
return []migration{}, fmt.Errorf("%w: %v", moviestore.ErrIncompatibleSQLMigration, want)
|
||||
return []migration{}, fmt.Errorf("%w: %v", ErrIncompatibleSQLMigration, want)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue