diff --git a/cmd/api-service/handler/handler.go b/cmd/api-service/handler/handler.go deleted file mode 100644 index 9784c0a..0000000 --- a/cmd/api-service/handler/handler.go +++ /dev/null @@ -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)) -} diff --git a/cmd/api-service/handler/job2.go b/cmd/api-service/handler/job2.go deleted file mode 100644 index f4bc66e..0000000 --- a/cmd/api-service/handler/job2.go +++ /dev/null @@ -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) -} diff --git a/cmd/api-service/handler/movie.go b/cmd/api-service/handler/movie.go deleted file mode 100644 index 547e3bd..0000000 --- a/cmd/api-service/handler/movie.go +++ /dev/null @@ -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)) -} diff --git a/cmd/api-service/handler/moviereview.go b/cmd/api-service/handler/moviereview.go deleted file mode 100644 index ca5c2fd..0000000 --- a/cmd/api-service/handler/moviereview.go +++ /dev/null @@ -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 - } -} diff --git a/cmd/api-service/handler/review.go b/cmd/api-service/handler/review.go deleted file mode 100644 index bd5000b..0000000 --- a/cmd/api-service/handler/review.go +++ /dev/null @@ -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) -} diff --git a/cmd/api-service/handler/server.go b/cmd/api-service/handler/server.go deleted file mode 100644 index dab3062..0000000 --- a/cmd/api-service/handler/server.go +++ /dev/null @@ -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:] -} diff --git a/cmd/api-service/moviestore/sqlite.go b/cmd/api-service/moviestore/sqlite.go deleted file mode 100644 index 900ac09..0000000 --- a/cmd/api-service/moviestore/sqlite.go +++ /dev/null @@ -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 -//} diff --git a/cmd/api-service/service.go b/cmd/api-service/service.go deleted file mode 100644 index 2a76968..0000000 --- a/cmd/api-service/service.go +++ /dev/null @@ -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") -} diff --git a/storage/movie.go b/storage/movie.go index 87c0408..47ac269 100644 --- a/storage/movie.go +++ b/storage/movie.go @@ -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) diff --git a/storage/postgres.go b/storage/postgres.go index 4e31d5a..ea2ad51 100644 --- a/storage/postgres.go +++ b/storage/postgres.go @@ -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) } }