diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..819668a Binary files /dev/null and b/.DS_Store differ diff --git a/projects/.DS_Store b/projects/.DS_Store new file mode 100644 index 0000000..b8e20a2 Binary files /dev/null and b/projects/.DS_Store differ diff --git a/projects/greenlight/.DS_Store b/projects/greenlight/.DS_Store index a17f376..de5fc53 100644 Binary files a/projects/greenlight/.DS_Store and b/projects/greenlight/.DS_Store differ diff --git a/projects/greenlight/cmd/api/errors.go b/projects/greenlight/cmd/api/errors.go index 308256f..97fe43f 100644 --- a/projects/greenlight/cmd/api/errors.go +++ b/projects/greenlight/cmd/api/errors.go @@ -63,3 +63,8 @@ func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http. func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) { app.errorResponse(w, r, http.StatusBadRequest, err.Error()) } + +func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) { + message := "unable to update the record due to an edit conflict, please try again" + app.errorResponse(w, r, http.StatusConflict, message) +} diff --git a/projects/greenlight/cmd/api/movies.go b/projects/greenlight/cmd/api/movies.go index fb42048..4d4fb81 100644 --- a/projects/greenlight/cmd/api/movies.go +++ b/projects/greenlight/cmd/api/movies.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "greenlight.debuggingjon.dev/internal/data" "greenlight.debuggingjon.dev/internal/validator" @@ -93,15 +94,13 @@ func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) } func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Request) { - // Extract the movie ID from the URL. id, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } - // Fetch the existing movie record from the database, sending a 404 Not Found - // response to the client if we couldn't find a matching record. + // Retrieve the movie record as normal. movie, err := app.models.Movies.Get(id) if err != nil { switch { @@ -112,30 +111,51 @@ func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Reques } return } - // Declare an input struct to hold the expected data from the client. - var input struct { - Title string `json:"title"` - Year int32 `json:"year"` - Runtime data.Runtime `json:"runtime"` - Genres []string `json:"genres"` + // If the request contains a X-Expected-Version header, verify that the movie + // version in the database matches the expected version specified in the header. + if r.Header.Get("X-Expected-Version") != "" { + if strconv.FormatInt(int64(movie.Version), 32) != r.Header.Get("X-Expected-Version") { + app.editConflictResponse(w, r) + return + } } - // Read the JSON request body data into the input struct. + // Use pointers for the Title, Year and Runtime fields. + var input struct { + Title *string `json:"title"` + Year *int32 `json:"year"` + Runtime *data.Runtime `json:"runtime"` + Genres []string `json:"genres"` + } + + // Decode the JSON as normal. err = app.readJSON(w, r, &input) if err != nil { app.badRequestResponse(w, r, err) return } - // Copy the values from the request body to the appropriate fields of the movie - // record. - movie.Title = input.Title - movie.Year = input.Year - movie.Runtime = input.Runtime - movie.Genres = input.Genres + // If the input.Title value is nil then we know that no corresponding "title" key/ + // value pair was provided in the JSON request body. So we move on and leave the + // movie record unchanged. Otherwise, we update the movie record with the new title + // value. Importantly, because input.Title is a now a pointer to a string, we need + // to dereference the pointer using the * operator to get the underlying value + // before assigning it to our movie record. + if input.Title != nil { + movie.Title = *input.Title + } + + // We also do the same for the other fields in the input struct. + if input.Year != nil { + movie.Year = *input.Year + } + if input.Runtime != nil { + movie.Runtime = *input.Runtime + } + if input.Genres != nil { + movie.Genres = input.Genres // Note that we don't need to dereference a slice. + } - // Validate the updated movie record, sending the client a 422 Unprocessable Entity - // response if any checks fail. v := validator.New() if data.ValidateMovie(v, movie); !v.Valid() { @@ -143,14 +163,19 @@ func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Reques return } - // Pass the updated movie record to our new Update() method. + // Intercept any ErrEditConflict error and call the new editConflictResponse() + // helper. err = app.models.Movies.Update(movie) if err != nil { - app.serverErrorResponse(w, r, err) + switch { + case errors.Is(err, data.ErrEditConflict): + app.editConflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } return } - // Write the updated movie record in a JSON response. err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil) if err != nil { app.serverErrorResponse(w, r, err) diff --git a/projects/greenlight/cmd/api/routes.go b/projects/greenlight/cmd/api/routes.go index 5b01ef6..2950a5f 100644 --- a/projects/greenlight/cmd/api/routes.go +++ b/projects/greenlight/cmd/api/routes.go @@ -21,7 +21,7 @@ func (app *application) routes() *httprouter.Router { router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler) router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler) - router.HandlerFunc(http.MethodPut, "/v1/movies/:id", app.updateMovieHandler) + router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler) router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler) return router diff --git a/projects/greenlight/docs/.DS_Store b/projects/greenlight/docs/.DS_Store new file mode 100644 index 0000000..55f1f5f Binary files /dev/null and b/projects/greenlight/docs/.DS_Store differ diff --git a/projects/greenlight/docs/api/greenlight-bruno/environments/Development.yml b/projects/greenlight/docs/api/greenlight-bruno/environments/Development.yml index c21d9c7..fdeaeb1 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/environments/Development.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/environments/Development.yml @@ -5,4 +5,4 @@ variables: - name: VERSION value: v1 - name: MOVIE_ID - value: "8" + value: "1" diff --git a/projects/greenlight/internal/data/models.go b/projects/greenlight/internal/data/models.go index 645eac4..13674e8 100644 --- a/projects/greenlight/internal/data/models.go +++ b/projects/greenlight/internal/data/models.go @@ -9,6 +9,7 @@ import ( // looking up a movie that doesn't exist in our database. var ( ErrRecordNotFound = errors.New("record not found") + ErrEditConflict = errors.New("edit conflict") ) // Create a Models struct which wraps the MovieModel. We'll add other models to this, diff --git a/projects/greenlight/internal/data/movies.go b/projects/greenlight/internal/data/movies.go index 98d811a..972e6db 100644 --- a/projects/greenlight/internal/data/movies.go +++ b/projects/greenlight/internal/data/movies.go @@ -86,7 +86,7 @@ func (m MovieModel) Update(movie *Movie) error { query := ` UPDATE movies SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1 - WHERE id = $5 + WHERE id = $5 AND version = $6 RETURNING version` // Create an args slice containing the values for the placeholder parameters. @@ -96,11 +96,22 @@ func (m MovieModel) Update(movie *Movie) error { movie.Runtime, pq.Array(movie.Genres), movie.ID, + movie.Version, } // Use the QueryRow() method to execute the query, passing in the args slice as a // variadic parameter and scanning the new version value into the movie struct. - return m.DB.QueryRow(query, args...).Scan(&movie.Version) + err := m.DB.QueryRow(query, args...).Scan(&movie.Version) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return ErrEditConflict + default: + return err + } + } + + return nil } func (m MovieModel) Delete(id int64) error {