diff --git a/NOTES.md b/NOTES.md index 546d9fb..ce3996b 100644 --- a/NOTES.md +++ b/NOTES.md @@ -31,4 +31,12 @@ curl -i -X POST -d '{"title":"Death of a Unicorn","year":2025,"runtime":"126 min ```bash BODY='{"title":"Black Panther","year":2018,"runtime":"134 mins","genres":["sci-fi","action","adventure"]}' curl -X PUT -d "$BODY" http://0.0.0.0:5002/v1/movies/1 +``` + +## Generating an Update Conflict + +We can try to use the xargs tool to submit multiple requests to our webserver. Hopefully this results in the 409 that we are looking to create. + +```bash +xargs -I % -P8 curl -X PATCH -d '{"runtime": "97 mins"}' "localhost:5002/v1/movies/4" < <(printf '%s\n' {1..8}) ``` \ No newline at end of file diff --git a/cmd/api/errors.go b/cmd/api/errors.go index a8fc9d9..b1cd733 100644 --- a/cmd/api/errors.go +++ b/cmd/api/errors.go @@ -64,3 +64,8 @@ func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Reques func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { app.errorResponse(w, r, http.StatusUnprocessableEntity, errors) } + +func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) { + message := "unable to update the record due to an edit conflict, please review new version and try again" + app.errorResponse(w, r, http.StatusConflict, message) +} diff --git a/cmd/api/handlers.go b/cmd/api/handlers.go index f899ca4..f736228 100644 --- a/cmd/api/handlers.go +++ b/cmd/api/handlers.go @@ -122,11 +122,20 @@ func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Reques return } + // var input struct { + // Title string `json:"title"` + // Year int32 `json:"year"` + // Runtime data.Runtime `json:"runtime"` + // Genres []string `json:"genres"` + // } + + // Choosing to use pointers to allow us to determine if the zero value was sent + // by the client or if it was excluded from the client submission var input struct { - Title string `json:"title"` - Year int32 `json:"year"` - Runtime data.Runtime `json:"runtime"` - Genres []string `json:"genres"` + Title *string `json:"title"` + Year *int32 `json:"year"` + Runtime *data.Runtime `json:"runtime"` + Genres []string `json:"genres"` } err = app.readJSON(w, r, &input) @@ -135,10 +144,19 @@ func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Reques return } - movie.Title = input.Title - movie.Year = input.Year - movie.Runtime = input.Runtime - movie.Genres = input.Genres + if input.Title != nil { + movie.Title = *input.Title + } + if input.Year != nil { + movie.Year = *input.Year + } + if input.Runtime != nil { + movie.Runtime = *input.Runtime + } + + if input.Genres != nil { + movie.Genres = input.Genres + } v := validator.New() @@ -149,7 +167,12 @@ func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Reques err = app.models.Movies.Update(r.Context(), 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 } diff --git a/cmd/api/routes.go b/cmd/api/routes.go index 6314288..c121bda 100644 --- a/cmd/api/routes.go +++ b/cmd/api/routes.go @@ -25,7 +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) + router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler) router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler) // middleware diff --git a/internal/data/models.go b/internal/data/models.go index 7784a9e..ada0794 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -8,6 +8,7 @@ import ( var ( ErrRecordNotFound = errors.New("record not found") + ErrEditConflict = errors.New("edit conflict") ) // Models is a wrapper around all model repository objects diff --git a/internal/data/movies.go b/internal/data/movies.go index 5b75bda..129fe6e 100644 --- a/internal/data/movies.go +++ b/internal/data/movies.go @@ -81,10 +81,17 @@ func (m MovieModel) Get(ctx context.Context, id int64) (*Movie, error) { } func (m MovieModel) Update(ctx context.Context, movie *Movie) error { + // Using version here as an optimistic lock. Look up optimistic vs pessimistic locking + // https://stackoverflow.com/questions/129329/optimistic-vs-pessimistic-locking/129397#129397 + + // version incrementing with an integer is cheap. Don't try with a last_updated timestamp...that could + // cause issues. If you don't want version to be guessable then a UUID generated by the DB is suitable. + // Example: SET ... version = uuid_generate_v4() + 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 ` @@ -94,9 +101,21 @@ func (m MovieModel) Update(ctx context.Context, movie *Movie) error { movie.Runtime, movie.Genres, movie.ID, + movie.Version, + } + + // Will not return any rows if the version number has already changed. + err := m.pool.QueryRow(ctx, query, args...).Scan(&movie.Version) + if err != nil { + switch { + case errors.Is(err, pgx.ErrNoRows): + return ErrEditConflict + default: + return err + } } - return m.pool.QueryRow(ctx, query, args...).Scan(&movie.Version) + return nil } // Here I decided to attempt an idiomatic version of the Delete service with