From 2936ce24393e8aa5eafcacd922716a56ecd6be15 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 7 Sep 2025 16:09:59 -0400 Subject: [PATCH] Updating movie --- Makefile | 4 ++ NOTES.md | 13 ++++- cmd/api/handlers.go | 107 ++++++++++++++++++++++++++++++++-------- cmd/api/helpers.go | 14 ++++++ cmd/api/main.go | 6 ++- cmd/api/routes.go | 1 + internal/data/models.go | 22 +++++++++ internal/data/movies.go | 84 ++++++++++++++++++++++++++++++- 8 files changed, 228 insertions(+), 23 deletions(-) create mode 100644 internal/data/models.go diff --git a/Makefile b/Makefile index 775c848..5940591 100644 --- a/Makefile +++ b/Makefile @@ -46,6 +46,10 @@ start-local: UID=$$(id -u) GID=$$(id -g) docker compose up -d .PHONY: start-local +db-local: + docker exec -it pulleydb psql postgres://pulley:passwd@localhost:5432/pulley +.PHONY: db-local + stop-local: docker compose down .PHONY: stop-local diff --git a/NOTES.md b/NOTES.md index e4ea06a..9603952 100644 --- a/NOTES.md +++ b/NOTES.md @@ -7,4 +7,15 @@ ## Creating a movie -curl -i -X POST -d '{"title":"Moana","year":2016,"runtime":107, "genres":["animation","adventure"]}' http://0.0.0.0:5002/v1/movies \ No newline at end of file +```bash +curl -i -X POST -d '{"title":"Moana","year":2016,"runtime":"107 mins", "genres":["animation","adventure"]}' http://0.0.0.0:5002/v1/movies +``` + +```bash +curl -i -X POST -d '{"title":"The Batman","year":2022,"runtime":"177 mins", "genres":["action","adventure"]}' http://0.0.0.0:5002/v1/movies +``` + + +```bash +curl -i -X POST -d '{"title":"Death of a Unicorn","year":2025,"runtime":"126 mins", "genres":["comedy","satire"]}' http://0.0.0.0:5002/v1/movies +``` diff --git a/cmd/api/handlers.go b/cmd/api/handlers.go index c17b112..c9ac897 100644 --- a/cmd/api/handlers.go +++ b/cmd/api/handlers.go @@ -1,14 +1,12 @@ package main import ( + "errors" "fmt" "net/http" - "strconv" - "time" "git.runcible.io/learning/pulley/internal/data" "git.runcible.io/learning/pulley/internal/validator" - "github.com/julienschmidt/httprouter" ) func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) { @@ -49,13 +47,23 @@ func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Reques app.failedValidationResponse(w, r, v.Errors) } - // TODO save to DB + // We can implement a timeout context if we would like here + ctx := r.Context() - // Dump the contents of the input struct in a HTTP response - // +v is the default format value plus field names for structs - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, "%+v\n", input) + err = app.models.Movies.Insert(ctx, m) + if err != nil { + app.serverErrorResponse(w, r, err) + } + // Include location headers in HTTP response for newly created resource + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/movies/%d", m.ID)) + + // Write a JSON 201 Response with movie data in the response body and Location header + err = app.writeJSON(w, http.StatusCreated, envelope{"movie": m}, headers) + if err != nil { + app.serverErrorResponse(w, r, err) + } } func (app *application) getMovieHandler(w http.ResponseWriter, r *http.Request) { @@ -64,25 +72,28 @@ func (app *application) getMovieHandler(w http.ResponseWriter, r *http.Request) // retrieve a slice containing these parameter names and values. // TODO refactor id retrieval to an app.readIDParam receiver - params := httprouter.ParamsFromContext(r.Context()) + // params := httprouter.ParamsFromContext(r.Context()) + + // id, err := strconv.ParseInt(params.ByName("id"), 10, 64) - id, err := strconv.ParseInt(params.ByName("id"), 10, 64) + id, err := app.readIDParam(r) - if err != nil || id < 1 { - // http.NotFound(w, r) + if err != nil { app.notFoundResponse(w, r) return } - movie := data.Movie{ - ID: id, - CreatedAt: time.Now(), - Title: "Eisenhorn", - // Runtime: 102, - Genres: []string{"sci-fi", "action"}, - Version: 1, + movie, err := app.models.Movies.Get(r.Context(), id) + if err != nil { + switch { + case errors.Is(err, data.ErrRecordNotFound): + app.notFoundResponse(w, r) + return + default: + app.serverErrorResponse(w, r, err) + return + } } - app.logger.Info("Hit the get movies and found", "movie", movie.Title) err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil) if err != nil { @@ -92,6 +103,62 @@ func (app *application) getMovieHandler(w http.ResponseWriter, r *http.Request) } } +func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Request) { + + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + + movie, err := app.models.Movies.Get(r.Context(), id) + if err != nil { + switch { + case errors.Is(err, data.ErrRecordNotFound): + app.notFoundResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + var input struct { + Title string `json:"title"` + Year int32 `json:"year"` + Runtime data.Runtime `json:"runtime"` + Genres []string `json:"genres"` + } + + err = app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + movie.Title = input.Title + movie.Year = input.Year + movie.Runtime = input.Runtime + movie.Genres = input.Genres + + v := validator.New() + + if validator.ValidateMovie(v, movie); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + err = app.models.Movies.Update(r.Context(), movie) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + func (app *application) healthCheckHandler(w http.ResponseWriter, r *http.Request) { // JSON has to be double quoted. FYI you would never really do this // js := `{"status": "available", "environment": %q, "version": %q}` diff --git a/cmd/api/helpers.go b/cmd/api/helpers.go index 5646526..56db86c 100644 --- a/cmd/api/helpers.go +++ b/cmd/api/helpers.go @@ -10,7 +10,10 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" + + "github.com/julienschmidt/httprouter" ) type envelope map[string]any @@ -142,3 +145,14 @@ func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any return nil } + +func (a *application) readIDParam(r *http.Request) (int64, error) { + params := httprouter.ParamsFromContext(r.Context()) + + id, err := strconv.ParseInt(params.ByName("id"), 10, 64) + if id < 1 { + return id, fmt.Errorf("integer less than 1") + } + + return id, err +} diff --git a/cmd/api/main.go b/cmd/api/main.go index 954f1dc..e68da6d 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -16,6 +16,7 @@ import ( "time" "git.runcible.io/learning/pulley/internal/config" + "git.runcible.io/learning/pulley/internal/data" "git.runcible.io/learning/pulley/internal/database" "git.runcible.io/learning/pulley/internal/logging" "git.runcible.io/learning/pulley/migrations" @@ -32,6 +33,7 @@ const Version = "1.0.0" type application struct { config config.ServiceConfig logger *slog.Logger + models data.Models } func run(ctx context.Context, w io.Writer, args []string) error { @@ -90,7 +92,9 @@ func run(ctx context.Context, w io.Writer, args []string) error { pool.Close() }() - app := application{config: cfg, logger: logger} + models := data.NewModels(pool) + + app := application{config: cfg, logger: logger, models: models} srv := &http.Server{ Addr: fmt.Sprintf("%s:%d", "0.0.0.0", app.config.Port), diff --git a/cmd/api/routes.go b/cmd/api/routes.go index 6d32d8b..054cdd1 100644 --- a/cmd/api/routes.go +++ b/cmd/api/routes.go @@ -25,6 +25,7 @@ func (app *application) routes() http.Handler { router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthCheckHandler) router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler) router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.getMovieHandler) + router.HandlerFunc(http.MethodPut, "/v1/movies/:id", app.updateMovieHandler) // middleware return app.recoverPanic(router) diff --git a/internal/data/models.go b/internal/data/models.go new file mode 100644 index 0000000..7784a9e --- /dev/null +++ b/internal/data/models.go @@ -0,0 +1,22 @@ +package data + +import ( + "errors" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrRecordNotFound = errors.New("record not found") +) + +// Models is a wrapper around all model repository objects +type Models struct { + Movies MovieModel +} + +func NewModels(pool *pgxpool.Pool) Models { + return Models{ + Movies: MovieModel{pool: pool}, + } +} diff --git a/internal/data/movies.go b/internal/data/movies.go index c080a19..da461b5 100644 --- a/internal/data/movies.go +++ b/internal/data/movies.go @@ -1,6 +1,13 @@ package data -import "time" +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) // MUST export fields to serialize them @@ -19,3 +26,78 @@ type Movie struct { Genres []string `json:"genres,omitzero"` Version int32 `json:"version"` } + +type MovieModel struct { + pool *pgxpool.Pool +} + +func (m MovieModel) Insert(ctx context.Context, movie *Movie) error { + query := ` + INSERT INTO movies (title, year, runtime, genres) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at, version` + + args := []any{movie.Title, movie.Year, movie.Runtime, movie.Genres} + + row := m.pool.QueryRow(ctx, query, args...) + // Insert is mutating the Movie struct + err := row.Scan(&movie.ID, &movie.CreatedAt, &movie.Version) + return err +} + +func (m MovieModel) Get(ctx context.Context, id int64) (*Movie, error) { + // safety validation + if id < 1 { + return nil, ErrRecordNotFound + } + + query := ` + SELECT id, created_at, title, year, runtime, genres, version + FROM movies + WHERE id = $1 + ` + var movie Movie + + err := m.pool.QueryRow(ctx, query, id).Scan( + &movie.ID, + &movie.CreatedAt, + &movie.Title, + &movie.Year, + &movie.Runtime, + &movie.Genres, + &movie.Version, + ) + if err != nil { + switch { + case errors.Is(err, pgx.ErrNoRows): + return nil, ErrRecordNotFound + default: + return nil, err + } + } + + return &movie, nil +} + +func (m MovieModel) Update(ctx context.Context, movie *Movie) error { + query := ` + UPDATE movies + SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1 + WHERE id = $5 + RETURNING version + ` + + args := []any{ + movie.Title, + movie.Year, + movie.Runtime, + movie.Genres, + movie.ID, + } + + return m.pool.QueryRow(ctx, query, args...).Scan(&movie.Version) +} + +func (m MovieModel) Delete(ctx context.Context, id int64) error { + return nil +}