diff --git a/README.md b/README.md index 578278c..6a1cb87 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ When you decode a JSON number into an any type the value will have the underlyin The `json.Number` type then provides an `Int64()` method that you can call to get the number as an `int64`, or the `String()` method to get the number as a `string`. For example: -``` +```golang js := `10` var n any @@ -251,3 +251,13 @@ Using the `errors.Is()` and `errors.As()` functions you can handle these errors |-------------|------------------------------------------|----------------|-----------------------------------------| | `errors.Is` | Compare error values (with wrapping) | Specific value | `errors.Is(err, io.EOF)` | | `errors.As` | Check and extract a specific error type | Specific type | `errors.As(err, *json.SyntaxError)` | + +#### Custom Decoding + +If we want to perform some kind of custom decoding for our type, this can be done with the json.Unmarshaler interface + +```golang +type Unmarshaler interface { + UnmarshalJSON([]byte) error +} +``` \ No newline at end of file diff --git a/cmd/api/handlers.go b/cmd/api/handlers.go index e42615b..5d357a0 100644 --- a/cmd/api/handlers.go +++ b/cmd/api/handlers.go @@ -18,10 +18,12 @@ func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Reques // just like encoding the field names need to be exported to have Decode set them 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"` + // implemented an Unmarshaler receiver for this + // Runtime int32 `json:"runtime"` + Runtime data.Runtime `json:"runtime"` + Genres []string `json:"genres"` } // reads from the request body and decodes to our input struct @@ -61,9 +63,9 @@ func (app *application) getMovieHandler(w http.ResponseWriter, r *http.Request) ID: id, CreatedAt: time.Now(), Title: "Eisenhorn", - Runtime: 102, - Genres: []string{"sci-fi", "action"}, - Version: 1, + // Runtime: 102, + Genres: []string{"sci-fi", "action"}, + Version: 1, } app.logger.Info("Hit the get movies and found", "movie", movie.Title) diff --git a/cmd/api/handlers_test.go b/cmd/api/handlers_test.go index 2c85814..6050ad0 100644 --- a/cmd/api/handlers_test.go +++ b/cmd/api/handlers_test.go @@ -49,7 +49,7 @@ func TestHealthRoute(t *testing.T) { func TestCreateMovieHandler(t *testing.T) { respRec := httptest.NewRecorder() - requestBody := `{"title": "Moana", "year": 2019, "runtime": 120, "genres": ["family", "Samoan"]}` + requestBody := `{"title": "Moana", "year": 2019, "runtime": "120 mins", "genres": ["family", "Samoan"]}` r, err := http.NewRequest(http.MethodPost, "/v1/movies", strings.NewReader(requestBody)) if err != nil { @@ -114,7 +114,7 @@ func TestCreateMovieError(t *testing.T) { }, { name: "Send unknown field", - input: strings.NewReader(`{"title": "Moana", "year": 2019, "runtime": 120, "genres": ["family", "Samoan"], "rating": "PG"}`), + input: strings.NewReader(`{"title": "Moana", "year": 2019, "runtime": "120 mins", "genres": ["family", "Samoan"], "rating": "PG"}`), wantBody: "body contains unknown key", wantCode: http.StatusBadRequest, }, @@ -131,6 +131,12 @@ func TestCreateMovieError(t *testing.T) { wantBody: "body must not be larger than 1048576 bytes", wantCode: http.StatusBadRequest, }, + { + name: "Send invalid runtime", + input: strings.NewReader(`{"title": "Moana", "runtime": 120}`), + wantBody: "invalid runtime format", + wantCode: http.StatusBadRequest, + }, } for _, test := range tests { diff --git a/internal/data/types.go b/internal/data/types.go index 9e732c1..ed088c5 100644 --- a/internal/data/types.go +++ b/internal/data/types.go @@ -1,10 +1,16 @@ 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 int32 type type Runtime int32 @@ -25,3 +31,42 @@ func (r Runtime) MarshalJSON() ([]byte, error) { return []byte(quotedJSONValue), nil } + +// 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 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 +}