diff --git a/projects/greenlight/cmd/api/errors.go b/projects/greenlight/cmd/api/errors.go index 1621120..308256f 100644 --- a/projects/greenlight/cmd/api/errors.go +++ b/projects/greenlight/cmd/api/errors.go @@ -5,6 +5,12 @@ import ( "net/http" ) +// Note that the errors parameter here has the type map[string]string, which is exactly +// the same as the errors map contained in our Validator type. +func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { + app.errorResponse(w, r, http.StatusUnprocessableEntity, errors) +} + // The logError() method is a generic helper for logging an error message. “Later in the // book we'll upgrade this to use structured logging, and record additional information // about the request including the HTTP method and URL. diff --git a/projects/greenlight/cmd/api/helpers.go b/projects/greenlight/cmd/api/helpers.go index df0f2d7..431e6bc 100644 --- a/projects/greenlight/cmd/api/helpers.go +++ b/projects/greenlight/cmd/api/helpers.go @@ -8,6 +8,7 @@ import ( "maps" "net/http" "strconv" + "strings" "github.com/julienschmidt/httprouter" ) @@ -46,59 +47,72 @@ func (app *application) writeJSON(w http.ResponseWriter, status int, data envelo return nil } -func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { - // Decode the request body into the target destination. - err := json.NewDecoder(r.Body).Decode(dst) +func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any) error { + // Use http.MaxBytesReader() to limit the size of the request body to 1MB. + maxBytes := 1_048_576 + r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes)) + + // Initialize the json.Decoder, and call the DisallowUnknownFields() method on it + // before decoding. This means that if the JSON from the client now includes any + // field which cannot be mapped to the target destination, the decoder will return + // an error instead of just ignoring the field. + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + // Decode the request body to the destination. + err := dec.Decode(dst) if err != nil { - // If there is an error during decoding, start the triage... var syntaxError *json.SyntaxError var unmarshalTypeError *json.UnmarshalTypeError var invalidUnmarshalError *json.InvalidUnmarshalError switch { - // Use the errors.As() function to check whether the error has the type - - // *json.SyntaxError. If it does, then return a plain-english error message - // which includes the location of the problem. case errors.As(err, &syntaxError): return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset) - // In some circumstances Decode() may also return an io.ErrUnexpectedEOF error - // for syntax errors in the JSON. So we check for this using errors.Is() and - // return a generic error message. There is an open issue regarding this at - // https://github.com/golang/go/issues/25956. case errors.Is(err, io.ErrUnexpectedEOF): return errors.New("body contains badly-formed JSON") - // Likewise, catch any *json.UnmarshalTypeError errors. These occur when the - // JSON value is the wrong type for the target destination. If the error relates - // to a specific field, then we include that in our error message to make it - // easier for the client to debug. case errors.As(err, &unmarshalTypeError): if unmarshalTypeError.Field != "" { return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field) } return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset) - // An io.EOF error will be returned by Decode() if the request body is empty. We - // check for this with errors.Is() and return a plain-english error message - // instead. case errors.Is(err, io.EOF): return errors.New("body must not be empty") + // If the JSON contains a field which cannot be mapped to the target destination + // then Decode() will now return an error message in the format "json: unknown + // field """. We check for this, extract the field name from the error, + // and interpolate it into our custom error message. Note that there's an open + // issue at https://github.com/golang/go/issues/29035 regarding turning this + // into a distinct error type in the future. + case strings.HasPrefix(err.Error(), "json: unknown field "): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") + return fmt.Errorf("body contains unknown key %s", fieldName) + + // If the request body exceeds 1MB in size the decode will now fail with the + // error "http: request body too large". There is an open issue about turning + // this into a distinct error type at https://github.com/golang/go/issues/30715. + case err.Error() == "http: request body too large": + return fmt.Errorf("body must not be larger than %d bytes", maxBytes) - // A json.InvalidUnmarshalError error will be returned if we pass a non-nil - // pointer to Decode(). We catch this and panic, rather than returning an error - // to our handler. At the end of this chapter we'll talk about panicking - // versus returning errors, and discuss why it's an appropriate thing to do in - // this specific situation. case errors.As(err, &invalidUnmarshalError): panic(err) - // For anything else, return the error message as-is. default: return err } } + // Call Decode() again, using a pointer to an empty anonymous struct as the + // destination. If the request body only contained a single JSON value this will + // return an io.EOF error. So if we get anything else, we know that there is + // additional data in the request body and we return our own custom error message. + err = dec.Decode(&struct{}{}) + if err != io.EOF { + return errors.New("body must only contain a single JSON value") + } + return nil } diff --git a/projects/greenlight/cmd/api/main.go b/projects/greenlight/cmd/api/main.go index 55a4ec8..98423e4 100644 --- a/projects/greenlight/cmd/api/main.go +++ b/projects/greenlight/cmd/api/main.go @@ -1,12 +1,17 @@ package main import ( + "context" + "database/sql" "flag" "fmt" "log" "net/http" "os" "time" + + "github.com/joho/godotenv" + _ "github.com/lib/pq" ) // Declare a string containing the application version number. Later in the book we'll @@ -22,6 +27,9 @@ const version = "1.0.0" type config struct { port int env string + db struct { + dsn string + } } // Define an application struct to hold the dependencies for our HTTP handlers, helpers, @@ -33,13 +41,38 @@ type application struct { } func main() { + logger := log.New(os.Stdout, "", log.Ldate|log.Ltime) var cfg config + err := godotenv.Load() + if err != nil { + logger.Fatal(err) + } + dsn := os.Getenv("DATABASE_DSN") + flag.IntVar(&cfg.port, "port", 4000, "API server port") flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)") + // Read the DSN value from the db-dsn command-line flag into the config struct. We + // default to using our development DSN if no flag is provided. + flag.StringVar(&cfg.db.dsn, "db-dsn", dsn, "PostgreSQL DSN") + flag.Parse() - logger := log.New(os.Stdout, "", log.Ldate|log.Ltime) + // Call the openDB() helper function (see below) to create the connection pool, + // passing in the config struct. If this returns an error, we log it and exit the + // application immediately. + db, err := openDB(cfg) + if err != nil { + logger.Fatal(err) + } + + // Defer a call to db.Close() so that the connection pool is closed before the + // main() function exits. + defer db.Close() + + // Also log a message to say that the connection pool has been successfully + // established. + logger.Printf("database connection pool established") app := &application{ config: cfg, @@ -56,6 +89,31 @@ func main() { } logger.Printf("starting %s server on %s", cfg.env, srv.Addr) - err := srv.ListenAndServe() + err = srv.ListenAndServe() logger.Fatal(err) } + +func openDB(cfg config) (*sql.DB, error) { + // Use sql.Open() to create an empty connection pool, using the DSN from the config + // struct. + db, err := sql.Open("postgres", cfg.db.dsn) + if err != nil { + return nil, err + } + + // Create a context with a 5-second timeout deadline. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Use PingContext() to establish a new connection to the database, passing in the + // context we created above as a parameter. If the connection couldn't be + // established successfully within the 5 second deadline, then this will return an + // error. + err = db.PingContext(ctx) + if err != nil { + return nil, err + } + + // Return the sql.DB connection pool. + return db, nil +} diff --git a/projects/greenlight/cmd/api/movies.go b/projects/greenlight/cmd/api/movies.go index d0da4da..e911285 100644 --- a/projects/greenlight/cmd/api/movies.go +++ b/projects/greenlight/cmd/api/movies.go @@ -6,26 +6,41 @@ import ( "time" "greenlight.debuggingjon.dev/internal/data" + "greenlight.debuggingjon.dev/internal/validator" ) func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) { var input struct { - Title string `json:"title"` - Year int32 `json:"year"` - Runtime int32 `json:"runtime"` - Genres []string `json:"genres"` + Title string `json:"title"` + Year int32 `json:"year"` + Runtime data.Runtime `json:"runtime"` + Genres []string `json:"genres"` } - // Use the new readJSON() helper to decode the request body into the input struct. - // If this returns an error we send the client the error message along with a 400 - // Bad Request status code, just like before. err := app.readJSON(w, r, &input) if err != nil { - // Use the new badRequestResponse() helper. app.badRequestResponse(w, r, err) return } + // Copy the values from the input struct to a new Movie struct. + movie := &data.Movie{ + Title: input.Title, + Year: input.Year, + Runtime: input.Runtime, + Genres: input.Genres, + } + + // Initialize a new Validator. + v := validator.New() + + // Call the ValidateMovie() function and return a response containing the errors if + // any of the checks fail. + if data.ValidateMovie(v, movie); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + fmt.Fprintf(w, "%+v\n", input) } diff --git a/projects/greenlight/greenlight-bruno/.gitignore b/projects/greenlight/docs/greenlight-bruno/.gitignore similarity index 100% rename from projects/greenlight/greenlight-bruno/.gitignore rename to projects/greenlight/docs/greenlight-bruno/.gitignore diff --git a/projects/greenlight/greenlight-bruno/Create Movie.yml b/projects/greenlight/docs/greenlight-bruno/Create Movie.yml similarity index 84% rename from projects/greenlight/greenlight-bruno/Create Movie.yml rename to projects/greenlight/docs/greenlight-bruno/Create Movie.yml index afc38b4..57cde42 100644 --- a/projects/greenlight/greenlight-bruno/Create Movie.yml +++ b/projects/greenlight/docs/greenlight-bruno/Create Movie.yml @@ -10,7 +10,8 @@ http: type: json data: |- { - "title": "Moana" + "title": "2313", + "runtime": "2" } auth: inherit diff --git a/projects/greenlight/greenlight-bruno/Get Single Movie.yml b/projects/greenlight/docs/greenlight-bruno/Get Single Movie.yml similarity index 100% rename from projects/greenlight/greenlight-bruno/Get Single Movie.yml rename to projects/greenlight/docs/greenlight-bruno/Get Single Movie.yml diff --git a/projects/greenlight/greenlight-bruno/Healthcheck.yml b/projects/greenlight/docs/greenlight-bruno/Healthcheck.yml similarity index 100% rename from projects/greenlight/greenlight-bruno/Healthcheck.yml rename to projects/greenlight/docs/greenlight-bruno/Healthcheck.yml diff --git a/projects/greenlight/greenlight-bruno/environments/Development.yml b/projects/greenlight/docs/greenlight-bruno/environments/Development.yml similarity index 100% rename from projects/greenlight/greenlight-bruno/environments/Development.yml rename to projects/greenlight/docs/greenlight-bruno/environments/Development.yml diff --git a/projects/greenlight/greenlight-bruno/opencollection.yml b/projects/greenlight/docs/greenlight-bruno/opencollection.yml similarity index 100% rename from projects/greenlight/greenlight-bruno/opencollection.yml rename to projects/greenlight/docs/greenlight-bruno/opencollection.yml diff --git a/projects/greenlight/go.mod b/projects/greenlight/go.mod index ff85ef2..02cb525 100644 --- a/projects/greenlight/go.mod +++ b/projects/greenlight/go.mod @@ -2,4 +2,8 @@ module greenlight.debuggingjon.dev go 1.25.0 -require github.com/julienschmidt/httprouter v1.3.0 // indirect +require ( + github.com/joho/godotenv v1.5.1 // indirect + github.com/julienschmidt/httprouter v1.3.0 // indirect + github.com/lib/pq v1.10.0 // indirect +) diff --git a/projects/greenlight/go.sum b/projects/greenlight/go.sum index 096c54e..de6d4bc 100644 --- a/projects/greenlight/go.sum +++ b/projects/greenlight/go.sum @@ -1,2 +1,6 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/lib/pq v1.10.0 h1:Zx5DJFEYQXio93kgXnQ09fXNiUKsqv4OUEu2UtGcB1E= +github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= diff --git a/projects/greenlight/internal/data/movies.go b/projects/greenlight/internal/data/movies.go index ed81785..598fe1b 100644 --- a/projects/greenlight/internal/data/movies.go +++ b/projects/greenlight/internal/data/movies.go @@ -2,6 +2,8 @@ package data import ( "time" + + "greenlight.debuggingjon.dev/internal/validator" ) type Movie struct { @@ -13,3 +15,20 @@ type Movie struct { Genres []string `json:"genres,omitempty"` // Add the omitempty directive Version int32 `json:"version"` } + +func ValidateMovie(v *validator.Validator, movie *Movie) { + v.Check(movie.Title != "", "title", "must be provided") + v.Check(len(movie.Title) <= 500, "title", "must not be more than 500 bytes long") + + v.Check(movie.Year != 0, "year", "must be provided") + v.Check(movie.Year >= 1888, "year", "must be greater than 1888") + v.Check(movie.Year <= int32(time.Now().Year()), "year", "must not be in the future") + + v.Check(movie.Runtime != 0, "runtime", "must be provided") + v.Check(movie.Runtime > 0, "runtime", "must be a positive integer") + + v.Check(movie.Genres != nil, "genres", "must be provided") + v.Check(len(movie.Genres) >= 1, "genres", "must contain at least 1 genre") + v.Check(len(movie.Genres) <= 5, "genres", "must not contain more than 5 genres") + v.Check(validator.Unique(movie.Genres), "genres", "must not contain duplicate values") +} diff --git a/projects/greenlight/internal/data/runtime.go b/projects/greenlight/internal/data/runtime.go index 09266f6..87c2129 100644 --- a/projects/greenlight/internal/data/runtime.go +++ b/projects/greenlight/internal/data/runtime.go @@ -1,14 +1,59 @@ package data import ( + "errors" "fmt" "strconv" + "strings" ) +// Define an error that our UnmarshalJSON() method can return if we're unable to parse +// or convert the JSON string successfully. +var ErrInvalidRuntimeFormat = errors.New("invalid runtime format") + // Declare a custom Runtime type, which has the underlying type int32 (the same as our // Movie struct field). type Runtime int32 +// Implement a UnmarshalJSON() method on the Runtime type so that it satisfies the +// json.Unmarshaler interface. IMPORTANT: Because UnmarshalJSON() needs to modify the +// receiver (our Runtime type), we must use a pointer receiver for this to work +// correctly. Otherwise, we will only be modifying a copy (which is then discarded when +// this method returns). +func (r *Runtime) UnmarshalJSON(jsonValue []byte) error { + // We expect that the incoming JSON value will be a string in the format + // " mins", and the first thing we need to do is remove the surrounding + // double-quotes from this string. If we can't unquote it, then we return the + // ErrInvalidRuntimeFormat error. + unquotedJSONValue, err := strconv.Unquote(string(jsonValue)) + if err != nil { + return ErrInvalidRuntimeFormat + } + + // Split the string to isolate the part containing the number. + parts := strings.Split(unquotedJSONValue, " ") + + // Sanity check the parts of the string to make sure it was in the expected format. + // If it isn't, we return the ErrInvalidRuntimeFormat error again. + if len(parts) != 2 || parts[1] != "mins" { + return ErrInvalidRuntimeFormat + } + + // Otherwise, parse the string containing the number into an int32. Again, if this + // fails return the ErrInvalidRuntimeFormat error. + i, err := strconv.ParseInt(parts[0], 10, 32) + if err != nil { + return ErrInvalidRuntimeFormat + } + + // Convert the int32 to a Runtime type and assign this to the receiver. Note that we use + // use the * operator to deference the receiver (which is a pointer to a Runtime + // type) in order to set the underlying value of the pointer. + *r = Runtime(i) + + return nil +} + // Implement a MarshalJSON() method on the Runtime type so that it satisfies the // json.Marshaler interface. This should return the JSON-encoded value for the movie // runtime (in our case, it will return a string in the format " mins"). diff --git a/projects/greenlight/internal/validator/validator.go b/projects/greenlight/internal/validator/validator.go new file mode 100644 index 0000000..4afe6e8 --- /dev/null +++ b/projects/greenlight/internal/validator/validator.go @@ -0,0 +1,66 @@ +package validator + +import ( + "regexp" + "slices" +) + +// Declare a regular expression for sanity checking the format of email addresses (we'll +// use this later in the book). If you're interested, this regular expression pattern is +// taken from https://html.spec.whatwg.org/#valid-e-mail-address. Note: if you're +// reading this in PDF or EPUB format and cannot see the full pattern, please see the +// note further down the page. +var ( + EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") +) + +// Define a new Validator type which contains a map of validation errors. +type Validator struct { + Errors map[string]string +} + +// New is a helper which creates a new Validator instance with an empty errors map. +func New() *Validator { + return &Validator{Errors: make(map[string]string)} +} + +// Valid returns true if the errors map doesn't contain any entries. +func (v *Validator) Valid() bool { + return len(v.Errors) == 0 +} + +// AddError adds an error message to the map (so long as no entry already exists for +// the given key). +func (v *Validator) AddError(key, message string) { + if _, exists := v.Errors[key]; !exists { + v.Errors[key] = message + } +} + +// Check adds an error message to the map only if a validation check is not 'ok'. +func (v *Validator) Check(ok bool, key, message string) { + if !ok { + v.AddError(key, message) + } +} + +// In returns true if a specific value is in a list of strings. +func In(value string, list ...string) bool { + return slices.Contains(list, value) +} + +// Matches returns true if a string value matches a specific regexp pattern. +func Matches(value string, rx *regexp.Regexp) bool { + return rx.MatchString(value) +} + +// Unique returns true if all string values in a slice are unique. +func Unique(values []string) bool { + uniqueValues := make(map[string]bool, 5) + + for _, value := range values { + uniqueValues[value] = true + } + + return len(values) == len(uniqueValues) +}