feat: update changes, race condition check on version, put -> patch

This commit is contained in:
2026-03-17 16:26:33 +01:00
parent bcf16bc4c6
commit 973a9e341d
10 changed files with 67 additions and 25 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+5
View File
@@ -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) { func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
app.errorResponse(w, r, http.StatusBadRequest, 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)
}
+46 -21
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"greenlight.debuggingjon.dev/internal/data" "greenlight.debuggingjon.dev/internal/data"
"greenlight.debuggingjon.dev/internal/validator" "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) { func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Request) {
// Extract the movie ID from the URL.
id, err := app.readIDParam(r) id, err := app.readIDParam(r)
if err != nil { if err != nil {
app.notFoundResponse(w, r) app.notFoundResponse(w, r)
return return
} }
// Fetch the existing movie record from the database, sending a 404 Not Found // Retrieve the movie record as normal.
// response to the client if we couldn't find a matching record.
movie, err := app.models.Movies.Get(id) movie, err := app.models.Movies.Get(id)
if err != nil { if err != nil {
switch { switch {
@@ -112,30 +111,51 @@ func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Reques
} }
return return
} }
// Declare an input struct to hold the expected data from the client. // If the request contains a X-Expected-Version header, verify that the movie
var input struct { // version in the database matches the expected version specified in the header.
Title string `json:"title"` if r.Header.Get("X-Expected-Version") != "" {
Year int32 `json:"year"` if strconv.FormatInt(int64(movie.Version), 32) != r.Header.Get("X-Expected-Version") {
Runtime data.Runtime `json:"runtime"` app.editConflictResponse(w, r)
Genres []string `json:"genres"` 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) err = app.readJSON(w, r, &input)
if err != nil { if err != nil {
app.badRequestResponse(w, r, err) app.badRequestResponse(w, r, err)
return return
} }
// Copy the values from the request body to the appropriate fields of the movie // If the input.Title value is nil then we know that no corresponding "title" key/
// record. // value pair was provided in the JSON request body. So we move on and leave the
movie.Title = input.Title // movie record unchanged. Otherwise, we update the movie record with the new title
movie.Year = input.Year // value. Importantly, because input.Title is a now a pointer to a string, we need
movie.Runtime = input.Runtime // to dereference the pointer using the * operator to get the underlying value
movie.Genres = input.Genres // 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() v := validator.New()
if data.ValidateMovie(v, movie); !v.Valid() { if data.ValidateMovie(v, movie); !v.Valid() {
@@ -143,14 +163,19 @@ func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Reques
return 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) err = app.models.Movies.Update(movie)
if err != nil { 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 return
} }
// Write the updated movie record in a JSON response.
err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil) err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
if err != nil { if err != nil {
app.serverErrorResponse(w, r, err) app.serverErrorResponse(w, r, err)
+1 -1
View File
@@ -21,7 +21,7 @@ func (app *application) routes() *httprouter.Router {
router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler) router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler)
router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler) 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) router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler)
return router return router
Binary file not shown.
@@ -5,4 +5,4 @@ variables:
- name: VERSION - name: VERSION
value: v1 value: v1
- name: MOVIE_ID - name: MOVIE_ID
value: "8" value: "1"
@@ -9,6 +9,7 @@ import (
// looking up a movie that doesn't exist in our database. // looking up a movie that doesn't exist in our database.
var ( var (
ErrRecordNotFound = errors.New("record not found") 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, // Create a Models struct which wraps the MovieModel. We'll add other models to this,
+13 -2
View File
@@ -86,7 +86,7 @@ func (m MovieModel) Update(movie *Movie) error {
query := ` query := `
UPDATE movies UPDATE movies
SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1 SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1
WHERE id = $5 WHERE id = $5 AND version = $6
RETURNING version` RETURNING version`
// Create an args slice containing the values for the placeholder parameters. // Create an args slice containing the values for the placeholder parameters.
@@ -96,11 +96,22 @@ func (m MovieModel) Update(movie *Movie) error {
movie.Runtime, movie.Runtime,
pq.Array(movie.Genres), pq.Array(movie.Genres),
movie.ID, movie.ID,
movie.Version,
} }
// Use the QueryRow() method to execute the query, passing in the args slice as a // 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. // 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 { func (m MovieModel) Delete(id int64) error {