7. Operaciones CRUD

Ahora nos centraremos en desarrollar la funcionalidad para crear, leer, actualizar y eliminar películas en nuestro sistema. Avanzaremos bastante rápido en los próximos capítulos y, al final de esta sección, tendremos los siguientes puntos finales de la API completos y funcionando:

MétodoRuta URLManejadorAcción
GET/v1/healthcheckhealthcheckHandlerMostrar información de la aplicación
POST/v1/moviescreateMovieHandlerCrear una nueva película
GET/v1/movies/:idshowMovieHandlerMostrar los detalles de una película específica
PUT/v1/movies/:idupdateMovieHandlerActualizar los detalles de una película específica
DELETE/v1/movies/:iddeleteMovieHandlerEliminar una película específica

En esta sección aprenderás:

  • Cómo crear un modelo de base de datos que aísle toda la lógica para ejecutar consultas SQL contra tu base de datos.
  • Cómo implementar las cuatro operaciones básicas de CRUD (crear, leer, actualizar y eliminar) en un recurso específico en el contexto de una API.

7.1 Configurando el modelo Movie

En este capítulo, vamos a configurar el código Skeleton para nuestro modelo de base de datos de películas. Si no te gusta el término “modelo”, podrías pensar en esto como tu capa de acceso o almacenamiento de datos. Pero, independientemente de cómo prefieras llamarlo, el principio es el mismo: encapsulará todo el código para leer y escribir datos de películas hacia y desde nuestra base de datos PostgreSQL.

Volvamos al archivo internal/data/movies.go y creemos un tipo de estructura MovieModel y algunos métodos de marcador de posición para realizar acciones básicas de CRUD (crear, leer, actualizar y eliminar) en nuestra tabla de base de datos de movies.

package data

import (
	"database/sql"
	"github.com/nahuelev23/greenlight/internal/validator"
	"time"
)

...

// Define a MovieModel struct type which wraps a sql.DB connection pool.
type MovieModel struct {
	DB *sql.DB
}

// Add a placeholder method for inserting a new record in the movies table.
func (m MovieModel) Insert(movie *Movie) error {
	return nil
}

// Add a placeholder method for fetching a specific record from the movies table.
func (m MovieModel) Get(id int64) (*Movie, error) {
	return nil, nil
}

// Add a placeholder method for updating a specific record in the movies table.
func (m MovieModel) Update(movie *Movie) error {
	return nil
}

// Add a placeholder method for deleting a specific record from the movies table.
func (m MovieModel) Delete(id int64) error {
	return nil
}

Como paso adicional, vamos a envolver nuestro MovieModel en una estructura principal Models. Hacer esto es completamente opcional, pero tiene la ventaja de proporcionar un contenedor único y conveniente que puede contener y representar todos los modelos de tu base de datos a medida que tu aplicación crece.

Si estás siguiendo, crea un nuevo archivo internal/data/models.go y agrega el siguiente código:

$ touch internal/data/models.go
package data

import (
	"database/sql"
	"errors"
)

// Define a custom ErrRecordNotFound error. We'll return this from our Get() method when
// looking up a movie that doesn't exist in our database.
var (
	ErrRecordNotFound = errors.New("record not found")
)

// Create a Models struct which wraps the MovieModel. We'll add other models to this,
// like a UserModel and PermissionModel, as our build progresses.
type Models struct {
	Movies MovieModel
}

// For ease of use, we also add a New() method which returns a Models struct containing
// the initialized MovieModel.
func NewModels(db *sql.DB) Models {
	return Models{
		Movies: MovieModel{DB: db},
	}
}

Y ahora, editamos nuestro archivo cmd/api/main.go para que la estructura Models se inicialice en nuestra función main() y luego se pase a nuestros controladores como una dependencia. Algo así:

package main

import (
	"context"
	"database/sql"
	"flag"
	"fmt"
	"net/http"
	"os"
	"time"

	"log/slog"

	_ "github.com/lib/pq"
+	"github.com/nahuelev23/greenlight/internal/data"
)

// Add a models field to hold our new Models struct.
type application struct {
	config config
	logger *slog.Logger
+	models data.Models
}

func main() {
	var cfg config

	flag.IntVar(&cfg.port, "port", 4000, "API server port")
	flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)")

	flag.StringVar(&cfg.db.dsn, "db-dsn", "postgres://greenlight:pa55word@localhost/greenlight", "PostgreSQL DSN")

	flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections")
	flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections")
	flag.DurationVar(&cfg.db.maxIdleTime, "db-max-idle-time", 15*time.Minute, "PostgreSQL max connection idle time")

	flag.Parse()

	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

	db, err := openDB(cfg)
	if err != nil {
		logger.Error(err.Error())
		os.Exit(1)
	}

	defer db.Close()

	// Use the data.NewModels() function to initialize a Models struct, passing in the
	// connection pool as a parameter.
	app := application{
		config: cfg,
		logger: logger,
+		models: data.NewModels(db),
	}

	srv := &http.Server{

		Addr:         fmt.Sprintf(":%d", cfg.port),
		Handler:      app.routes(),
		IdleTimeout:  time.Minute,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		ErrorLog:     slog.NewLogLogger(logger.Handler(), slog.LevelError),
	}

	logger.Info("starting server", "addr", srv.Addr, "env", cfg.env)

	err = srv.ListenAndServe()
	logger.Error(err.Error())
	os.Exit(1)
}

Si lo deseas, puedes intentar reiniciar la aplicación en este punto. Deberías encontrar que el código se compila y se ejecuta correctamente.

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development

Una de las cosas positivas de este patrón es que el código para ejecutar acciones en nuestra tabla de películas será muy claro y legible desde la perspectiva de los controladores de nuestra API. Por ejemplo, podremos ejecutar el método Insert() simplemente escribiendo:

app.models.Movies.Insert(...)

La estructura general también es fácil de extender. Cuando creemos más modelos de base de datos en el futuro, todo lo que tenemos que hacer es incluirlos en la estructura Models y estarán disponibles automáticamente para nuestros controladores de API.

7.1 Informacion adicional

7.1 Mocking models

Con una pequeña modificación en este patrón, también es posible admitir el mockeo de tus modelos de base de datos con fines de pruebas unitarias. Ya hablamos de esto en detalle en “Let’s Go”, así que no quiero repetir la misma información y orientación aquí. Pero como ejemplo rápido, podrías crear una estructura MockMovieModel similar a esta:

package data
...
type MockMovieModel struct{}
func (m MockMovieModel) Insert(movie *Movie) error {
// Mock the action...
}
func (m MockMovieModel) Get(id int64) (*Movie, error) {
// Mock the action...
}
func (m MockMovieModel) Update(movie *Movie) error {
// Mock the action...
}
func (m MockMovieModel) Delete(id int64) error {
// Mock the action...
}

Y luego actualiza el archivo internal/data/models.go de la siguiente manera:

package data
import (
    "database/sql"
    "errors"
)
var (
	ErrRecordNotFound = errors.New("record not found")
)
type Models struct {
    // Set the Movies field to be an interface containing the methods that both the
    // 'real' model and mock model need to support.
    Movies interface {
    Insert(movie *Movie) error
    Get(id int64) (*Movie, error)
    Update(movie *Movie) error
    Delete(id int64) error
	}
}
...
// Create a helper function which returns a Models instance containing the mock models
// only.
func NewMockModels() Models {
    return Models{
    	Movies: MockMovieModel{},
    }
}

Entonces, puedes llamar a NewMockModels() siempre que lo necesites en tus pruebas unitarias en lugar de la función ‘real’ NewModels().

7.2 Creando una nueva Movie

Vamos a empezar con el método Insert() de nuestro modelo de base de datos y actualizarlo para crear un nuevo registro en nuestra tabla de películas. Específicamente, queremos que ejecute la siguiente consulta SQL:

INSERT INTO movies (title, year, runtime, genres)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at, version

Hay algunas cosas sobre esta consulta que merecen un poco de explicación.

  • Utiliza la notación $N para representar parámetros de marcador de posición para los datos que queremos insertar en la tabla de películas. Como explicamos en “Let’s Go”, cada vez que envías datos no confiables desde un cliente a una base de datos SQL, es importante utilizar parámetros de marcador de posición para ayudar a prevenir ataques de inyección SQL, a menos que haya una razón muy específica para no usarlos.
  • Solo estamos insertando valores para title, year, runtime y genres. Las columnas restantes en la tabla de películas se llenarán con valores generados por el sistema en el momento de la inserción; el id será un entero autoincremental, y los valores created_at y version se establecerán en la hora actual y 1, respectivamente.
  • Al final de la consulta, tenemos una cláusula RETURNING. Esta es una cláusula específica de PostgreSQL (no es parte del estándar SQL) que puedes usar para devolver valores de cualquier registro que esté siendo manipulado por una declaración INSERT, UPDATE o DELETE. En esta consulta, la usamos para devolver los valores generados por el sistema id, created_at y version.

7.2 Ejecutando la consulta SQL

A lo largo de este proyecto, seguiremos utilizando el paquete database/sql de Go para ejecutar nuestras consultas a la base de datos, en lugar de utilizar un ORM de terceros u otra herramienta. Hablamos sobre cómo usar database/sql y sus diversas características, comportamientos y consideraciones en “Let’s Go”, así que con suerte esto te resultará familiar. ¡Considéralo como un breve repaso!

Normalmente, usarías el método Exec() de Go para ejecutar una instrucción INSERT en una tabla de base de datos. Pero dado que nuestra consulta SQL está devolviendo una sola fila de datos (gracias a la cláusula RETURNING), aquí necesitaremos usar el método QueryRow().

Regresa a tu archivo internal/data/movies.go y actualízalo de la siguiente manera:

package data

import (
    "database/sql"
    "time"

    "greenlight.alexedwards.net/internal/validator"
    "github.com/lib/pq" // Nuevo import
)

// El método Insert() acepta un puntero a una estructura de película, que debería contener los
// datos para el nuevo registro.
func (m MovieModel) Insert(movie *Movie) error {
    // Define la consulta SQL para insertar un nuevo registro en la tabla de películas y devolver
    // los datos generados por el sistema.
    query := `
        INSERT INTO movies (title, year, runtime, genres)
        VALUES ($1, $2, $3, $4)
        RETURNING id, created_at, version`
    
    // Crea una slice de args que contiene los valores para los parámetros de marcador de posición de
    // la estructura de la película. Declarar esta slice inmediatamente al lado de nuestra consulta SQL ayuda a
    // aclarar *qué valores se están utilizando dónde* en la consulta.
    args := []interface{}{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)}
    
    // Usa el método QueryRow() para ejecutar la consulta SQL en nuestro pool de conexiones,
    // pasando la slice args como un parámetro variádico y escaneando el id generado por el sistema, el creado_en y
    // los valores de versión en la estructura de película.
    return m.DB.QueryRow(query, args...).Scan(&movie.ID, &movie.CreatedAt, &movie.Version)
}

Ese código es conciso y claro, pero hay algunas cosas importantes que mencionar.

Debido a que la firma del método Insert() toma un puntero *Movie como parámetro, cuando llamamos a Scan() para leer los datos generados por el sistema, estamos actualizando los valores en la ubicación a la que apunta el parámetro. Básicamente, nuestro método Insert() muta la estructura Movie que le pasamos y le agrega los valores generados por el sistema.

Lo siguiente a mencionar son las entradas de los parámetros de marcador de posición, que declaramos en una matriz args de la siguiente manera:

args := []any{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)}

Almacenar las entradas en una matriz no es estrictamente necesario, pero como se menciona en los comentarios del código, es un patrón útil que puede mejorar la claridad de tu código. Personalmente, suelo hacer esto para consultas SQL con más de tres parámetros de marcador de posición.

Además, ¿notaste el último valor en la matriz? Para almacenar nuestro valor movie.Genres (que es una matriz []string), necesitamos pasarla a través de la función de adaptador pq.Array() antes de ejecutar la consulta SQL.

En el fondo, el adaptador pq.Array() toma nuestra matriz []string y la convierte a un tipo pq.StringArray. A su vez, el tipo pq.StringArray implementa las interfaces driver.Valuer y sql.Scanner necesarias para traducir nuestra matriz nativa []string a un valor que nuestra base de datos PostgreSQL puede entender y almacenar en una columna de tipo text[].

Sugerencia: También puedes utilizar la función de adaptador pq.Array() de la misma manera con las matrices []bool, []byte, []int32, []int64, []float32 y []float64 en tu código Go.

7.2 Conectando a nuestro API Handler

Ah, I see! Aquí tienes:

Ahora viene la parte emocionante. Vamos a conectar el método Insert con nuestro createMovieHandler para que nuestro endpoint POST /v1/movies funcione completamente. Así:

  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
  	}
  	// Note that the movie variable contains a *pointer* to a Movie struct.
  	movie := &data.Movie{
  		Title:   input.Title,
  		Year:    input.Year,
  		Runtime: input.Runtime,
  		Genres:  input.Genres,
  	}
  
  	v := validator.New()
  
  	if data.ValidateMovie(v, movie); !v.Valid() {
  		app.failedValidationResponse(w, r, v.Errors)
  		return
  	}

+	// Call the Insert() method on our movies model, passing in a pointer to the
+	// validated movie struct. This will create a record in the database and update the
+	// movie struct with the system-generated information.
+	err = app.models.Movies.Insert(movie)
+	if err != nil {
+		app.serverErrorResponse(w, r, err)
+		return
+	}
+	// When sending a HTTP response, we want to include a Location header to let the
+	// client know which URL they can find the newly-created resource at. We make an
+	// empty http.Header map and then use the Set() method to add a new Location header,
+	// interpolating the system-generated ID for our new movie in the URL.
+	headers := make(http.Header)
+	headers.Set("Location", fmt.Sprintf("/v1/movies/%d", movie.ID))
+	// Write a JSON response with a 201 Created status code, the movie data in the
+	// response body, and the Location header.
+	err = app.writeJSON(w, http.StatusCreated, envelope{"movie": movie}, headers)
+	if err != nil {
+		app.serverErrorResponse(w, r, err)
+	}
}

De acuerdo, probemos esto. Reinicia la API y luego abre una segunda ventana de terminal y realiza la siguiente solicitud al endpoint POST /v1/movies:

$ BODY='{"title":"Moana","year":2016,"runtime":"107 mins", "genres":["animation","adventure"]}'
$ curl -i -d "$BODY" localhost:4000/v1/movies
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/movies/1
Date: Wed, 07 Apr 2021 19:21:41 GMT
Content-Length: 156
{
		"movie": {
		"id": 1,
		"title": "Moana",
		"year": 2016,
		"runtime": "107 mins",
		"genres": [
		"animation",
		"adventure"
		],
		"version": 1
	}
}

Eso se ve perfecto. Podemos ver que la respuesta JSON contiene toda la información para la nueva película, incluidos los números de ID y versión generados por el sistema. Además, la respuesta también incluye la cabecera Location: /v1/movies/1, que apunta a la URL que representará posteriormente la película en nuestro sistema.

7.2 Creando registros adicionales

Mientras estamos en ello, creemos algunos registros más en el sistema para ayudarnos a demostrar diferentes funcionalidades a medida que avanzamos en la construcción.

Si estás siguiendo el código, por favor, ejecuta los siguientes comandos para crear tres registros más de películas en la base de datos:

$ BODY='{"title":"Black Panther","year":2018,"runtime":"134 mins","genres":["action","adventure"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{
"movie": {
"id": 2,
"title": "Black Panther",
"year": 2018,
"runtime": "134 mins",
"genres": [
"action",
"adventure"
],
"version": 1
}
}
$ BODY='{"title":"Deadpool","year":2016, "runtime":"108 mins","genres":["action","comedy"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{
"movie": {
"id": 3,
"title": "Deadpool",
"year": 2016,
"runtime": "108 mins",
"genres": [
"action",
"comedy"
],
"version": 1
}
}
$ BODY='{"title":"The Breakfast Club","year":1986, "runtime":"96 mins","genres":["drama"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{
"movie": {
"id": 4,
"title": "The Breakfast Club",
"year": 1986,
"runtime": "96 mins",
"genres": [
"drama"
],
"version": 1
}
}

En este punto, es posible que también desees echar un vistazo a PostgreSQL para confirmar que los registros se hayan creado correctamente. Deberías ver que el contenido de la tabla de películas ahora se ve similar a esto (incluyendo los géneros de películas apropiados en un array).

$ psql $GREENLIGHT_DB_DSN
Password for user greenlight:
psql (15.4 (Ubuntu 15.4-1.pgdg22.04+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.
greenlight=> SELECT * FROM movies;
 id |       created_at        |        title        | year | runtime |        genres        | version
----+------------------------+---------------------+------+---------+-----------------------+---------
  1 | 2021-04-07 21:21:41+02 | Moana               | 2016 |    107  | {animation,adventure}|     1
  2 | 2021-04-07 21:28:28+02 | Black Panther       | 2018 |    134  | {action,adventure}   |     1
  3 | 2021-04-07 21:28:36+02 | Deadpool            | 2016 |    108  | {action,comedy}      |     1
  4 | 2021-04-07 21:28:44+02 | The Breakfast Club  | 1986 |         | {drama}              |     1
(4 rows)

7.2 Informacion adicional

7.2 Notacion $N

Una característica interesante de la notación de parámetros de marcador de posición $N de PostgreSQL es que puedes usar el mismo valor de parámetro en varios lugares de tu declaración SQL. Por ejemplo, es perfectamente aceptable escribir código como este:

// This SQL statement uses the $1 parameter twice, and the value `123` will be used in
// both locations where $1 appears.
stmt := "UPDATE foo SET bar = $1 + $2 WHERE bar = $1"
err := db.Exec(stmt, 123, 456)
if err != nil {
...
}

7.2 Ejecutando multiples declaraciones

Ocasionalmente, es posible que te encuentres en la posición en la que desees ejecutar más de una declaración SQL en la misma llamada a la base de datos, como esto:

stmt := `
UPDATE foo SET bar = true;
UPDATE foo SET baz = false;`
err := db.Exec(stmt)
if err != nil {
...
}

Tener varias declaraciones en la misma llamada es compatible con el controlador pq, siempre y cuando las declaraciones no contengan ningún parámetro de marcador de posición. Si contienen parámetros de marcador de posición, recibirás el siguiente mensaje de error en tiempo de ejecución:

pq: cannot insert multiple commands into a prepared statement

Para solucionar esto, deberás dividir las declaraciones en llamadas de base de datos separadas o, si eso no es posible, puedes crear una función personalizada en PostgreSQL que actúe como un envoltorio alrededor de las múltiples declaraciones SQL que deseas ejecutar.

7.3 Fetching de una Movie

Ahora pasemos al código para recuperar y mostrar los datos de una película específica. Nuevamente, comenzaremos en nuestro modelo de base de datos aquí y comenzaremos actualizando el método Get() para ejecutar la siguiente consulta SQL:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE id = $1

Dado que nuestra tabla de películas utiliza la columna id como su clave principal, esta consulta solo devolverá exactamente una fila de la base de datos (o ninguna). Por lo tanto, es apropiado ejecutar esta consulta nuevamente utilizando el método QueryRow() de Go.

Si estás siguiendo el ejemplo, abre tu archivo internal/data/movies.go y actualízalo de la siguiente manera:

func (m MovieModel) Get(id int64) (*Movie, error) {
	// The PostgreSQL bigserial type that we're using for the movie ID starts
	// auto-incrementing at 1 by default, so we know that no movies will have ID values
	// less than that. To avoid making an unnecessary database call, we take a shortcut
	// and return an ErrRecordNotFound error straight away.
	if id < 1 {
		return nil, ErrRecordNotFound
	}
	// Define the SQL query for retrieving the movie data.
	query := `
	SELECT id, created_at, title, year, runtime, genres, version
	FROM movies
	WHERE id = $1`
	// Declare a Movie struct to hold the data returned by the query.
	var movie Movie
	// Execute the query using the QueryRow() method, passing in the provided id value// Execute the query using the QueryRow() method, passing in the provided id value
	// as a placeholder parameter, and scan the response data into the fields of the
	// Movie struct. Importantly, notice that we need to convert the scan target for the
	// genres column using the pq.Array() adapter function again.
	err := m.DB.QueryRow(query, id).Scan(
		&movie.ID,
		&movie.CreatedAt,
		&movie.Title,
		&movie.Year,
		&movie.Runtime,
		pq.Array(&movie.Genres),
		&movie.Version,
	)
	// Handle any errors. If there was no matching movie found, Scan() will return
	// a sql.ErrNoRows error. We check for this and return our custom ErrRecordNotFound
	// error instead.
	if err != nil {
		switch {
		case errors.Is(err, sql.ErrNoRows):
			return nil, ErrRecordNotFound
		default:
			return nil, err
		}
	}
	// Otherwise, return a pointer to the Movie struct.
	return &movie, nil
}

Con suerte, el código anterior debería sentirse claro y familiar; es una implementación directa del patrón que discutimos en detalle en “Let’s Go”. Lo único destacable es el hecho de que necesitamos utilizar el adaptador pq.Array() nuevamente al escanear los datos de géneros desde la matriz de texto PostgreSQL. Si no usáramos este adaptador, obtendríamos el siguiente error en tiempo de ejecución:

sql: Scan error on column index 5, name "genres": unsupported Scan, storing driver.Value type []uint8 into type *[]string

7.3 Actualizando el handler de la API

OK, lo siguiente que necesitamos hacer es actualizar nuestro showMovieHandler para que llame al método Get() que acabamos de crear. El controlador debería verificar si Get() devuelve un error ErrRecordNotFound, y si lo hace, se debe enviar al cliente una respuesta 404 Not Found. De lo contrario, podemos proceder a renderizar la estructura Movie devuelta en una respuesta JSON. Como sigue:

  func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) {
  	id, err := app.readIDParam(r)
  	if err != nil {
  		app.notFoundResponse(w, r)
  		return
  	}
+  	// Call the Get() method to fetch the data for a specific movie. We also need to
+  	// use the errors.Is() function to check if it returns a data.ErrRecordNotFound
+  	// error, in which case we send a 404 Not Found response to the client.
+  	movie, err := app.models.Movies.Get(id)
+  	if err != nil {
+  		switch {
+  		case errors.Is(err, data.ErrRecordNotFound):
+  			app.notFoundResponse(w, r)
+  		default:
+  			app.serverErrorResponse(w, r, err)
+  		}
+  		return
+  	}
+  	err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
+  	if err != nil {
+  		app.serverErrorResponse(w, r, err)
+  	}
}

¡Genial! Todo esto es agradable y conciso, gracias a la estructura y a los ayudantes que ya hemos establecido. Siéntete libre de probar esto reiniciando la API y buscando una película que ya hayas creado en la base de datos. Por ejemplo:

$ curl -i localhost:4000/v1/movies/2
HTTP/1.1 200 OK
Content-Type: application/json
Date: Wed, 07 Apr 2021 19:37:12 GMT
Content-Length: 161
{
	"movie": {
	"id": 2,
	"title": "Black Panther",
	"year": 2018,
	"runtime": "134 mins",
	"genres": [
	"action",
	"adventure"
	],
	"version": 1
	}
}

Y de la misma manera, también puedes intentar hacer una solicitud con un ID de película que aún no existe en la base de datos (pero es válido de otra manera). En ese escenario, deberías recibir una respuesta 404 Not Found como esta:

$ curl -i localhost:4000/v1/movies/42
HTTP/1.1 404 Not Found
Content-Type: application/json
Date: Wed, 07 Apr 2021 19:37:58 GMT
Content-Length: 58
{
"error": "the requested resource could not be found"
}

7.3 Informacion adicional

7.3 Porque no usamos un entero unsigned par el ID de movie?

Al inicio del método Get() tenemos el siguiente código que verifica si el parámetro de ID de la película es menor que 1:

func (m MovieModel) Get(id int64) (*Movie, error) {
	if id < 1 {
		return nil, ErrRecordNotFound
	}

}

Esto podría haber provocado la pregunta: si el ID de la película nunca es negativo, ¿por qué no estamos utilizando un tipo uint64 sin signo para almacenar el ID en nuestro código Go en lugar de un int64? Hay dos razones para esto. La primera razón es porque PostgreSQL no tiene enteros sin signo. En general, tiene sentido alinear sus tipos de enteros de Go y de base de datos para evitar desbordamientos u otros problemas de compatibilidad. Dado que PostgreSQL no tiene enteros sin signo, esto significa que debemos evitar el uso de tipos uint* en nuestro código Go para cualquier valor que estemos leyendo/escribiendo en PostgreSQL. En su lugar, es mejor alinear los tipos de enteros según la siguiente tabla:

PostgreSQL typeGo type
smallint, smallserialint16 (-32768 to 32767)
integer, serialint32 (-2147483648 to 2147483647)
bigint, bigserialint64 (-9223372036854775808 to 9223372036854775807)

También hay otra razón más sutil. El paquete database/sql de Go en realidad no admite ningún valor entero mayor que 9223372036854775807 (el valor máximo para un int64). Es posible que un valor uint64 sea mayor que esto, lo que a su vez llevaría a que Go genere un error en tiempo de ejecución similar a este:

sql: converting argument $1 type: uint64 values with high bit set are not supported

Al mantener un int64 en nuestro código de Go, eliminamos el riesgo de encontrarnos con este error.

7.4 Actualizando una Movie

En este capítulo continuaremos construyendo nuestra aplicación y agregaremos un nuevo punto final que permita a los clientes actualizar los datos de una película específica.

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

Más precisamente, configuraremos el punto final para que un cliente pueda editar los valores de title, year, runtime y genres para una película. En nuestro proyecto, los valores de id y created_at nunca deberían cambiar una vez creados, y el valor de version no es algo que el cliente deba controlar, por lo que no permitiremos que esos campos se editen. Por ahora, configuraremos este punto final para que realice un reemplazo completo de los valores para una película. Esto significa que el cliente deberá proporcionar valores para todos los campos editables en el cuerpo de su solicitud JSON, incluso si solo quieren cambiar uno de ellos. Por ejemplo, si un cliente quisiera agregar el género “sci-fi” a la película “Black Panther” en nuestra base de datos, debería enviar un cuerpo de solicitud JSON que se vea así:

{
	"title": "Black Panther",
	"year": 2018,
	"runtime": "134 mins",
	"genres": [
		"action",
		"adventure",
		"sci-fi"
		]
}

7.4 Ejecutando la consulta SQL

Comencemos en nuestro modelo de base de datos nuevamente y editemos el método Update() para ejecutar la siguiente consulta SQL:

UPDATE movies
SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1
WHERE id = $5
RETURNING version

Aquí, ¿notas que estamos incrementando el valor de version como parte de la consulta? Y luego, al final, estamos utilizando la cláusula RETURNING para devolver este nuevo valor de version incrementado. Al igual que antes, esta consulta devuelve una sola fila de datos, por lo que también necesitaremos usar el método QueryRow() de Go para ejecutarla. Si estás siguiendo, vuelve a tu archivo internal/data/movies.go y completa el método Update() de la siguiente manera:

func (m MovieModel) Update(movie *Movie) error {
	// Declare the SQL query for updating the record and returning the new version
	// number.
	query := `
	UPDATE movies
	SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1
	WHERE id = $5
	RETURNING version`
	// Create an args slice containing the values for the placeholder parameters.
	args := []any{
		movie.Title,
		movie.Year,
		movie.Runtime,
		pq.Array(movie.Genres),
		movie.ID,
	}
	// Use the QueryRow() method to execute the query, passing in the args slice as a
	// variadic parameter and scanning the new version value into the movie struct.
	return m.DB.QueryRow(query, args...).Scan(&movie.Version)
}

Es importante enfatizar que, al igual que nuestro método Insert(), el método Update() toma un puntero a una estructura Movie como parámetro de entrada y la modifica en su lugar nuevamente, esta vez actualizándola solo con el nuevo número de versión.

7.4 Creando el handler de la API

Ahora volvamos a nuestro archivo cmd/api/movies.go y actualicémoslo para incluir el nuevo método updateMovieHandler.

MethodURL PatternHandlerAction
PUT/v1/movies/:idupdateMovieHandlerUpdate the details of a specific movie

Lo bueno de este controlador es que ya hemos sentado las bases para él; nuestro trabajo aquí consiste principalmente en vincular el código y las funciones de ayuda que ya hemos escrito para manejar la solicitud. Específicamente, necesitaremos:

  1. Extraer el ID de la película de la URL utilizando el ayudante app.readIDParam().
  2. Obtener el registro de película correspondiente de la base de datos utilizando el método Get() que hicimos en el capítulo anterior.
  3. Leer el cuerpo de la solicitud JSON que contiene los datos actualizados de la película en una estructura de entrada.
  4. Copiar los datos de la estructura de entrada al registro de la película.
  5. Verificar que el registro de película actualizado sea válido utilizando la función data.ValidateMovie().
  6. Llamar al método Update() para almacenar el registro de película actualizado en nuestra base de datos.
  7. Escribir los datos actualizados de la película en una respuesta JSON utilizando el ayudante app.writeJSON().

Entonces, sigamos adelante y hagamos exactamente eso:

func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Request) {
	// Extract the movie ID from the URL.
	id, err := app.readIDParam(r)
	if err != nil {
		app.notFoundResponse(w, r)
		return
	}
	// Fetch the existing movie record from the database, sending a 404 Not Found
	// response to the client if we couldn't find a matching record.
	movie, err := app.models.Movies.Get(id)
	if err != nil {
		switch {
		case errors.Is(err, data.ErrRecordNotFound):
			app.notFoundResponse(w, r)
		default:
			app.serverErrorResponse(w, r, err)
		}
		return
	}
	// Declare an input struct to hold the expected data from the client.// Declare an input struct to hold the expected data from the client.
	var input struct {
		Title   string       `json:"title"`
		Year    int32        `json:"year"`
		Runtime data.Runtime `json:"runtime"`
		Genres  []string     `json:"genres"`
	}
	// Read the JSON request body data into the input struct.
	err = app.readJSON(w, r, &input)
	if err != nil {
		app.badRequestResponse(w, r, err)
		return
	}
	// Copy the values from the request body to the appropriate fields of the movie
	// record.
	movie.Title = input.Title
	movie.Year = input.Year
	movie.Runtime = input.Runtime
	movie.Genres = input.Genres
	// Validate the updated movie record, sending the client a 422 Unprocessable Entity
	// response if any checks fail.
	v := validator.New()
	if data.ValidateMovie(v, movie); !v.Valid() {
		app.failedValidationResponse(w, r, v.Errors)
		return
	}
	// Pass the updated movie record to our new Update() method.
	err = app.models.Movies.Update(movie)
	if err != nil {
		app.serverErrorResponse(w, r, err)
		return
	}
	// Write the updated movie record in a JSON response.
	err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

Finalmente, para terminar esto, también debemos actualizar las rutas de nuestra aplicación para incluir el nuevo punto final. Así:

  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)
  
+  	// Add the route for the PUT /v1/movies/:id endpoint.
+  	router.HandlerFunc(http.MethodPut, "/v1/movies/:id", app.updateMovieHandler)
  
  	return app.recoverPanic(router)
}

7.4 Usando el nuevo endpoint

Y con eso, ¡ahora estamos listos para probarlo! Para demostrarlo, continuemos con el ejemplo que dimos al principio de este capítulo y actualicemos nuestro registro para Black Panther para incluir el género “sci-fi”. Como recordatorio, el registro actualmente se ve así:

$ curl localhost:4000/v1/movies/2
{
	"movie": {
		"id": 2,
		"title": "Black Panther",
		"year": 2018,
		"runtime": "134 mins",
		"genres": [
			"action",
			"adventure"
		],
	"version": 1
	}
}

Para realizar la actualización en el campo “genres”, podemos ejecutar la siguiente llamada a la API:

$ BODY='{"title":"Black Panther","year":2018,"runtime":"134 mins","genres":["sci-fi","action","adventure"]}'
$ curl -X PUT -d "$BODY" localhost:4000/v1/movies/2
{
	"movie": {
	"id": 2,
	"title": "Black Panther",
	"year": 2018,
	"runtime": "134 mins",
	"genres": [
		"sci-fi",
		"action",
		"adventure"
		],
	"version": 2
	}
}

Eso se ve genial; podemos ver en la respuesta que los géneros de la película se han actualizado para incluir “sci-fi” y el número de versión se ha incrementado a 2, como era de esperar. También deberías poder verificar que el cambio se ha guardado haciendo una solicitud GET a /v1/movies/2, así:

$ curl localhost:4000/v1/movies/2
{
	"movie": {
	"id": 2,
	"title": "Black Panther",
	"year": 2018,
	"runtime": "134 mins",
		"genres": [
			"sci-fi",
			"action",
			"adventure"
		],
	"version": 2
	}
}

7.5 Eliminando una Movie

Perfecto, en este capítulo agregaremos nuestro último punto final CRUD para que un cliente pueda eliminar una película específica de nuestro sistema.

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

Comparado con los otros puntos finales en nuestra API, el comportamiento que queremos implementar aquí es bastante sencillo.

  • Si existe una película con el ID proporcionado en la URL en la base de datos, queremos eliminar el registro correspondiente y devolver un mensaje de éxito al cliente.

  • Si el ID de la película no existe, queremos devolver una respuesta 404 Not Found al cliente.

La consulta SQL para eliminar el registro en nuestra base de datos también es sencilla:

DELETE FROM movies
WHERE id = $1

En este caso, la consulta SQL no devuelve filas, por lo que es apropiado utilizar el método Exec() de Go para ejecutarla. Una de las ventajas de Exec() es que devuelve un objeto sql.Result, que contiene información sobre la cantidad de filas afectadas por la consulta. En nuestro escenario, esta información es realmente útil.

  • Si el número de filas afectadas es 1, entonces sabemos que la película existía en la tabla y ahora ha sido eliminada… así que podemos enviar al cliente un mensaje de éxito.
  • Conversamente, si el número de filas afectadas es 0, sabemos que no existía ninguna película con ese ID en el momento en que intentamos eliminarla, y podemos enviar al cliente una respuesta 404 Not Found.

7.5 Agregando un nuevo endpoit

Correcto, sigamos adelante y actualicemos el método Delete() en nuestro modelo de base de datos. Básicamente, queremos que ejecute la consulta SQL anterior y devuelva un error ErrRecordNotFound si el número de filas afectadas es 0. Así:

func (m MovieModel) Delete(id int64) error {
	// Return an ErrRecordNotFound error if the movie ID is less than 1.
	if id < 1 {
		return ErrRecordNotFound
	}
	// Construct the SQL query to delete the record.
	query := `
	DELETE FROM movies
	WHERE id = $1`
	// Execute the SQL query using the Exec() method, passing in the id variable as
	// the value for the placeholder parameter. The Exec() method returns a sql.Result
	// object.
	result, err := m.DB.Exec(query, id)
	if err != nil {
		return err
	}
	// Call the RowsAffected() method on the sql.Result object to get the number of rows
	// affected by the query.
	rowsAffected, err := result.RowsAffected()
	if err != nil {
		return err
	}
	// If no rows were affected, we know that the movies table didn't contain a record
	// with the provided ID at the moment we tried to delete it. In that case we
	// return an ErrRecordNotFound error.
	if rowsAffected == 0 {
		return ErrRecordNotFound
	}
	return nil
}

Una vez hecho esto, vayamos a nuestro archivo cmd/api/movies.go y agreguemos un nuevo método deleteMovieHandler. En este método, necesitamos leer el ID de la película de la URL de la solicitud, llamar al método Delete() que acabamos de hacer y, según el valor de retorno de Delete(), enviar la respuesta adecuada al cliente. Aquí está el código:

func (app *application) deleteMovieHandler(w http.ResponseWriter, r *http.Request) {
	// Extract the movie ID from the URL.
	id, err := app.readIDParam(r)
	if err != nil {
		app.notFoundResponse(w, r)
		return
	}
	// Delete the movie from the database, sending a 404 Not Found response to the
	// client if there isn't a matching record.
	err = app.models.Movies.Delete(id)
	if err != nil {
		switch {
		case errors.Is(err, data.ErrRecordNotFound):
			app.notFoundResponse(w, r)
		default:
			app.serverErrorResponse(w, r, err)
		}
		return
	}
	// Return a 200 OK status code along with a success message.
	err = app.writeJSON(w, http.StatusOK, envelope{"message": "movie successfully deleted"}, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

Nota: Puedes preferir enviar un cuerpo de respuesta vacío y un código de estado 204 No Content aquí, en lugar de un mensaje “película eliminada con éxito”. Realmente depende de quiénes sean tus clientes; si son humanos, enviar un mensaje similar al anterior es un buen toque de experiencia de usuario; si son máquinas, probablemente sea suficiente con una respuesta 204 No Content.

Finalmente agregamos la nueva ruta:

  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)
  	router.HandlerFunc(http.MethodPut, "/v1/movies/:id", app.updateMovieHandler)
  
+  	// Add the route for the DELETE /v1/movies/:id endpoint.
+  	router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler)
  
  	return app.recoverPanic(router)
}

reiniciemos la API y probemos eliminando a Deadpool de nuestra base de datos de películas (esto debería tener un ID de 3 si has estado siguiendo). La operación de eliminación debería funcionar sin problemas y deberías recibir el mensaje de confirmación así:

$ curl -X DELETE localhost:4000/v1/movies/3
{
"message": "movie successfully deleted"
}

Si repites la misma solicitud para eliminar la película que ya ha sido eliminada, ahora deberías obtener una respuesta 404 Not Found y un mensaje de error. Algo así:

$ curl -X DELETE localhost:4000/v1/movies/3
{
"error": "the requested resource could not be found"
}

Post Relacionados