From 6b170a270578c87d6a38be6d18f88edc38be944a Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 18 Mar 2026 13:52:32 +0100 Subject: [PATCH] feat: get movies, url parametes parsing and validating, sql exeme querying --- projects/greenlight/cmd/api/helpers.go | 59 ++++++++++++ projects/greenlight/cmd/api/movies.go | 56 ++++++++++++ projects/greenlight/cmd/api/routes.go | 2 +- .../api/greenlight-bruno/Delete Movie.yml | 2 +- .../docs/api/greenlight-bruno/Get Movies.yml | 35 +++++++ .../api/greenlight-bruno/Get Single Movie.yml | 2 +- .../api/greenlight-bruno/Update Movie.yml | 2 +- projects/greenlight/internal/data/filters.go | 24 +++++ projects/greenlight/internal/data/movies.go | 91 ++++++++++++++++++- 9 files changed, 265 insertions(+), 8 deletions(-) create mode 100644 projects/greenlight/docs/api/greenlight-bruno/Get Movies.yml create mode 100644 projects/greenlight/internal/data/filters.go diff --git a/projects/greenlight/cmd/api/helpers.go b/projects/greenlight/cmd/api/helpers.go index 431e6bc..1cad132 100644 --- a/projects/greenlight/cmd/api/helpers.go +++ b/projects/greenlight/cmd/api/helpers.go @@ -7,10 +7,12 @@ import ( "io" "maps" "net/http" + "net/url" "strconv" "strings" "github.com/julienschmidt/httprouter" + "greenlight.debuggingjon.dev/internal/validator" ) // Define an envelope type. @@ -116,3 +118,60 @@ func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any return nil } + +// The readString() helper returns a string value from the query string, or the provided +// default value if no matching key could be found. +func (app *application) readString(qs url.Values, key string, defaultValue string) string { + // Extract the value for a given key from the query string. If no key exists this + // will return the empty string "". + s := qs.Get(key) + + // If no key exists (or the value is empty) then return the default value. + if s == "" { + return defaultValue + } + + // Otherwise return the string. + return s +} + +// The readCSV() helper reads a string value from the query string and then splits it +// into a slice on the comma character. If no matching key could be found, it returns +// the provided default value. +func (app *application) readCSV(qs url.Values, key string, defaultValue []string) []string { + // Extract the value from the query string. + csv := qs.Get(key) + + // If no key exists (or the value is empty) then return the default value. + if csv == "" { + return defaultValue + } + + // Otherwise parse the value into a []string slice and return it. + return strings.Split(csv, ",") +} + +// The readInt() helper reads a string value from the query string and converts it to an +// integer before returning. If no matching key could be found it returns the provided +// default value. If the value couldn't be converted to an integer, then we record an +// error message in the provided Validator instance. +func (app *application) readInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int { + // Extract the value from the query string. + s := qs.Get(key) + + // If no key exists (or the value is empty) then return the default value. + if s == "" { + return defaultValue + } + + // Try to convert the value to an int. If this fails, add an error message to the + // validator instance and return the default value. + i, err := strconv.Atoi(s) + if err != nil { + v.AddError(key, "must be an integer value") + return defaultValue + } + + // Otherwise, return the converted integer value. + return i +} diff --git a/projects/greenlight/cmd/api/movies.go b/projects/greenlight/cmd/api/movies.go index 4d4fb81..dfa7333 100644 --- a/projects/greenlight/cmd/api/movies.go +++ b/projects/greenlight/cmd/api/movies.go @@ -209,3 +209,59 @@ func (app *application) deleteMovieHandler(w http.ResponseWriter, r *http.Reques app.serverErrorResponse(w, r, err) } } + +func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request) { + // To keep things consistent with our other handlers, we'll define an input struct + // to hold the expected values from the request query string. + var input struct { + Title string + Genres []string + data.Filters + } + + // Initialize a new Validator instance. + v := validator.New() + + // Call r.URL.Query() to get the url.Values map containing the query string data. + qs := r.URL.Query() + + // Use our helpers to extract the title and genres query string values, falling back + // to defaults of an empty string and an empty slice respectively if they are not + // provided by the client. + input.Title = app.readString(qs, "title", "") + input.Genres = app.readCSV(qs, "genres", []string{}) + + // Get the page and page_size query string values as integers. Notice that we set + // the default page value to 1 and default page_size to 20, and that we pass the + // validator instance as the final argument here. + input.Page = app.readInt(qs, "page", 1, v) + input.PageSize = app.readInt(qs, "page_size", 20, v) + + // Extract the sort query string value, falling back to "id" if it is not provided + // by the client (which will imply a ascending sort on movie ID). + input.Sort = app.readString(qs, "sort", "id") + + // Add the supported sort values for this endpoint to the sort safelist. + input.SortSafelist = []string{"id", "title", "year", "runtime", "-id", "-title", "-year", "-runtime"} + + // Execute the validation checks on the Filters struct and send a response + // containing the errors if necessary. + if data.ValidateFilters(v, input.Filters); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + // Call the GetAll() method to retrieve the movies, passing in the various filter + // parameters. + movies, err := app.models.Movies.GetAll(input.Title, input.Genres, input.Filters) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + // Send a JSON response containing the movie data. + err = app.writeJSON(w, http.StatusOK, envelope{"movies": movies}, 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 2950a5f..042ed4a 100644 --- a/projects/greenlight/cmd/api/routes.go +++ b/projects/greenlight/cmd/api/routes.go @@ -17,7 +17,7 @@ func (app *application) routes() *httprouter.Router { // Likewise, convert the methodNotAllowedResponse() helper to a http.Handler and set // it as the custom error handler for 405 Method Not Allowed responses. router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse) - + router.HandlerFunc(http.MethodGet, "/v1/movies", app.listMoviesHandler) 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) diff --git a/projects/greenlight/docs/api/greenlight-bruno/Delete Movie.yml b/projects/greenlight/docs/api/greenlight-bruno/Delete Movie.yml index 0b1c428..8fc3b2b 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Delete Movie.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Delete Movie.yml @@ -1,7 +1,7 @@ info: name: Delete Movie type: http - seq: 5 + seq: 6 http: method: DELETE diff --git a/projects/greenlight/docs/api/greenlight-bruno/Get Movies.yml b/projects/greenlight/docs/api/greenlight-bruno/Get Movies.yml new file mode 100644 index 0000000..ba7d0ed --- /dev/null +++ b/projects/greenlight/docs/api/greenlight-bruno/Get Movies.yml @@ -0,0 +1,35 @@ +info: + name: Get Movies + type: http + seq: 3 + +http: + method: GET + url: "{{URL}}{{VERSION}}/movies?genres=action" + params: + - name: title + value: ddd + type: query + disabled: true + - name: page + value: "9999999" + type: query + disabled: true + - name: page_size + value: "10" + type: query + disabled: true + - name: sort + value: year + type: query + disabled: true + - name: genres + value: action + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/projects/greenlight/docs/api/greenlight-bruno/Get Single Movie.yml b/projects/greenlight/docs/api/greenlight-bruno/Get Single Movie.yml index 471cd52..d92e8ad 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Get Single Movie.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Get Single Movie.yml @@ -1,7 +1,7 @@ info: name: Get Single Movie type: http - seq: 3 + seq: 4 http: method: GET diff --git a/projects/greenlight/docs/api/greenlight-bruno/Update Movie.yml b/projects/greenlight/docs/api/greenlight-bruno/Update Movie.yml index ce0419a..1f85eb3 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Update Movie.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Update Movie.yml @@ -1,7 +1,7 @@ info: name: Update Movie type: http - seq: 4 + seq: 5 http: method: PATCH diff --git a/projects/greenlight/internal/data/filters.go b/projects/greenlight/internal/data/filters.go new file mode 100644 index 0000000..55cc00e --- /dev/null +++ b/projects/greenlight/internal/data/filters.go @@ -0,0 +1,24 @@ +package data + +import ( + "greenlight.debuggingjon.dev/internal/validator" +) + +// Add a SortSafelist field to hold the supported sort values. +type Filters struct { + Page int + PageSize int + Sort string + SortSafelist []string +} + +func ValidateFilters(v *validator.Validator, f Filters) { + // Check that the page and page_size parameters contain sensible values. + v.Check(f.Page > 0, "page", "must be greater than zero") + v.Check(f.Page <= 10_000_000, "page", "must be a maximum of 10 million") + v.Check(f.PageSize > 0, "page_size", "must be greater than zero") + v.Check(f.PageSize <= 100, "page_size", "must be a maximum of 100") + + // Check that the sort parameter matches a value in the safelist. + v.Check(validator.In(f.Sort, f.SortSafelist...), "sort", "invalid sort value") +} diff --git a/projects/greenlight/internal/data/movies.go b/projects/greenlight/internal/data/movies.go index 972e6db..b01ddd9 100644 --- a/projects/greenlight/internal/data/movies.go +++ b/projects/greenlight/internal/data/movies.go @@ -1,6 +1,7 @@ package data import ( + "context" "database/sql" "errors" "time" @@ -14,6 +15,69 @@ type MovieModel struct { DB *sql.DB } +// Create a new GetAll() method which returns a slice of movies. Although we're not +// using them right now, we've set this up to accept the various filter parameters as +// arguments. +func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, error) { + // Use full-text search for the title filter. + query := ` + SELECT id, created_at, title, year, runtime, genres, version + FROM movies + WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '') + AND (genres @> $2 OR $2 = '{}') + ORDER BY id` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + // Use QueryContext() to execute the query. This returns a sql.Rows resultset + // containing the result. + rows, err := m.DB.QueryContext(ctx, query, title, pq.Array(genres)) + if err != nil { + return nil, err + } + + // Importantly, defer a call to rows.Close() to ensure that the resultset is closed + // before GetAll() returns. + defer rows.Close() + + // Initialize an empty slice to hold the movie data. + movies := []*Movie{} + + // Use rows.Next to iterate through the rows in the resultset. + for rows.Next() { + // Initialize an empty Movie struct to hold the data for an individual movie. + var movie Movie + + // Scan the values from the row into the Movie struct. Again, note that we're + // using the pq.Array() adapter on the genres field here. + err := rows.Scan( + &movie.ID, + &movie.CreatedAt, + &movie.Title, + &movie.Year, + &movie.Runtime, + pq.Array(&movie.Genres), + &movie.Version, + ) + if err != nil { + return nil, err + } + + // Add the Movie struct to the slice. + movies = append(movies, &movie) + } + + // When the rows.Next() loop has finished, call rows.Err() to retrieve any error + // that was encountered during the iteration. + if err = rows.Err(); err != nil { + return nil, err + } + + // If everything went OK, then return the slice of movies. + return movies, nil +} + // Add a placeholder method for inserting a new record in the movies table. func (m MovieModel) Insert(movie *Movie) error { // Define the SQL query for inserting a new record in the movies table and returning @@ -28,10 +92,14 @@ func (m MovieModel) Insert(movie *Movie) error { // make it nice and clear *what values are being used where* in the query. args := []interface{}{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)} + // Create a context with a 3-second timeout. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + // Use the QueryRow() method to execute the SQL query on our connection pool, // passing in the args slice as a variadic parameter and scanning the system- // generated id, created_at and version values into the movie struct. - return m.DB.QueryRow(query, args...).Scan(&movie.ID, &movie.CreatedAt, &movie.Version) + return m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.ID, &movie.CreatedAt, &movie.Version) } func (m MovieModel) Get(id int64) (*Movie, error) { @@ -52,11 +120,20 @@ func (m MovieModel) Get(id int64) (*Movie, error) { // Declare a Movie struct to hold the data returned by the query. var movie Movie + // Use the context.WithTimeout() function to create a context.Context which carries a + // 3-second timeout deadline. Note that we're using the empty context.Background() + // as the 'parent' context. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + + // Importantly, use defer to make sure that we cancel the context before the Get() + // method returns. + defer cancel() + // Execute the query using the QueryRow() method, passing in the provided id value // as a placeholder parameter, and scan the response data into the fields of the // Movie struct. Importantly, notice that we need to convert the scan target for the // genres column using the pq.Array() adapter function again. - err := m.DB.QueryRow(query, id).Scan( + err := m.DB.QueryRowContext(ctx, query, id).Scan( &movie.ID, &movie.CreatedAt, &movie.Title, @@ -98,10 +175,13 @@ func (m MovieModel) Update(movie *Movie) error { movie.ID, movie.Version, } + // Create a context with a 3-second timeout. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() // 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. - err := m.DB.QueryRow(query, args...).Scan(&movie.Version) + err := m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.Version) if err != nil { switch { case errors.Is(err, sql.ErrNoRows): @@ -124,11 +204,14 @@ func (m MovieModel) Delete(id int64) error { query := ` DELETE FROM movies WHERE id = $1` + // Create a context with a 3-second timeout. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() // Execute the SQL query using the Exec() method, passing in the id variable as // the value for the placeholder parameter. The Exec() method returns a sql.Result // object. - result, err := m.DB.Exec(query, id) + result, err := m.DB.ExecContext(ctx, query, id) if err != nil { return err }