4. Parseando peticiones JSON

En el post anterior vimos como crear y enviar respuestas JSON desde nuestra API hacia el cliente, pero ahora vamos a ver como leer y parsear peticiones JSON desde nuestros clientes.

Para ayudar a la demostracion vamos a comenzar trabajando sobre el endpoint POST /v1/movies y createMovieHandler que hemos creado antes.

MethodURL PatternHandlerAction
GET/v1/healthcheckhealthcheckHandlerShow application information
POST/v1/moviescreateMovieHandlerCreate a new movie
GET/v1/movies/:idshowMovieHandlerShow the details of a specific movie

Cuando un cliente llama a esta endpoint, nosotros esperaremos que nos envie una peticion con un body en formato JSON con los datos de la pelicula que se quiere agregar al sistema. Esperariamos que los datos del cliente sean asi:

{
  "title": "Moana",
  "year": 2016,
  "runtime": 107,
  "genres": ["animation", "adventure"]
}

Por ahora nos centraremos en leer,parsear y validar el body de la peticion JSON. Especialmente aprenderas:

  • Como leer el cuerpo de una peticion y decodificar en un objeto nativo de Go usando el paquete encoding/json
  • Como afrontar bad requests que vienen desde el cliente y validarlos y retornar mensajes de error de manera clara.
  • Como crear un helper reusable para validar los datos para asergurar que cumple con las reglas del negocio.
  • Diferentes tecnicas para controlar y personalizar como el JSON es decodificado.

4.1 Decodificando JSON

Tenemos dos maneras de decodificar un JSON a un objeto nativo de Go : usando el tipo json.Decoder o usando la funcion json.Unmarshal()

Ambos enfoques tienen sus pros y sus contras, pero para propositos de decodificar un JSON en el cuerpo de una peticion HTTP, usar json.Decode es generalmente la mejor opcion.Es mas eficiente que
json.Unmarshal(), requiere menos codigo y ofrece algunas opciones utiles que podes usar para cambiar su comportamiento.

Es facil demostrar como json.Decoder trabaja con codigo.Vamos a actualizar createMovieHandler.

func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
	// Declare an anonymous struct to hold the information that we expect to be in the
	// HTTP request body (note that the field names and types in the struct are a subset
	// of the Movie struct that we created earlier). This struct will be our *target
	// decode destination*.
	var input struct {
		Title   string   `json:"title"`
		Year    int32    `json:"year"`
		Runtime int32    `json:"runtime"`
		Genres  []string `json:"genres"`
	}
	// Initialize a new json.Decoder instance which reads from the request body, and
	// then use the Decode() method to decode the body contents into the input struct.
	// Importantly, notice that when we call Decode() we pass a *pointer* to the input
	// struct as the target decode destination. If there was an error during decoding,
	// we also use our generic errorResponse() helper to send the client a 400 Bad
	// Request response containing the error message.
	err := json.NewDecoder(r.Body).Decode(&input)
	if err != nil {
		app.errorResponse(w, r, http.StatusBadRequest, err.Error())
		return
	}
	// Dump the contents of the input struct in a HTTP response.
	fmt.Fprintf(w, "%+v\n", input)
}

Hay unas cosas importantes sobre lo anterior:

  • Cuando llamas a Decode(), debes pasar un puntero no nulo como destino de decodificación. Si no utilizas un puntero, retornará un error json.InvalidUnmarshalError en tiempo de ejecución. En otras palabras, Decode() espera que le pases una referencia (puntero) a la variable en la que deseas decodificar los datos JSON. Si pasas una variable no puntero, generará un error en tiempo de ejecución porque no puede modificar directamente el valor de la variable sin una referencia (puntero) a ella. Por lo tanto, es importante asegurarse de usar un puntero al llamar a Decode().
  • Si el destino de decodificación es una estructura (struct), como en nuestro caso, los campos de la estructura deben ser exportados, lo que significa que deben comenzar con una letra mayúscula. Al igual que con la codificación, deben ser exportados para que sean visibles para el paquete encoding/json.
  • Cuando decodificas un objeto JSON en una estructura, los pares clave/valor en el JSON se asignan a los campos de la estructura según los nombres de las etiquetas de la estructura. Si no hay una etiqueta de estructura coincidente, Go intentará decodificar el valor en un campo que coincida con el nombre de la clave (se prefieren las coincidencias exactas, pero se utilizará una coincidencia insensible a mayúsculas y minúsculas como alternativa). Cualquier par clave/valor JSON que no se pueda asignar con éxito a los campos de la estructura se ignorará silenciosamente.

No es necesario cerrar r.Body después de que se haya leído. Esto se hará automáticamente por el servidor HTTP de Go, por lo que no es necesario que lo hagas manualmente. Go maneja la limpieza de los recursos asociados a r.Body automáticamente, lo que facilita la programación y reduce el riesgo de errores relacionados con la gestión de recursos.

Para iniciar la aplicación y realizar una solicitud POST al endpoint /v1/movies con un cuerpo de solicitud JSON válido que contenga datos de una película.

# Create a BODY variable containing the JSON data that we want to send.
$ BODY='{"title":"Moana","year":2016,"runtime":107, "genres":["animation","adventure"]}'
# Use the -d flag to send the contents of the BODY variable as the HTTP request body.
# Note that curl will default to sending a POST request when the -d flag is used.
$ curl -i -d "$BODY" localhost:4000/v1/movies
HTTP/1.1 200 OK
Date: Tue, 06 Apr 2021 17:13:46 GMT
Content-Length: 65
Content-Type: text/plain; charset=utf-8
{Title:Moana Year:2016 Runtime:107 Genres:[animation adventure]

¡Excelente! Parece que funcionó bien. Podemos ver que los valores que proporcionamos en el cuerpo de la solicitud se han decodificado en los campos apropiados de nuestra estructura de entrada. Esto demuestra que la decodificación JSON se realizó con éxito y que los datos de la película se asignaron correctamente a los campos de la estructura en tu aplicación. ¡Es un buen indicativo de que tu API está funcionando según lo esperado!

4.1 Zero values

Que pasa si se omite algun par key/value en el cuerpo de la peticion JSON? por ejemplo si recibimos una peticion sin el campo year como el siguiente.

$ BODY='{"title":"Moana","runtime":107, "genres":["animation","adventure"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{Title:Moana Year:0 Runtime:107 Genres:[animation adventure]}

Cuando Year no esta en la peticion, tomara el valor cero de la estructura a la que la asignemos, en este caso sera 0 ya que el campo Year es de tipo int32.

Esto nos lleva a otra pregunta: Como sabemos si el cliente no envio el par clave/valor o si intencionadamente puso el valor literal 0? asi:

$ BODY='{"title":"Moana","year":0,"runtime":107, "genres":["animation","adventure"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{Title:Moana Year:0 Runtime:107 Genres:[animation adventure]}

Al final el resultado es el mismo, no es facil darse cuenta cual es el verdadero escenario.Mas adelante retomaremos esto y lo veremos mejor. Por ahora esta bien que seas consciente de esto.

4.1 Informacion adicional

4.1 Tipos soportados de destino

Es importante mensionar que ciertos types del JSON solo pueden ser decodificados por ciertos tipos de Go . Por ejemplo si tenemos un string en el JSON foo, este puede ser decodificado en un Go string, pero si intentas hacerlo en gun Go int o int32, obtendras un runtime error.

La siguiente tabla muestra los tipos soportados para los destinos de un JSON.

JSON typeSupported Go types
JSON booleanbool
JSON stringstring
JSON numberint*, uint*, float*, rune
JSON arrayarray, slice
JSON objectstruct, map

4.1 Usando la funciona json.Unmarshal

Como mensionamos al principio , tambien es posible usar json.Unmarshal() para decodificar el cuerpo de la peticion HTTP.

El uso seria asi:

func (app *application) exampleHandler(w(w http.ResponseWriter, r *http.Request) {
	var input struct {
	Foo string `json:"foo"`
	}
	// Use io.ReadAll() to read the entire request body into a []byte slice.
	body, err := io.ReadAll(r.Body)
	if err != nil {
	app.serverErrorResponse(w, r, err)
	return
	}
	// Use the json.Unmarshal() function to decode the JSON in the []byte slice to the
	// input struct. Again, notice that we are using a *pointer* to the input
	// struct as the decode destination.
	err = json.Unmarshal(body, &input)
	if err != nil {
	app.errorResponse(w, r, http.StatusBadRequest, err.Error())
	return
	}
	fmt.Fprintf(w, "%+v\n", input)
}

Hacerlo asi, funciona, pero es mas verboso y menos eficiente.

No solo eso si vemos los benchmark para este caso particular, podemos ver que json.Unmarshal() requiere arriba del 80% mas de memoria (B/op) que json.Decoder.

$ go test -run=^$ -bench=. -benchmem -count=3 -benchtime=5s
goos: linux
goarch: amd64
BenchmarkUnmarshal-8 528088 9543 ns/op 2992 B/op 20 allocs/op
BenchmarkUnmarshal-8 554365 10469 ns/op 2992 B/op 20 allocs/op
BenchmarkUnmarshal-8 537139 10531 ns/op 2992 B/op 20 allocs/op
BenchmarkDecoder-8 811063 8644 ns/op 1664 B/op 21 allocs/op
BenchmarkDecoder-8 672088 8529 ns/op 1664 B/op 21 allocs/op
BenchmarkDecoder-8 1000000 7573 ns/op 1664 B/op 21 allocs/op

4.2 Manejar bad requests

Nuestro createMovieHandler ahora funciona correctamente cuando recibe un cuerpo de solicitud JSON válido con los datos apropiados. Pero en este punto, es posible que te estés preguntando:

  • ¿Qué sucede si el cliente envía algo que no es JSON, como XML o algunos bytes aleatorios?
  • ¿Qué ocurre si el JSON está malformado o contiene un error?
  • ¿Y si los tipos JSON no coinciden con los tipos en los que estamos intentando decodificar?
  • ¿Qué sucede si la solicitud ni siquiera contiene un cuerpo?
# Send some XML as the request body
$ curl -d '<?xml version="1.0" encoding="UTF-8"?><note><to>Alice</to></note>' localhost:4000/v1/movies
{
"error": "invalid character '\u003c' looking for beginning of value"
}
# Send some malformed JSON (notice the trailing comma)
$ curl -d '{"title": "Moana", }' localhost:4000/v1/movies
{
"error": "invalid character '}' looking for beginning of object key string"
}
# Send a JSON array instead of an object
$ curl -d '["foo", "bar"]' localhost:4000/v1/movies
{
"error": "json: cannot unmarshal array into Go value of type struct { Title string
\"json:\\\"title\\\"\"; Year int32 \"json:\\\"year\\\"\"; Runtime int32 \"json:\\
\"runtime\\\"\"; Genres []string \"json:\\\"genres\\\"\" }"
}
# Send a numeric 'title' value (instead of string)
$ curl -d '{"title": 123}' localhost:4000/v1/movies
{
"error": "json: cannot unmarshal number into Go struct field .title of type string"
}
# Send an empty request body
$ curl -X POST localhost:4000/v1/movies
{
"error": "EOF"
}

En todos estos casos, podemos ver que nuestro createMovieHandler está haciendo lo correcto. Cuando recibe una solicitud inválida que no se puede decodificar en nuestra estructura de entrada, no se lleva a cabo ningún procesamiento adicional y se envía al cliente una respuesta JSON que contiene el mensaje de error devuelto por el método Decode().

Para una API privada que no será utilizada por miembros del público, este comportamiento probablemente esté bien y no necesitas hacer nada más.

Pero para una API pública, los mensajes de error en sí mismos no son ideales. Algunos son demasiado detallados y exponen información sobre la implementación subyacente de la API. Otros no son lo suficientemente descriptivos (como “EOF”), y algunos son simplemente confusos y difíciles de entender. Tampoco hay consistencia en el formato o el lenguaje utilizado.

Para mejorar esto, vamos a explicar cómo diagnosticar los errores devueltos por Decode() y reemplazarlos por mensajes de error más claros y fáciles de entender, para ayudar al cliente a depurar exactamente lo que está mal con su JSON.

4.2 Diagnosticando el error de decodificación

En este punto, el metodo Dedode() podria potencialmente regresar cinco tipos de error.

Error typesReason
json.SyntaxError io.ErrUnexpectedEOFThere is a syntax problem with the JSON being decoded.
json.UnmarshalTypeErrorA JSON value is not appropriate for the destination Go type.
json.InvalidUnmarshalErrorThe decode destination is not valid (usually because it is not a pointer). This is actually a problem with our application code, not the JSON itself.
io.EOFThe JSON being decoded is empty.

Diagnosticar estos posibles errores (lo cual podemos hacer utilizando las funciones errors.Is() y errors.As() de Go) hará que el código en nuestro createMovieHandler sea mucho más extenso y complicado. Y la lógica es algo que necesitaremos duplicar en otros controladores a lo largo de este proyecto también

Entonces, para ayudar con esto, creemos un nuevo ayudante llamado readJSON() en el archivo cmd/api/helpers.go. En este ayudante, descodificaremos el JSON del cuerpo de la solicitud como de costumbre, luego diagnosticaremos los errores y los reemplazaremos con nuestros propios mensajes personalizados según sea necesario.

package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "strconv"

    "github.com/julienschmidt/httprouter"
)

func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
    // Decode the request body into the target destination.
    err := json.NewDecoder(r.Body).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")
        // A json.InvalidUnmarshalError error will be returned if we pass something
        // that is not 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
        }
    }
    return nil
}

Con este nuevo ayudante en su lugar, volvamos al archivo cmd/api/movies.go y actualicemos nuestro createMovieHandler para utilizarlo. Así:

  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"`
  	}
  
-  	err := json.NewDecoder(r.Body).Decode(&input)
+    // 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 {
  		app.errorResponse(w, r, http.StatusBadRequest, err.Error())
  		return
  	}
  
  	fmt.Fprintf(w, "%+v\n", input)
  }

Reinicia la API, y luego probemos esto repitiendo las mismas solicitudes incorrectas que hicimos al comienzo del capítulo. Ahora deberías ver nuestros nuevos mensajes de error personalizados, similares a esto:

# Send some XML as the request body
$ curl -d '<?xml version="1.0" encoding="UTF-8"?><note><to>Alex</to></note>' localhost:4000/v1/movies
{
"error": "body contains badly-formed JSON (at character 1)"
}
# Send some malformed JSON (notice the trailing comma)
$ curl -d '{"title": "Moana", }' localhost:4000/v1/movies
{
"error": "body contains badly-formed JSON (at character 20)"
}
# Send a JSON array instead of an object
$ curl -d '["foo", "bar"]' localhost:4000/v1/movies
{
"error": "body contains incorrect JSON type (at character 1)"
}
# Send a numeric 'title' value (instead of string)
$ curl -d '{"title": 123}' localhost:4000/v1/movies
{
"error": "body contains incorrect JSON type for \"title\""
}
# Send an empty request body
$ curl -X POST localhost:4000/v1/movies
{
"error": "body must not be empty"
}

4.2 Haciendo el helper bad request

En el createMovieHandler de arriba usamos el helper generico app.errorResponse, para enviar al client un 400 Bad Request .

Vamos a reemplazar esto por uno especifico app.badRequestResponse():

package main
...
func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
app.errorResponse(w, r, http.StatusBadRequest, err.Error())
}
package main
...
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"`
}
err := app.readJSON(w, r, &input)
if err != nil {
// Use the new badRequestResponse() helper.
app.badRequestResponse(w, r, err)
return
}
fmt.Fprintf(w, "%+v\n", input)
}
...

Este es un cambio pequeño, pero útil. A medida que nuestra aplicación se vuelve gradualmente más compleja, el uso de ayudantes especializados como este para gestionar diferentes tipos de errores ayudará a garantizar que nuestras respuestas de error sean consistentes en todos nuestros endpoints.

4.2 Informacion adicional

4.2 Regresar errores vs panic

La desicion de usar panic en el helper readJSON() cuando obtenemos json.InvalidUnmarshalError no es porque si.Generalmente se cosidera mejor practiva en Go retornar nuestros errores y manejarlos de forma agraciada.

Pero en circunstancias especificas esta bien usar panic.

Es útil aquí distinguir entre las dos clases de errores que tu aplicación podría encontrar.

La primera clase de errores son los errores esperados que pueden ocurrir durante la operación normal. Algunos ejemplos de errores esperados son los causados por un tiempo de espera de consulta a la base de datos, la falta de disponibilidad de un recurso de red o una entrada incorrecta del usuario. Estos errores no necesariamente significan que haya un problema con tu programa en sí; de hecho, a menudo son causados por factores fuera del control de tu programa. Casi siempre es una buena práctica devolver estos tipos de errores y manejarlos de manera adecuada.

La otra clase de errores son los errores inesperados. Estos son errores que no deberían ocurrir durante la operación normal, y si lo hacen, probablemente se deba a un error del desarrollador o un error lógico en tu código. Estos errores son verdaderamente excepcionales, y en estas circunstancias es más ampliamente aceptado usar el pánico. De hecho, la biblioteca estándar de Go lo hace con frecuencia cuando cometes un error lógico o intentas utilizar las características del lenguaje de una manera no prevista, como cuando intentas acceder a un índice fuera de límites en una rebanada (slice) o intentas cerrar un canal que ya está cerrado.

Pero incluso en esos casos, te recomendaría intentar devolver y manejar los errores inesperados de manera elegante en la mayoría de los casos. La excepción a esto es cuando devolver el error agrega una cantidad inaceptable de manejo de errores al resto de tu código.

Volviendo a nuestro ayudante readJSON(), si obtenemos un error de json.InvalidUnmarshalError en tiempo de ejecución, es porque nosotros, como desarrolladores, hemos pasado un valor no admitido a Decode(). Esto es claramente un error inesperado que no deberíamos ver durante la operación normal y es algo que debería detectarse durante el desarrollo y las pruebas mucho antes de la implementación.

Si devolviéramos este error en lugar de entrar en pánico, tendríamos que introducir código adicional para gestionarlo en cada uno de nuestros controladores de la API, lo cual no parece ser un buen equilibrio para un error que es poco probable que veamos en producción.

La página de Go By Example sobre panics resume todo esto de manera bastante clara:

Un pánico generalmente significa que algo salió inesperadamente mal. Mayormente lo usamos para fallar rápidamente en errores que no deberían ocurrir durante la operación normal y que no estamos preparados para manejar de manera elegante.

4.3 Restringiendo inputs

Algo que podemos hacer para que nuestra API maneje de manera mas robusta los JSON que recibe seria manejar de alguna manera los campos recibidos que no existen en nuestra estructura.

Por ejemplo podemos recibir un el campo rating en nuestro createMovieHandler asi :

$ curl -i -d '{"title": "Moana", "rating":"PG"}' localhost:4000/v1/movies
HTTP/1.1 200 OK
Date: Tue, 06 Apr 2021 18:51:50 GMT
Content-Length: 41
Content-Type: text/plain; charset=utf-8
{Title:Moana Year:0 Runtime:0 Genres:[]}

Esa peticion funciona sin problemas y ningun error es enviado al cliente. En ciertos escenarios es el comportamiento que queremos, pero en nuestro caso es mejor alertar al cliente de ese error.

Por suerte, json.Decoder nos da DisallowUnknownFields() que podemos usar para generar un error cuando eso pase.

Otro problema que tenemos es que json.Decoder es diseniado para soportar streams de datos JSON. Cuando llamamos a Decode() en nuestro cuerpo de la peticion,actualmente lee el primer JSON value del body y lo decodifica.Si hacemos una segunda llamada a Decode(), leeria y decodificaria el segundo valor.

Pero debido a que llamamos a Decode() una vez, y solo una vez, en nuestro ayudante readJSON(), cualquier cosa después del primer valor JSON en el cuerpo de la solicitud se ignora. Esto significa que podrías enviar un cuerpo de solicitud que contenga múltiples valores JSON o contenido basura después del primer valor JSON, y nuestros controladores de API no generarían un error.

Por ejemplo:

# Body contains multiple JSON values
$ curl -i -d '{"title": "Moana"}{"title": "Top Gun"}' localhost:4000/v1/movies
HTTP/1.1 200 OK
Date: Tue, 06 Apr 2021 18:53:57 GMT
Content-Length: 41
Content-Type: text/plain; charset=utf-8
{Title:Moana Year:0 Runtime:0 Genres:[]}
# Body contains garbage content after the first JSON value
$ curl -i -d '{"title": "Moana"} :~()' localhost:4000/v1/movies
HTTP/1.1 200 OK
Date: Tue, 06 Apr 2021 18:54:15 GMT
Content-Length: 41
Content-Type: text/plain; charset=utf-8
{Title:Moana Year:0 Runtime:0 Genres:[]}

Para asegurarnos de que no haya valores JSON adicionales (ni ningún otro contenido) en el cuerpo de la solicitud, deberemos llamar a Decode() una segunda vez en nuestro ayudante readJSON() y verificar que devuelva un error de tipo io.EOF (fin de archivo).

Finalmente, actualmente no hay un límite superior en el tamaño máximo del cuerpo de la solicitud que aceptamos. Esto significa que nuestro createMovieHandler sería un objetivo atractivo para clientes maliciosos que deseen realizar un ataque de denegación de servicio en nuestra API. Podemos abordar esto utilizando la función http.MaxBytesReader() para limitar el tamaño máximo del cuerpo de la solicitud.

Actualicemos readJSON() para arreglar esas tres cosas.

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 {
		var syntaxError *json.SyntaxError
		var unmarshalTypeError *json.UnmarshalTypeError
		var invalidUnmarshalError *json.InvalidUnmarshalError

		// Add a new maxBytesError variable.
		var maxBytesError *http.MaxBytesError

		switch {

		case errors.As(err, &syntaxError):
			return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset)

		case errors.Is(err, io.ErrUnexpectedEOF):
			return errors.New("body contains badly-formed JSON")

		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)
		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 "<name>"". 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)
		// Use the errors.As() function to check whether the error has the type
		// *http.MaxBytesError. If it does, then it means the request body exceeded our
		// size limit of 1MB and we return a clear error message.
		case errors.As(err, &maxBytesError):
			return fmt.Errorf("body must not be larger than %d bytes", maxBytesError.Limit)

		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 !errors.Is(err, io.EOF) {
		return errors.New("body must only contain a single JSON value")
	}
	return nil
}

Una vez tengamos estos cambios, hagamos otravez las peticiones anteriores.

$ curl -d '{"title": "Moana", "rating":"PG"}' localhost:4000/v1/movies
{
"error": "body contains unknown key \"rating\""
}
$ curl -d '{"title": "Moana"}{"title": "Top Gun"}' localhost:4000/v1/movies
{
"error": "body must only contain a single JSON value"
}
$ curl -d '{"title": "Moana"} :~()' localhost:4000/v1/movies
{
"error": "body must only contain a single JSON value"
}

Por último, intentemos hacer una solicitud con un cuerpo JSON muy grande.

Para demostrar esto, he creado un archivo JSON de 1.5MB que puedes descargar en tu directorio /tmp ejecutando el siguiente comando:

$ wget -O /tmp/largefile.json https://www.alexedwards.net/static/largefile.json

Si intentas realizar una solicitud a tu punto final POST /v1/movies con este archivo como cuerpo de solicitud, la comprobación de http.MaxBytesReader() entrará en acción y deberías recibir una respuesta similar a esta:

$ curl -d @/tmp/largefile.json localhost:4000/v1/movies
{
"error": "body must not be larger than 1048576 bytes"
}

4.4 Decodificacion de JSON personalizada

Anteriormente en este post , agregamos un comportamiento de codificación personalizado a nuestra API para que la información de la duración de la película se mostrara en el formato “duración minutos” en nuestras respuestas JSON.

Ahora vamos a hacer al reves, haremos que nuestro handler createMovieHandler acepte en el campo runtime el tipo de dato “5 mins ”

Si en este momento recibimos la peticion con ese formato obtendremso un 400 bad request, ya que no es posible decodificar un string en un int32 :

$ curl -d '{"title": "Moana", "runtime": "107 mins"}' localhost:4000/v1/movies
{
"error": "body contains incorrect JSON type for \"runtime\""
}

Para que esto funcione, lo que debemos hacer es interceptar el proceso de decodificación y convertir manualmente la cadena JSON “duración minutos” en un entero int32 en su lugar.

4.4 La interfaz json.Unmarshal

La clave esta en conocer quen la interfaz json.Unmarshal se ve asi:

type Unmarshaler interface {
UnmarshalJSON([]byte) error
}

Cuando Go está decodificando JSON, verificará si el tipo de destino cumple con la interfaz json.Unmarshaler. Si cumple con la interfaz, Go llamará a su método UnmarshalJSON() para determinar cómo decodificar el JSON proporcionado en el tipo de destino. Esto es básicamente el reverso de la interfaz json.Marshaler que utilizamos anteriormente para personalizar nuestro comportamiento de codificación JSON.

Lo primero que tenemos que hacer es actualizar createMovieHandler para que la estructura input use el custom type Runtime , en vez de el regular int32. Al convertirlo en un tipo personalizado tenemos la libertad de implementar un metodo UnmarshalJSON().

func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
	var input struct {
		Title   string       `json:"title"`
		Year    int32        `json:"year"`
		Runtime data.Runtime `json:"runtime"`
		Genres  []string     `json:"genres"`
	}

	err := app.readJSON(w, r, &input)
	if err != nil {
		app.badRequestResponse(w, r, err)
		return
	}

	fmt.Fprintf(w, "%+v\n", input)
}

Vamos a internal/data/runtime.go y agregamos UnmarshalJSON() a nuestro Runtime. En este método, necesitamos analizar la cadena JSON en el formato “duración minutos”, convertir el número de duración en un int32 y luego asignarlo al valor de Runtime en sí mismo.

// 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")

// 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
	// "<runtime> 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
}

Una vez que hayas realizado estos cambios, reinicia la aplicación y luego realiza una solicitud utilizando el nuevo formato del valor de duración en el JSON. Deberías ver que la solicitud se completa con éxito y que el número se extrae de la cadena y se asigna al campo Runtime de nuestra entrada.

En cambio, si realizas la solicitud utilizando un número JSON o cualquier otro formato, deberías recibir ahora una respuesta de error que contiene el mensaje de la variable ErrInvalidRuntimeFormat, similar a esto:

$ curl -d '{"title": "Moana", "runtime": 107}' localhost:4000/v1/movies
{
"error": "invalid runtime format"
}
$ curl -d '{"title": "Moana", "runtime": "107 minutes"}' localhost:4000/v1/movies
{
"error": "invalid runtime format"
}

4.5 Validando JSON input

En muchos casos, desearás realizar comprobaciones de validación adicionales en los datos del cliente para asegurarte de que cumpla con tus reglas comerciales específicas antes de procesarlo. En este capítulo, ilustraremos cómo hacerlo en el contexto de una API JSON, actualizando nuestro createMovieHandler para verificar que.

  • El título de la película proporcionado por el cliente no está vacío y no supera los 500 bytes de longitud.
  • El año de la película no está vacío y se encuentra entre 1888 y el año actual.
  • La duración de la película no está vacía y es un número entero positivo.
  • La película tiene entre uno y cinco géneros (únicos).

Si cualquiera de esas comprobaciones falla, deseamos enviar al cliente una respuesta 422 Entidad no procesable junto con mensajes de error que describan claramente las fallas en la validación.

4.5 Creando el paquete validador

Para ayudarnos con la validación a lo largo de este proyecto, vamos a crear un pequeño paquete interno llamado “validator” que contendrá algunos tipos y funciones auxiliares simples y reutilizables.

mkdir internal/validator
touch internal/validator/validator.go

Dentro de __ internal/validator/validator.go__.

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-]")
)

// 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)
	}
}

// Generic function which returns true if a specific value is in a list of permitted
// values.
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
	return slices.Contains(permittedValues, 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)
}

// Generic function which returns true if all values in a slice are unique.
func Unique[T comparable](values []T) bool {
	uniqueValues := make(map[T]bool)
	for _, value := range values {
		uniqueValues[value] = true
	}
	return len(values) == len(uniqueValues)
}


En el código anterior, hemos definido un tipo personalizado llamado “Validator” que contiene un mapa de errores. El tipo “Validator” proporciona un método “Check()” para agregar errores al mapa de manera condicional, y un método “Valid()” que devuelve si el mapa de errores está vacío o no. También hemos agregado funciones como “PermittedValue()”, “Matches()” y “Unique()” para ayudarnos a realizar algunas comprobaciones de validación específicas.

Conceptualmente, este tipo “Validator” es bastante básico, pero eso no es algo malo. Como veremos a lo largo de estos post , en la práctica es sorprendentemente poderoso y nos brinda mucha flexibilidad y control sobre las comprobaciones de validación y cómo las realizamos.

4.5Realizando comprobaciones de validacion

Vamos a poner a Validator en uso.

Lo primero es actualizar cmd/api/error.go para incluir el nuevo failedValidationResponse() El cual escribe un 422 Unprocessable Entity y el contenido de los errores en el map desde nuestro Validator como un cuerpo de respuesta.

// 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)

Una vez hecho eso, vuelve al createMovieHandler y actualízalo para realizar las comprobaciones de validación necesarias en la estructura input. De la siguiente manera:

func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
	var input struct {
		Title   string       `json:"title"`
		Year    int32        `json:"year"`
		Runtime data.Runtime `json:"runtime"`
		Genres  []string     `json:"genres"`
	}

	err := app.readJSON(w, r, &input)
	if err != nil {
		app.badRequestResponse(w, r, err)
		return
	}

	// Initialize a new Validator instance.
	v := validator.New()
	// Use the Check() method to execute our validation checks. This will add the
	// provided key and error message to the errors map if the check does not evaluate
	// to true. For example, in the first line here we "check that the title is not
	// equal to the empty string". In the second, we "check that the length of the title
	// is less than or equal to 500 bytes" and so on.
	v.Check(input.Title != "", "title", "must be provided")
	v.Check(len(input.Title) <= 500, "title", "must not be more than 500 bytes long")
	v.Check(input.Year != 0, "year", "must be provided")
	v.Check(input.Year >= 1888, "year", "must be greater than 1888")
	v.Check(input.Year <= int32(time.Now().Year()), "year", "must not be in the future")
	v.Check(input.Runtime != 0, "runtime", "must be provided")
	v.Check(input.Runtime > 0, "runtime", "must be a positive integer")
	v.Check(input.Genres != nil, "genres", "must be provided")
	v.Check(len(input.Genres) >= 1, "genres", "must contain at least 1 genre")
	v.Check(len(input.Genres) <= 5, "genres", "must not contain more than 5 genres")
	// Note that we're using the Unique helper in the line below to check that all
	// values in the input.Genres slice are unique.
	v.Check(validator.Unique(input.Genres), "genres", "must not contain duplicate values")
	// Use the Valid() method to see if any of the checks failed. If they did, then use
	// the failedValidationResponse() helper to send a response to the client, passing
	// in the v.Errors map.
	if !v.Valid() {
		app.failedValidationResponse(w, r, v.Errors)
		return
	}
	fmt.Fprintf(w, "%+v\n", input)
}

Con eso hecho, deberíamos estar listos para probarlo. Reinicia la API y luego emite una solicitud al punto final POST /v1/movies que contenga datos no válidos, similar a esto.

$ BODY='{"title":"","year":1000,"runtime":"-123 mins","genres":["sci-fi","sci-fi"]}'
$ curl -i -d "$BODY" localhost:4000/v1/movies
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
Date: Wed, 07 Apr 2021 10:33:57 GMT
Content-Length: 180
{
"error": {
"genres": "must not contain duplicate values",
"runtime": "must be a positive integer",
"title": "must be provided",
"year": "must be greater than 1888"
}

4.5 Haciendo reglas de validaciones reusable

En proyectos grandes, es probable que desees reutilizar algunas de las mismas comprobaciones de validación en múltiples lugares. En nuestro caso, por ejemplo, queremos utilizar muchas de estas mismas comprobaciones más adelante cuando un cliente edite los datos de la película.

Para evitar la duplicación, podemos recopilar las comprobaciones de validación para una película en una función independiente llamada ValidateMovie(). En teoría, esta función podría estar en casi cualquier lugar de nuestro código, ya sea junto a los controladores en el archivo cmd/api/movies.go, o posiblemente en el paquete internal/validators. Pero personalmente, me gusta mantener las comprobaciones de validación cerca del tipo de dominio relevante en el paquete internal/data.

Si estás siguiendo, vuelve a abrir el archivo internal/data/movies.go y agrega una función ValidateMovie() que contenga las comprobaciones de la siguiente manera.

Importante: Observa que las comprobaciones de validación ahora se realizan en una estructura Movie, no en la estructura de entrada en nuestros controladores.

Una vez hecho esto, debemos volver a nuestro createMovieHandler y actualizarlo para inicializar una nueva estructura Movie, copiar los datos de nuestra estructura de entrada y luego llamar a esta nueva función de validación. De la siguiente manera:

func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
	var input struct {
		Title   string       `json:"title"`
		Year    int32        `json:"year"`
		Runtime data.Runtime `json:"runtime"`
		Genres  []string     `json:"genres"`
	}
	err := app.readJSON(w, r, &input)
	if err != nil {
		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
	}

Al observar este código, es posible que te surjan algunas preguntas. En primer lugar, podrías preguntarte por qué estamos inicializando la instancia de Validator en nuestro controlador y pasándola a la función ValidateMovie, en lugar de inicializarla en ValidateMovie y devolverla como valor de retorno. Esto se debe a que a medida que nuestra aplicación se vuelve más compleja, necesitaremos llamar a múltiples ayudantes de validación desde nuestros controladores, en lugar de solo uno, como lo estamos haciendo en el ejemplo anterior. Por lo tanto, inicializar el Validator en el controlador y pasarlo alrededor nos brinda más flexibilidad.

También podrías preguntarte por qué estamos decodificando la solicitud JSON en la estructura de entrada y luego copiando los datos, en lugar de decodificar directamente en la estructura Movie. El problema con la decodificación directa en una estructura Movie es que un cliente podría proporcionar las claves “id” y “version” en su solicitud JSON, y los valores correspondientes se decodificarían sin ningún error en los campos “ID” y “Version” de la estructura Movie, aunque no deseamos que eso ocurra. Podríamos verificar los campos necesarios en la estructura Movie después del evento para asegurarnos de que estén vacíos, pero eso puede parecer un poco “poco ortodoxo”. La decodificación en una estructura intermedia (como lo hacemos en nuestro controlador) es un enfoque más limpio, simple y robusto, aunque un poco más verboso.

Post Relacionados