feat: get movies, url parametes parsing and validating, sql exeme querying
This commit is contained in:
@@ -7,10 +7,12 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
|
"greenlight.debuggingjon.dev/internal/validator"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Define an envelope type.
|
// Define an envelope type.
|
||||||
@@ -116,3 +118,60 @@ func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -209,3 +209,59 @@ func (app *application) deleteMovieHandler(w http.ResponseWriter, r *http.Reques
|
|||||||
app.serverErrorResponse(w, r, err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func (app *application) routes() *httprouter.Router {
|
|||||||
// Likewise, convert the methodNotAllowedResponse() helper to a http.Handler and set
|
// Likewise, convert the methodNotAllowedResponse() helper to a http.Handler and set
|
||||||
// it as the custom error handler for 405 Method Not Allowed responses.
|
// it as the custom error handler for 405 Method Not Allowed responses.
|
||||||
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)
|
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.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)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Delete Movie
|
name: Delete Movie
|
||||||
type: http
|
type: http
|
||||||
seq: 5
|
seq: 6
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Single Movie
|
name: Get Single Movie
|
||||||
type: http
|
type: http
|
||||||
seq: 3
|
seq: 4
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Movie
|
name: Update Movie
|
||||||
type: http
|
type: http
|
||||||
seq: 4
|
seq: 5
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PATCH
|
method: PATCH
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,6 +15,69 @@ type MovieModel struct {
|
|||||||
DB *sql.DB
|
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.
|
// Add a placeholder method for inserting a new record in the movies table.
|
||||||
func (m MovieModel) Insert(movie *Movie) error {
|
func (m MovieModel) Insert(movie *Movie) error {
|
||||||
// Define the SQL query for inserting a new record in the movies table and returning
|
// 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.
|
// 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)}
|
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,
|
// 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-
|
// passing in the args slice as a variadic parameter and scanning the system-
|
||||||
// generated id, created_at and version values into the movie struct.
|
// 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) {
|
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.
|
// Declare a Movie struct to hold the data returned by the query.
|
||||||
var movie Movie
|
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
|
// 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
|
// 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
|
// Movie struct. Importantly, notice that we need to convert the scan target for the
|
||||||
// genres column using the pq.Array() adapter function again.
|
// 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.ID,
|
||||||
&movie.CreatedAt,
|
&movie.CreatedAt,
|
||||||
&movie.Title,
|
&movie.Title,
|
||||||
@@ -98,10 +175,13 @@ func (m MovieModel) Update(movie *Movie) error {
|
|||||||
movie.ID,
|
movie.ID,
|
||||||
movie.Version,
|
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
|
// 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.
|
||||||
err := m.DB.QueryRow(query, args...).Scan(&movie.Version)
|
err := m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, sql.ErrNoRows):
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
@@ -124,11 +204,14 @@ func (m MovieModel) Delete(id int64) error {
|
|||||||
query := `
|
query := `
|
||||||
DELETE FROM movies
|
DELETE FROM movies
|
||||||
WHERE id = $1`
|
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
|
// 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
|
// the value for the placeholder parameter. The Exec() method returns a sql.Result
|
||||||
// object.
|
// object.
|
||||||
result, err := m.DB.Exec(query, id)
|
result, err := m.DB.ExecContext(ctx, query, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user