3. Enviando respuestas JSON

En este post vamos a actualizar nuestra api para que responda con un JSON en vez de texto plano.

La pelicula deberia verse asi en la respuesta.

{
  "id": 123,
  "title": "Casablanca",
  "runtime": 102,
  "genres": [
    "drama",
    "romance",
    "war"
  ],
  "version": 1
}

Los saltos de linea no son importantes, el codigo enterior puede ser este y funcionaria igual.

{"id":123,"title":"Casablanca","runtime":102,"genres":["drama","romance","war"],"version":1}

Si no usaste JSON antes, podes leer esta guia para principiantes.

En esta seccion aprenderemos:

  • Como enviar respuestas JSON desde nuestra REST API (incluyendo las respuestas de error).
  • Como codificar objetos nativos de Go en JSON usando el paquete encoding/json.
  • Diferentes tecnicas para personalizar como los objetos de Go son codificados a JSON. Primero usando struct tags, y luego aprovechando la interfaz json.Marshaler.
  • Como crear un helper reusable para enviar respuestas JSON, esto garantiza que todas las respuestas de la API tenga una estructura consistente.

3.1 Fixed-Format JSON

Comencemos actualizando healthcheckHandler para enviar como respuesta un JSON que se vera asi:

{"status": "available", "environment": "development", "version": "1.0.0"}

En Go podemos manejar los json con de la misma manera que lo hariamos con un texto plano al momento de enviar la respuesta, usando w.Write(),io.WriteString() o fmt.Fprint.

Lo unico especial que tenemso que tener en cuenta es el setear Content-Type: application/json en el header de la respuesta , de esa manera el cliente sabe que la respuesta viene en formato JSON y puede interpretarlo correctamente.

Actualicemos __cmd/api/healthcheck.go y modificamos healthcheckHandler:

package main

import (
	"fmt"
	"net/http"
)

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
	// Create a fixed-format JSON response from a string. Notice how we're using a raw
	// string literal (enclosed with backticks) so that we can include double-quote
	// characters in the JSON without needing to escape them? We also use the %q verb to
	// wrap the interpolated values in double-quotes.
	js := `{"status": "available", "environment": %q, "version": %q}`
	js = fmt.Sprintf(js, app.config.env, version)
	// Set the "Content-Type: application/json" header on the response. If you forget to
	// this, Go will default to sending a "Content-Type: text/plain; charset=utf-8"
	// header instead.
	w.Header().Set("Content-Type", "application/json")
	// Write the JSON as the HTTP response body.
	w.Write([]byte(js))
}

Iniciamos el servidor y hacemos una peticion GET /v1/healthcheck:

$ curl -i localhost:4000/v1/healthcheck
HTTP/1.1 200 OK
Content-Type: application/json
Date: Tue, 06 Apr 2021 08:38:12 GMT
Content-Length: 73
{"status": "available", "environment": "development", "version": "1.0.0"}

Podes verlo en un navegador directamente y podras ver que esta en formato JSON.

3.2 JSON Encoding

Veamos como codificar objetos nativos de Go como mapas,estructuras y slices a JSON.

El paquete encoding/json provee dos opciones para codificar las cosas a JSON. Podes usar json.Marshal() o podes declara y usar el tipo json.Encoder.

Explicare ambos enfoques en esta seccion, pero para una respueta HTTP en formato JSON generalmente es mejor json.Marshal(). Asi que empezare con esa.

La manera en que json.Marshal() funciona es simple conceptualmente. Le pasamos un objeto nativo de Go como parametro y nos retornara un JSON en un slice de tipo []byte. La firma de la funcion es la siguiente:

func Marshal(v any)([]byte,error)

Vamos a actualizar healthcheckHandler para que use json.Marshal() para que genere una respuesta JSON directamente desde el map de Go.

 package main
 
 import (
- 	"fmt"
+ 	"encoding/json"

 	"net/http"
 )
 
 func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
- 	js := `{"status": "available", "environment": %q, "version": %q}`
- 	js = fmt.Sprintf(js, app.config.env, version)

+    data := map[string]string{
+		"status":      "available",
+		"environment": app.config.env,
+		"version":     version,
+	}
+
+	js, err := json.Marshal(data)
+	if err != nil {
+		app.logger.Error(err.Error())
+		http.Error(w, "The server encountered a problem and could not process your request", http.StatusInternalServerError)
+		return
+	}

+	js = append(js, '\n')
	w.Header().Set("Content-Type", "application/json")

+	w.Write(js)
- 	w.Write([]byte(js))
}

Reiniciamos la API y si vamos al navegador hacia localhost:4000/v1/healthcheck deberiamos ver el json. Ademas aparece ordenado alfabeticamente segun las keys.

3.2 Creando el helper writeJSON

Vamos a cmd/api/helpers.go y creemos el metodo writeJSON()

// Define a writeJSON() helper for sending responses. This takes the destination
// http.ResponseWriter, the HTTP status code to send, the data to encode to JSON, and a
// header map containing any additional HTTP headers we want to include in the response.
func (app *application) writeJSON(w http.ResponseWriter, status int, data any, headers http.Header) error {
	js, err := json.Marshal(data)
	// Encode the data to JSON, returning the error if there was one.
	if err != nil {
		return err
	}
	// Append a newline to make it easier to view in terminal applications.
	js = append(js, '\n')
	// At this point, we know that we won't encounter any more errors before writing the
	// response, so it's safe to add any headers that we want to include. We loop
	// through the header map and add each header to the http.ResponseWriter header map.
	// Note that it's OK if the provided header map is nil. Go doesn't throw an error
	// if you try to range over (or generally, read from) a nil map.
	for key, value := range headers {
		w.Header()[key] = value
	}
	// Add the "Content-Type: application/json" header, then write the status code and
	// JSON response.
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	w.Write(js)
	return nil
}

Aun no lo usaremos pero si quisieramos setear los headers y pasarlos como parametros lo hariamos asi.

headers := make(http.Header)
headers.Add("Content-Disposition", "attachment; filename=example.json")
headers.Add("Cache-Control", "no-store")

// Luego puedes llamar a writeJSON pasando los encabezados
err := app.writeJSON(w, http.StatusOK, data, headers)
if err != nil {
    // Manejo de errores
}

Ahora hagamos uso de nuestro nuevo metodo en healthcheckHandler

  func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
  	data := map[string]string{
  		"status":      "available",
  		"environment": app.config.env,
  		"version":     version,
  	}
  
-  	js, err := json.Marshal(data)
+  	err := app.writeJSON(w, http.StatusOK, data, nil)
  	if err != nil {
  		app.logger.Error(err.Error())
  		http.Error(w, "The server encountered a problem and could not process your request", http.StatusInternalServerError)
  		return
  	}
  
-  	js = append(js, '\n')
-  	w.Header().Set("Content-Type", "application/json")
  
-  	w.Write(js)
}

Si reinicias la API todo deberia funcionar igual que antes.

3.2 Informacion adicional

3.2 Usando json.Encoder

Al principio de este capítulo mencioné que también es posible usar el tipo json.Encoder de Go para realizar la codificación JSON. Esto te permite codificar un objeto a JSON y escribir ese JSON en un flujo de salida en un solo paso.

Por ejemplo, puedes usarlo en un controlador (handler) de la siguiente manera:

func (app *application) ejemploControlador(w http.ResponseWriter, r *http.Request) {
    data := map[string]string{
        "hello": "world",
    }
    // Establece la cabecera "Content-Type: application/json" en la respuesta.
    w.Header().Set("Content-Type", "application/json")
    // Utiliza la función json.NewEncoder() para inicializar una instancia de json.Encoder que
    // escribe en http.ResponseWriter. Luego llamamos a su método Encode(), pasando los datos
    // que queremos codificar a JSON (en este caso, el mapa anterior). Si los datos se pueden
    // codificar con éxito en JSON, se escribirán en nuestro http.ResponseWriter.
    err := json.NewEncoder(w).Encode(data)
    if err != nil {
        app.logger.Error(err.Error())
        http.Error(w, "El servidor encontró un problema y no pudo procesar tu solicitud", http.StatusInternalServerError)
    }
}

Este patrón funciona y es muy limpio y elegante, pero si lo consideras cuidadosamente, podrías notar un pequeño problema…

Cuando llamamos json.NewEncoder(w).Encode(data), el JSON se crea y se escribe en http.ResponseWriter en un solo paso, lo que significa que no hay oportunidad de establecer las cabeceras de respuesta HTTP de manera condicional según si el método Encode() devuelve un error o no.

Imagina, por ejemplo, que quieres establecer una cabecera “Cache-Control” en una respuesta exitosa, pero no establecerla si la codificación JSON falla y tienes que devolver una respuesta de error.

Implementar esto de manera limpia mientras usas el patrón json.Encoder es bastante complicado. Podrías establecer la cabecera “Cache-Control” y luego eliminarla del mapa de cabeceras en caso de un error, pero eso es un poco chapucero. Otra opción es escribir el JSON en un búfer temporal bytes.Buffer en lugar de escribirlo directamente en http.ResponseWriter. Luego puedes verificar los errores, establecer la cabecera “Cache-Control” y copiar el JSON desde el búfer temporal a http.ResponseWriter. Pero una vez que empiezas a hacer eso, es más simple y más limpio (y un poco más rápido) usar la alternativa json.Marshal() en su lugar.

3.2 Performance de json.Encode y json.Marshal

json.Encode es apenas mas optimo por milisegundos, lo que no deberia preocuparte al menos que estes manejando datos de manera masiva, sino no hay diferencia apreciable. Podemos ver el benchmark.

$ go test -run=^$ -bench=. -benchmem -count=3 -benchtime=5s
goos: linux
goarch: amd64
BenchmarkEncoder-8 3477318 1692 ns/op 1046 B/op 15 allocs/op
BenchmarkEncoder-8 3435145 1704 ns/op 1048 B/op 15 allocs/op
BenchmarkEncoder-8 3631305 1595 ns/op 1039 B/op 15 allocs/op
BenchmarkMarshal-8 3624570 1616 ns/op 1119 B/op 16 allocs/op
BenchmarkMarshal-8 3549090 1626 ns/op 1123 B/op 16 allocs/op
BenchmarkMarshal-8 3548070 1638 ns/op 1123 B/op 16 allocs/op

3.3 Encoding Structs

En esta seccion vamos a editar showMovieHandler para que regrese un JSON de una sola pelicula.

Esta vez de codificar un map a un objeto JSON, como hicimos antes, esta vez vamos a hacerlo con una struct.

Primero tenemos que crear la estrutura Movie. Lo haremos en el paquete internal/data.

mkdir internal/data
touch internal/data/movies.go
package data

import (
	"time"
)

type Movie struct {
	ID        int64     // Unique integer ID for the movie
	CreatedAt time.Time // Timestamp for when the movie is added to our database
	Title     string    // Movie title
	Year      int32     // Movie release year
	Runtime   int32     // Movie runtime (in minutes)
	Genres    []string  // Slice of genres for the movie (romance, comedy, etc.)
	Version   int32     // The version number starts at 1 and will be incremented each
	// time the movie information is updated
}

Importante: Es crucial señalar aquí que todos los campos en nuestra estructura Movie están exportados (es decir, comienzan con una letra mayúscula), lo cual es necesario para que sean visibles para el paquete encoding/json de Go. Cualquier campo que no esté exportado no se incluirá al codificar una estructura a JSON.

Ahora actualizamos showMovieHandler para que haga uso de esta estructura y la llenamos de datos falsos para luego devolver el json con el metodo writeJSON().

func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) {

	id, err := app.readIDParam(r)
	if err != nil || id < 1 {
		http.NotFound(w, r)
		return
	}

	// Create a new instance of the Movie struct, containing the ID we extracted from
	// the URL and some dummy data. Also notice that we deliberately haven't set a
	// value for the Year field.
	movie := data.Movie{
		ID:        id,
		CreatedAt: time.Now(),
		Title:     "Casablanca",
		Runtime:   102,
		Genres:    []string{"drama", "romance", "war"},
		Version:   1,
	}
	// Encode the struct to JSON and send it as the HTTP response.
	err = app.writeJSON(w, http.StatusOK, movie, nil)
	if err != nil {
		app.logger.Error(err.Error())
		http.Error(w, "The server encountered a problem and could not process your request", http.StatusInternalServerError)
	}
}

Reiniciamos la API y vamos a localhost:4000/v1/movies/123 Deberiamos ver los datos en formato JSON.

Si un campo de la estructura no tiene un valor explícito establecido, entonces la codificación JSON del valor cero para ese campo aparecerá en la salida. Podemos ver un ejemplo de esto en la respuesta anterior: no establecimos un valor para el campo Year en nuestro código Go, pero aún así aparece en la salida JSON con el valor 0.

3.3 Cambiando las keys en el objeto JSON

Podemos personalizar como se mostraran las keys en le objeto de salida usando struct tags. Los nombres pueden ser iguales o uno totalmente diferente.

package data

import (
	"time"
)

type Movie struct {
	ID        int64     `json:"id"`
	CreatedAt time.Time `json:"created_at"` // Timestamp for when the movie is added to our database
	Title     string    `json:"title"`      // Movie title
	Year      int32     `json:"year"`       // Movie release year
	Runtime   int32     `json:"runtime"`    // Movie runtime (in minutes)
	Genres    []string  `json:"genres"`     // Slice of genres for the movie (romance, comedy, etc.)
	Version   int32     `json:"version"`    // The version number starts at 1 and will be incremented each
	// time the movie information is updated
}


Si resetamos el server vamos a apoder ver que ahora createdAt se muestra como created_at en la respuesta json

3.3 Ocultar campos de la estructura en el objeto JSON

Podemos controlar que campos se veran en el output de la respuesta usando omitempty o -

- Esto es util cuando no queremos que datos sensibles o datos irrelevantes llegen al frontend, como puede ser algun hash o un password.

Por otro lado omitempty ocultara el campo solo si el campo esta vacio.Con vacio queremos decir falsy values:

  • false, 0 o ""
  • array, slice o map vacios
  • nill pointer o nill interface
type Movie struct {
	ID        int64     `json:"id"`
	CreatedAt time.Time `json:"-"` // Use the - directive
	Title     string    `json:"title"`
	Year      int32     `json:"year,omitempty"`    // Add the omitempty directive
	Runtime   int32     `json:"runtime,omitempty"` // Add the omitempty directive
	Genres    []string  `json:"genres,omitempty"`  // Add the omitempty directive
	Version   int32     `json:"version"`
}

Ahora si reinicamos el servidor no se mostraran los campos que definimos antes.

Nota: También puedes evitar que un campo de la estructura aparezca en la salida JSON simplemente haciéndolo no exportado. Sin embargo, generalmente es una mejor opción usar la etiqueta de estructura json:”-”: es una indicación explícita tanto para Go como para cualquier lector futuro de tu código de que no deseas que el campo se incluya en el JSON, y ayuda a prevenir problemas si alguien cambia el campo para que sea exportado en el futuro sin darse cuenta de las consecuencias.

3.3 Informacion adicional

3.3 Directiva string struct tag

Una directiva de tag menos frequente es string el cual hace que el campo al ser devuelto en el JSON sea convertido a string.

Por ejemplo si queremos hacerlo con el campo runtime lo hariamso asi:

type Movie struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"-"`
Title string `json:"title"`
Year int32 `json:"year,omitempty"`
Runtime int32 `json:"runtime,omitempty,string"` // Add the string directive
Genres []string `json:"genres,omitempty"`
Version int32 `json:"version"`
}

El resultado seria:

{
"id": 123,
"title": "Casablanca",
"runtime": "102", ← This is now a string
"genres": [
"drama",
"romance",
"war"
],
"version": 1
}

Ten en cuenta que la directiva “string” solo funcionará en campos de estructura que tengan tipos int*, uint*, float* o bool. Para cualquier otro tipo de campo de estructura, no tendrá ningún efecto.

3.4 Fomateando las respuestas

Si queremos hacer alguna de las peticiones anteriores con curl veremos que no esta formateado.

$ curl localhost:4000/v1/healthcheck
{"environment":"development","status":"available","version":"1.0.0"}
$ curl localhost:4000/v1/movies/123
{"id":123,"title":"Casablanca","runtime":102,"genres":["drama","romance","war"],"version":1}

Podemos hacerlo mas facil de leer desde la terminal usando json.MarshalIndent(), esto agregara automaticamente espacios en blanco al output.

Actualicemos writeJSON().

  func (app *application) writeJSON(w http.ResponseWriter, status int, data any, headers http.Header) error {

-  	js, err := json.Marshal(data)
+  	js, err := json.MarshalIndent(data, "", "\t")
  	if err != nil {
  		return err
  	}
  	js = append(js, '\n')
  
  	for key, value := range headers {
  		w.Header()[key] = value
  	}
  
  	w.Header().Set("Content-Type", "application/json")
  	w.WriteHeader(status)
  	w.Write(js)
  	return nil
  }

Si reinicamos la API.

$ curl -i localhost:4000/v1/healthcheck
{
  "environment": "development",
  "status": "available",
  "version": "1.0.0"
}
$ curl localhost:4000/v1/movies/123
{
  "id": 123,
  "title": "Casablanca",
  "runtime": 102,
  "genres": [
  "drama",
  "romance",
  "war"
],
"version": 1
}

3.4 Performance relativa

Este hermoso formateo no viene gratis, agregar estos espaciados hace trabajar mas a Go, veamos los benchmarks.

$ go test -run=^$ -bench=. -benchmem -count=3 -benchtime=5s
goos: linux
goarch: amd64
BenchmarkMarshalIndent-8 2177511 2695 ns/op 1472 B/op 18 allocs/op
BenchmarkMarshalIndent-8 2170448 2677 ns/op 1473 B/op 18 allocs/op
BenchmarkMarshalIndent-8 2150780 2712 ns/op 1476 B/op 18 allocs/op
BenchmarkMarshal-8 3289424 1681 ns/op 1135 B/op 16 allocs/op
BenchmarkMarshal-8 3532242 1641 ns/op 1123 B/op 16 allocs/op
BenchmarkMarshal-8 3619472 1637 ns/op 1119 B/op 16 allocs/op

En estos análisis de rendimiento, podemos observar que json.MarshalIndent() tarda un 65% más en ejecutarse y utiliza aproximadamente un 30% más de memoria que json.Marshal(), además de realizar dos asignaciones de memoria en el montón adicionales. Estas cifras pueden variar según lo que estés codificando, pero en mi experiencia, son bastante indicativas del impacto en el rendimiento.

Nota: En realidad, json.MarshalIndent() funciona llamando a json.Marshal() de manera normal y luego ejecutando el JSON a través de la función independiente json.Indent() para agregar el espaciado. También existe una función inversa disponible, json.Compact(), que puedes usar para eliminar el espacio en blanco del JSON.

3.4 Envolviendo respuestas

Lo siguiente es actualizar la respuesta para que siempre este envuelta por un padre en el objeto JSON asi:

{
"movie": {
  "id": 123,
  "title": "Casablanca",
  "runtime": 102,
  "genres": [
  "drama",
  "romance",
  "war"
],
"version":1
}
}

Te das cuenta que los datos de la pelicula esta envuelta en el padre movie?, en vez de estar en la raiz del JSON.

Envolver los datos de la respuesta no es obligatorio y podes hacerlo o no. Pero hay algunas ventajas al hacerlo.

  • Incluir la key movie ayuda a que la respuesta sea auto documentada. Cualquier humano que vea la respueta sin contexto, entendera con que esta relacionada la respuesta.
  • Reduce el riego de error en el lado del cliente.Por que es dificil procesar por accidente una respuesta pensando que es algo diferente, para obtener los datos el cliente debe explicitamente referenciar a la key movie.
  • Siempre que “envolvemos” los datos devueltos por nuestra API, mitigamos una vulnerabilidad de seguridad en navegadores más antiguos que puede surgir si devolvemos un arreglo JSON como respuesta.

Hay un par de tecnicas que podemos usar para envolver nuestras respuestas, Pero lo mantendremos simple y lo haremos creando un custom map envelope con typo map[string]any.

Comencemos actualizando cmd/api/helpers.go.

+ // Define an envelope type.
+ type envelope map[string]any
+ // Change the data parameter to have the type envelope instead of any.
+   func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error {
  	js, err := json.MarshalIndent(data, "", "\t")
  	if err != nil {
  		return err
  	}
  	js = append(js, '\n')
  
  	for key, value := range headers {
  		w.Header()[key] = value
  	}
  
  	w.Header().Set("Content-Type", "application/json")
  	w.WriteHeader(status)
  	w.Write(js)
  	return nil
  }

Ahora tenemos que actualizar showMovieHandler para crear una instancia del mapa envelope que contanga los datos de la pelicula y posteriormente pasarlo a writeJSON() en vez de pasar la pelicula directamente.

  func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) {
  
  	id, err := app.readIDParam(r)
  	if err != nil || id < 1 {
  		http.NotFound(w, r)
  		return
  	}
  
  	// Create a new instance of the Movie struct, containing the ID we extracted from
  	// the URL and some dummy data. Also notice that we deliberately haven't set a
  	// value for the Year field.
  	movie := data.Movie{
  		ID:        id,
  		CreatedAt: time.Now(),
  		Title:     "Casablanca",
  		Runtime:   102,
  		Genres:    []string{"drama", "romance", "war"},
  		Version:   1,
  	}
+  	// create an envelope{"movie":movie} y lo pasamos a writeJSON()
+  	err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
  	if err != nil {
  		app.logger.Error(err.Error())
  		http.Error(w, "The server encountered a problem and could not process your request", http.StatusInternalServerError)
  	}
  }

Hacemos lo mismo con healthcheckHandler.

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
	// Declare an envelope map containing the data for the response. Notice that the way
	// we've constructed this means the environment and version data will now be nested
	// under a system_info key in the JSON response.
	env := envelope{
		"status": "available",
		"system_info": map[string]string{
			"environment": app.config.env,
			"version":     version,
		},
	}
	err := app.writeJSON(w, http.StatusOK, env, nil)
	if err != nil {
		app.logger.Error(err.Error())
		http.Error(w, "The server encountered a problem and could not process your request", http.StatusInternalServerError)
		return
	}

}

Reiniciamos el servidor y al hacer otra peticion via curl tenemos que ver esto:

$ curl localhost:4000/v1/movies/123
{
  "movie": {
  "id": 123,
  "title": "Casablanca",
  "runtime": 102,
  "genres": [
  "drama",
  "romance",
  "war"
],
"version":1
}
}
$ curl localhost:4000/v1/healthcheck
{
  "status": "available",
  "system_info": {
  "environment": "development",
  "version": "1.0.0"
}
}

3.4 Informacion adicional

3.4 Estructura de respuesta

Es importante enfatizar que no hay una única forma correcta o incorrecta de estructurar tus respuestas JSON. Hay algunos formatos populares, como JSON:API y jsend, que podrías optar por seguir o utilizar como inspiración, pero ciertamente no es necesario, y la mayoría de las API no siguen estos formatos. La estructura de tus respuestas JSON puede adaptarse a las necesidades específicas de tu aplicación y a las convenciones que consideres adecuadas.

Sin embargo, sea cual sea la estructura que elijas, es valioso pensar en el formato de antemano y mantener una estructura de respuesta clara y consistente en todos tus diferentes puntos finales de la API, especialmente si están disponibles para uso público. Esto facilitará el entendimiento y el uso de tu API tanto para ti como para los desarrolladores que la utilicen.

3.5 Personalizacion avanzada de JSON

Usando struct tags, agregando espaciado y envolviendo los datos, hemos agregado bastante personalizacion a nuestras respuestas JSON. Pero que si necesitamos aun mas?

Para responder a esta pregunta, primero tenemos que entender como Go maneja la codificacion de los JSON por detras.

Cuando Go está codificando un tipo particular a JSON, busca si el tipo tiene un método MarshalJSON() implementado en él. Si existe dicho método, Go lo llamará para determinar cómo codificarlo.

Estrictamente hablando, cuando Go codifica un tipo particular a JSON, busca si el tipo cumple con la interfaz json.Marshaler, que se ve así:

type Marshaler interface {
    MarshalJSON() ([]byte, error)
}

Si el tipo satisface la interfaz, entonces Go llamará a su método MarshalJSON() y utilizará el slice []byte que este método devuelve como el valor JSON codificado.

Si el tipo no tiene un método MarshalJSON(), entonces Go intentará codificarlo a JSON basándose en sus propias reglas internas.

Por lo tanto, si deseamos personalizar cómo se codifica algo, todo lo que necesitamos hacer es implementar un método MarshalJSON() en él que devuelva una representación JSON personalizada de sí mismo en un slice []byte.

Puedes ver esto en acción si examinas el código fuente del tipo time.Time de Go. En realidad, time.Time es una estructura (struct), pero tiene un método MarshalJSON() que genera una representación en formato RFC 3339 de sí mismo. Esto es lo que se llama cada vez que se codifica un valor de tipo time.Time a JSON.

3.5 Personalizando el campo Runtime

Para ayudar a ilustrar esto, Cuando nuestra estructura Movie es codificada a JSON, el campo runtime el cual es de typo int32, actualmente es fomateado como un number en el JSON. Cambiemos esto para que al ser codificado sea un string con un formato __runtime mins. De manera que quede asi:

{
  "id": 123,
  "title": "Casablanca",
  "runtime": "102 mins", ← This is now a string
  "genres": [
  "drama",
  "romance",
  "war"
],
"version":1
}

Hay un par de maneras en lo que podriamos hacer esto, pero un limpio y simple enfoque es crear un typo personalizado especialmente para el campo Runtime e implementar un metodo MarshalJSON() en ese typo.

Para evitar que nuestro archivo internal/data/movie.go se desordene , vamos a crear un nuevo archivo para mantener la logica del typo Runtime:

$ touch internal/data/runtime.go

Agregamos el siguiente codigo:

package data

import (
	"fmt"
	"strconv"
)

// Declare a custom Runtime type, which has the underlying type int32 (the same as our
// Movie struct field).
type Runtime int32

// 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 "<runtime> mins").
func (r Runtime) MarshalJSON() ([]byte, error) {
	// Generate a string containing the movie runtime in the required format.
	jsonValue := fmt.Sprintf("%d mins", r)
	// Use the strconv.Quote() function on the string to wrap it in double quotes. It
	// needs to be surrounded by double quotes in order to be a valid *JSON string*.
	quotedJSONValue := strconv.Quote(jsonValue)
	// Convert the quoted string value to a byte slice and return it.
	return []byte(quotedJSONValue), nil
}

Hay dos cosas que decir sobre esto:

Si el metodo MarshalJSON() retorna un valor JSON string, como lo hacemos nostros , entonces tenes qeu envolver el string en comillas dobles antes de devolverlo.De otra manera no sera interpretado como un JSON string y recibiras un runtime error similar a este:

json: error calling MarshalJSON for type data.Runtime: invalid character 'm' after top-level value

Deliberadamente usamos un value reciver para nuestro metodo MarshalJSON() en vez de un puntero func(r *Runtime) MarshalJSON(). Esto nos da mas flexibilidad porque un value reciver funciona para ambos casos,esto incluye pointers values. Effective Go menciona:

La regla sobre punteros versus valores para receptores es que los métodos de valor se pueden invocar en punteros y valores, pero los métodos de puntero solo se pueden invocar en punteros.

Mas informacion sobre pointer reciver y value recivers

Ahora que tenemos el tipo personalizado Runtime es definido, abrimos internal/data/movies.go y actualizamos la estructura Movie para usarlo asi:

package data

import (
	"time"
)

type Movie struct {
	ID        int64     `json:"id"`
	CreatedAt time.Time `json:"-"`
	Title     string    `json:"title"`
	Year      int32     `json:"year,omitempty"`
	Runtime   Runtime   `json:"runtime,omitempty"`
	Genres    []string  `json:"genres,omitempty"`
	Version   int32     `json:"version"`
}

Si reinicias la API, podemos ver modificado el campo runtime :

$ curl localhost:4000/v1/movies/123
{
  "movie": {
  "id": 123,
  "title": "Casablanca",
  "runtime": "102 mins",
  "genres": [
  "drama",
  "romance",
  "war"
],
"version": 1
}
}

Pero hay un inconveniente. Es importante tener en cuenta que el uso de tipos personalizados a veces puede ser incómodo al integrar tu código con otros paquetes, y es posible que necesites realizar conversiones de tipos para cambiar tu tipo personalizado hacia y desde un valor que los otros paquetes entiendan y acepten.

3.5 Informacion adicional

Existen un par de enfoques alternativos que podrías tomar para lograr el mismo resultado aquí, y me gustaría describirlos brevemente y discutir sus ventajas y desventajas. Si estás siguiendo el desarrollo del código, no realices ninguno de estos cambios (a menos que tengas curiosidad, por supuesto).

3.5 Alternativa #1 - PersonalizaR la estructura Movie

En lugar de crear un tipo personalizado Runtime, podríamos haber implementado un método MarshalJSON() en nuestra estructura Movie y personalizado todo el proceso. Sería algo así como esto:

// Note that there are no struct tags on the Movie struct itself.
type Movie struct {
  CreatedAt time.Time
  ID int64
  Title string
  Year int32
  Runtime int32
  Genres []string
  Version int32
}
// Implement a MarshalJSON() method on the Movie struct, so that it satisfies the
// json.Marshaler interface.
func (m Movie) MarshalJSON() ([]byte, error) {
// Declare a variable to hold the custom runtime string (this will be the empty
// string "" by default).
var runtime string
// If the value of the Runtime field is not zero, set the runtime variable to be a
// string in the format "<runtime> mins".
if m.Runtime != 0 {
runtime = fmt.Sprintf("%d mins", m.Runtime)
}
// Create an anonymous struct to hold the data for JSON encoding. This has exactly
// the same fields, types and tags as our Movie struct, except that the Runtime
// field here is a string, instead of an int32. Also notice that we don't include
// a CreatedAt field at all (there's no point including one, because we don't want
// it to appear in the JSON output).
aux := struct {
  ID int64 `json:"id"`
  Title string `json:"title"`
  Year int32 `json:"year,omitempty"`
  Runtime string `json:"runtime,omitempty"` // This is a string.
  Genres []string `json:"genres,omitempty"`
  Version int32 `json:"version"`
}{
// Set the values for the anonymous struct.
  ID: m.ID,
  Title: m.Title,
  Year: m.Year,
  Runtime: runtime, // Note that we assign the value from the runtime variable here.
  Genres: m.Genres,
  Version: m.Version,
}
// Encode the anonymous struct to JSON, and return it.
return json.Marshal(aux)
}

En el método MarshalJSON(), creamos una nueva estructura “anónima” y la asignamos a la variable aux. Esta estructura anónima es básicamente idéntica a nuestra estructura Movie, excepto que el campo Runtime tiene el tipo string en lugar de int32. Luego, copiamos todos los valores directamente de la estructura Movie a la estructura anónima, excepto el valor de Runtime, que convertimos a una cadena en el formato “runtime mins” primero. Finalmente, codificamos la estructura anónima a JSON, no la estructura Movie original, y la devolvemos.

También es importante señalar que esto está diseñado de manera que la directiva omitempty siga funcionando con nuestra codificación personalizada. Si el valor del campo Runtime es cero, entonces la variable local runtime seguirá siendo igual a "", lo que (como mencionamos anteriormente) se considera “vacío” para fines de codificación.

3.5 Alternativa #2 - Embebiendo un alias

La desventaja del enfoque anterior es que el código se siente bastante verboso y repetitivo. Puede que te preguntes: ¿hay una forma mejor?

Para reducir la duplicación, en lugar de escribir todos los campos de la estructura manualmente, es posible incrustar un alias de la estructura Movie en la estructura anónima. Sería algo así:

type Movie struct {
    ID       int64     `json:"id"`
    CreatedAt time.Time `json:"-"`
    Title    string    `json:"title"`
    Year     int32     `json:"year,omitempty"`
    Runtime  int32     `json:"-"`
    Genres   []string  `json:"genres,omitempty"`
    Version  int32     `json:"version"`
}

func (m Movie) MarshalJSON() ([]byte, error) {
    // Create a variable holding the custom runtime string, just like before.
    var runtime string
    if m.Runtime != 0 {
        runtime = fmt.Sprintf("%d mins", m.Runtime)
    }

    // Define a MovieAlias type which has the underlying type Movie. Due to the way that
    // Go handles type definitions (https://golang.org/ref/spec#Type_definitions) the
    // MovieAlias type will contain all the fields that our Movie struct has but,
    // importantly, none of the methods.
    type MovieAlias Movie

    // Embed the MovieAlias type inside the anonymous struct, along with a Runtime field
    // that has the type string and the necessary struct tags. It's important that we
    // embed the MovieAlias type here, rather than the Movie type directly, to avoid
    // inheriting the MarshalJSON() method of the Movie type (which would result in an
    // infinite loop during encoding).
    aux := struct {
        MovieAlias
        Runtime string `json:"runtime,omitempty"`
    }{
        MovieAlias: MovieAlias(m),
        Runtime:    runtime,
    }

    return json.Marshal(aux)
}

Nota: Aunque hemos utilizado la palabra ‘alias’ aquí, la línea type MovieAlias Movie es simplemente una definición de tipo regular. No se trata de un tipo de alias, que generalmente se utilizan para facilitar la refactorización y las migraciones de código.

Por un lado, este enfoque es agradable porque reduce drásticamente la cantidad de líneas de código y elimina la repetición. Y si tienes una estructura grande y solo necesitas personalizar un par de campos, puede ser una buena opción. Pero no está exento de desventajas.

En particular:

  • Esta técnica puede parecer un poco “truculenta”, ya que se basa en el hecho de que los tipos recién definidos no heredan métodos. Aunque sigue siendo Go idiomático, es más ingenioso y menos claro que el primer enfoque. Esa no siempre es una buena compensación, especialmente si tienes nuevos programadores de Go trabajando en tu código.
  • Pierdes un control detallado sobre el orden de los campos en la respuesta JSON. En el ejemplo anterior, la clave runtime siempre será el último elemento en el objeto JSON, como se muestra a continuación:
{
  "id": 123,
  "title": "Casablanca",
  "genres": [
  "drama",
  "romance",
  "war"
  ],
  "version": 1,
  "runtime": "102 mins"
}

Desde un punto de vista técnico, esto no debería importar, ya que el RFC de JSON establece que los objetos JSON son “colecciones no ordenadas de cero o más pares de nombre/valor”. Sin embargo, esto aún podría resultar insatisfactorio desde un punto de vista estético o de interfaz de usuario, o ser problemático si necesitas mantener un orden preciso de campos por motivos de compatibilidad hacia atrás.

3.6 Enviando mensajes de errores

En este punto nuestra api envia correctamente los JSON formateados para las peticiones exitosas. pero si el cliente hace alguna paticion incorrecta o si algo malo pasa en nuestra app, aun estamos enviando texto plano como erro desde http.Error() y http.NotFound().

En esta seccion vamos a arreglar eso, creando helpers adicionales para manejar los errores y enviar el JSON apropiado al cliente.

Creemos un nuevo archivo en cmd/api/errors.go:

touch cmd/api/errors.go

Agregamos los siguentes metodos:

package main

import (
	"fmt"
	"net/http"
)

// The logError() method is a generic helper for logging an error message along
// with the current request method and URL as attributes in the log entry.
func (app *application) logError(r *http.Request, err error) {
	var (
		method = r.Method
		uri    = r.URL.RequestURI()
	)
	app.logger.Error(err.Error(), "method", method, "uri", uri)
}

// The errorResponse() method is a generic helper for sending JSON-formatted error
// messages to the client with a given status code. Note that we're using the any
// type for the message parameter, rather than just a string type, as this gives us
// more flexibility over the values that we can include in the response.
func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message any) {
	env := envelope{"error": message}
	// Write the response using the writeJSON() helper. If this happens to return an
	// error then log it, and fall back to sending the client an empty response with a
	// 500 Internal Server Error status code.
	err := app.writeJSON(w, status, env, nil)
	if err != nil {
		app.logError(r, err)
		w.WriteHeader(500)
	}
}

// The serverErrorResponse() method will be used when our application encounters an
// unexpected problem at runtime. It logs the detailed error message, then uses the
// errorResponse() helper to send a 500 Internal Server Error status code and JSON
// response (containing a generic error message) to the client.
func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
	app.logError(r, err)
	message := "the server encountered a problem and could not process your request"
	app.errorResponse(w, r, http.StatusInternalServerError, message)
}

// The notFoundResponse() method will be used to send a 404 Not Found status code and
// JSON response to the client.
func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) {
	message := "the requested resource could not be found"
	app.errorResponse(w, r, http.StatusNotFound, message)
}

// The methodNotAllowedResponse() method will be used to send a 405 Method Not Allowed
// status code and JSON response to the client.
func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
	message := fmt.Sprintf("the %s method is not supported for this resource", r.Method)
	app.errorResponse(w, r, http.StatusMethodNotAllowed, message)
}

Con eso en su lugar, actualicemos los handlers de la API para que las use:

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
	env := envelope{
		"status": "available",
		"system_info": map[string]string{
			"environment": app.config.env,
			"version":     version,
		},
	}
	err := app.writeJSON(w, http.StatusOK, env, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}
func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) {

	id, err := app.readIDParam(r)
	if err != nil || id < 1 {
		http.NotFound(w, r)
		return
	}

	// Create a new instance of the Movie struct, containing the ID we extracted from
	// the URL and some dummy data. Also notice that we deliberately haven't set a
	// value for the Year field.
	movie := data.Movie{
		ID:        id,
		CreatedAt: time.Now(),
		Title:     "Casablanca",
		Runtime:   102,
		Genres:    []string{"drama", "romance", "war"},
		Version:   1,
	}
	// create an envelope{"movie":movie} y lo pasamos a writeJSON()
	err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

3.6 Routing errors

Cualquier mensaje de error que nuestros handlers de la API envien, sera un JSON con el formato deseado.

Pero que pasa con los mensajes de error que httprouter envia automaticamente cuando no puede encontrar una ruta que haga match? Por default seran texto plano.

Por suerte httprouter nos permite setear nuestra propio handler de error personalizado, cuando inicializamos el router. Esos handlers personalizados tiene que satisfacer la interfaz http.Handler, Lo cual es una buena noticia ya que podremos reutilizar notFoundResponse() y mothodNotAllowedResponse().

Vamos a cmd/api/routes.go y configuraremos la instanci de httprouter asi:

package main

import (
	"github.com/julienschmidt/httprouter"
	"net/http"
)

func (app *application) routes() http.Handler {
	router := httprouter.New()

	// Convert the notFoundResponse() helper to a http.Handler using the
	// http.HandlerFunc() adapter, and then set it as the custom error handler for 404
	// Not Found responses.
	router.NotFound = http.HandlerFunc(app.notFoundResponse)
	// 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/healthcheck", app.healthcheckHandler)
	router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler)
	router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)

	return router
}

Reinciamos la API e intentamos hacer una peticion a un endpoint que no exista:

$ curl -i localhost:4000/foo
HTTP/1.1 404 Not Found
Content-Type: application/json
Date: Tue, 06 Apr 2021 15:13:42 GMT
Content-Length: 58
{
"error": "the requested resource could not be found"
}
$ curl -i localhost:4000/v1/movies/abc
HTTP/1.1 404 Not Found
Content-Type: application/json
Date: Tue, 06 Apr 2021 15:14:01 GMT
Content-Length: 58
{
"error": "the requested resource could not be found"
}
$ curl -i -X PUT localhost:4000/v1/healthcheck
HTTP/1.1 405 Method Not Allowed
Allow: GET, OPTIONS
Content-Type: application/json
Date: Tue, 06 Apr 2021 15:14:21 GMT
Content-Length: 66
{
"error": "the PUT method is not supported for this resource"
}

En este ejemplo final, observa que httprouter sigue configurando automáticamente la cabecera “Allow” (Permitir) correctamente para nosotros, incluso cuando estamos utilizando nuestro manejador de errores personalizado para la respuesta.

3.6 Panic recovery

Actualmente, cualquier pánico en los manejadores de nuestra API se recuperará automáticamente por parte del servidor HTTP de Go. Esto desenrollará la pila de llamadas para la gorutina afectada (llamando a cualquier función aplazada en el camino), cerrará la conexión HTTP subyacente y registrará un mensaje de error y una traza de la pila.

Este comportamiento está bien, pero sería mejor para el cliente si pudiéramos enviar una respuesta 500 Internal Server Error para explicar que algo ha salido mal, en lugar de simplemente cerrar la conexión HTTP sin contexto.

Creemos cmd/api/middleware.go

$ touch cmd/api/middleware.go

Dentro ponemos:

package main

import (
	"fmt"
	"net/http"
)

func (app *application) recoverPanic(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Create a deferred function (which will always be run in the event of a panic
		// as Go unwinds the stack).
		defer func() {
			// Use the builtin recover function to check if there has been a panic or
			// not.
			if err := recover(); err != nil {
				// If there was a panic, set a "Connection: close" header on the
				// response. This acts as a trigger to make Go's HTTP server
				// automatically close the current connection after a response has been
				// sent.
				w.Header().Set("Connection", "close")
				// The value returned by recover() has the type any, so we use
				// fmt.Errorf() to normalize it into an error and call our
				// serverErrorResponse() helper. In turn, this will log the error using
				// our custom Logger type at the ERROR level and send the client a 500
				// Internal Server Error response.
				app.serverErrorResponse(w, r, fmt.Errorf("%s", err))
			}
		}()
		next.ServeHTTP(w, r)
	})
}

Una vez hecho esto, tenemos que actualizar nuestro cmd/api/routes.go para que el middleware recoverPanic() envuelva nuestro router. Esto nos asegura que el middleware corra en cada una de nuestros endpoints de la API.

package main

import (
	"github.com/julienschmidt/httprouter"
	"net/http"
)

func (app *application) routes() http.Handler {
	router := httprouter.New()

	router.NotFound = http.HandlerFunc(app.notFoundResponse)

	router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)

	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)

	// Wrap the router with the panic recovery middleware.
	return app.recoverPanic(router)
}

Ahora que eso está en su lugar, si ocurre un pánico en uno de nuestros manejadores de API, el middleware recoverPanic() lo recuperará y llamará a nuestro ayudante regular app.serverErrorResponse(). A su vez, esto registrará el error utilizando nuestro registrador estructurado y enviará al cliente una respuesta amigable de “500 Internal Server Error” con un cuerpo JSON. Esto proporciona una mejor experiencia al cliente al informar de manera adecuada sobre un error interno del servidor.

3.6 Informacion adicional

3.6 System-generated error responses

Mientras estamos en el tema de los errores, me gustaría mencionar que en ciertos escenarios, el servidor HTTP de Go todavía puede generar y enviar automáticamente respuestas HTTP en texto plano. Estos escenarios incluyen cuando:

  • La solicitud HTTP especifica una versión del protocolo HTTP no admitida.
  • La solicitud HTTP contiene una cabecera Host faltante o inválida, o múltiples cabeceras Host.
  • La solicitud HTTP contiene un nombre o valor de cabecera no admitido.
  • La solicitud HTTP contiene una cabecera Transfer-Encoding no admitida
  • El tamaño de las cabeceras de la solicitud HTTP supera la configuración de MaxHeaderBytes del servidor.
  • El cliente realiza una solicitud HTTP a un servidor HTTPS.

Por ejemplo, si intentamos enviar una solicitud con un valor de cabecera Host no válido, recibiremos una respuesta como esta:

$ curl -i -H "Host: こんにちは" http://localhost:4000/v1/healthcheck
HTTP/1.1 400 Bad Request: malformed Host header
Content-Type: text/plain; charset=utf-8
Connection: close
400 Bad Request: malformed Host header

Desafortunadamente, estas respuestas están codificadas de manera fija en la biblioteca estándar de Go, y no hay nada que podamos hacer para personalizarlas y utilizar JSON en su lugar. Estas respuestas son generadas automáticamente por el servidor HTTP de Go para cumplir con los estándares y requisitos de HTTP en situaciones específicas.

3.6 Panic recovery en otras goroutines

Es realmente importante darse cuenta de que nuestro middleware solo recuperará pánicos que ocurran en la misma gorutina que ejecutó el middleware recoverPanic(). Si, por ejemplo, tienes un controlador que inicia otra gorutina (por ejemplo, para realizar algún procesamiento en segundo plano), entonces cualquier pánico que ocurra en la gorutina en segundo plano no será recuperado, ni por el middleware recoverPanic() ni por la recuperación de pánico incorporada en el http.Server. Estos pánicos harán que tu aplicación se cierre y detendrán el servidor.

Por lo tanto, si estás creando gorutinas adicionales desde tus controladores y existe la posibilidad de que ocurra un pánico, debes asegurarte de recuperar cualquier pánico desde esas gorutinas también.

Post Relacionados