Police Officer from behind

Hace un par de meses implementamos nuestra propia versión de Zod en PHP. Si no leíste la entrada y te da pereza hacerlo ahora, te lo resumo en un par de minutos.

Zod es una biblioteca de validación de esquemas para TypeScript. Con ella puedes definir un esquema como este:

import z from 'zod';
export const editorialTaskSchema = z.object( {
  id: z.string().uuid(),
  task: z.string().min( 1 ),
  completed: z.boolean(),
  postId: z.number().positive().optional(),
} );

y luego validar si los datos que tienes cumplen con él o no:

const maybeTask = apiFetch< unknown >( { ... } );
// Parse with exception throwing:
try {
  const task = editorialTaskSchema.parse( maybeTask );
} catch ( e ) { ... }
// Parse safely:
const result = editorialTaskSchema.safeParse( maybeTask );
if ( result.success ) {
  const task = result.data;
} else {
  const error = result.error;
}

Lo que me encanta de Zod es su simpleza y expresividad. Es muy fácil definir esquemas de datos en un lenguaje que cualquiera pueda entender, promoviendo así la validación de datos. No es de extrañar, pues, que en la entrada anterior dijera que sería genial si pudiéramos tener algo como Zod en PHP (existe) y explicara cómo implementar nuestra propia versión:

use Nelio\Zod\Zod as Z;
$editorial_task_schema = Z::object( [
  'id'        => Z::string()->uuid(),
  'task'      => Z::string()->min( 1 ),
  'completed' => Z::boolean(),
  'postId'    => Z::number()->positive()->optional(),
] );

la cual nos permite hacer lo que esperas:

$maybe_task = [ ... ];
// Parse with exception throwing:
try {
  const $task = $editorial_task_schema->parse( $maybe_task );
} catch ( e ) { ... }
// Parse safely:
const $result = $editorial_task_schema->safe_parse( $maybe_task );
if ( $result['success'] ) {
  const $task = $result['data'];
} else {
  const $error = $result['error'];
}

Mola, ¿eh?

Pues bien, hoy me gustaría explicarte el caso práctico de cómo usar Zod en PHP para validar y sanear los endpoints de tus API REST. Prometo un artículo breve pero con mucha información interesante. ¡Vamos allá!

Definición de endpoints en la API REST

Toda la información relevante sobre la API REST de WordPress y, en particular, sobre cómo definir tus propios endpoints, está perfectamente explicada en el manual oficial de la misma. Pero, como quiero que esta entrada sea autocontenida, permíteme repasar rápidamente los conceptos básicos aquí.

Supongamos que quieres crear un nuevo endpoint para obtener el título de una entrada cualquiera identificada por ID. Pues bien, todo lo que tienes que hacer es llamar register_rest_route durante la acción rest_api_init de la siguiente manera:

namespace Your_Plugin\Example;

add_action( 'rest_api_init', function () {
register_rest_route(
'your-plugin/v1',
'/post-title/(?P<id>\d+)',
array(
'methods' => 'GET',
'callback' => __NAMESPACE__ . '\get_post_title_by_id',
)
);
}

y así registrar el nuevo endpoint /your-plugin/v1/post-title/${id}. Cuando alguien haga una llamada al mismo, se invocará la función get_post_title_by_id y, si está bien programada, devolverá el título solicitado o un error en caso de no encontrar la entrada asociada:

function get_post_title_by_id( WP_REST_Request $request ) {
$id = $request['id'];

$post = get_post( $id );
if ( empty( $post ) ) {
return new WP_Error( 'post-not-found', 'Post not found' );
}

return $post->post_title;
}

Fácil, ¿no? Pues sigamos.

Validación y saneamiento básicos en la API REST

Como ya estarás pensando, la realidad suele ser un poquiiito más complicada que lo que acabo de contarte. En muchos casos vamos a recibir datos del cliente y tendremos que asegurarnos de que lo que obtenemos y lo que esperábamos coincidan. Por suerte para nosotros, WordPress también ha tenido en cuenta esto y nos facilita bastante la tarea.

Pongamos, por ejemplo, que queremos un endpoint para guardar una tarea editorial en nuestra base de datos. Algo tal que así:

namespace Your_Plugin\Another_Example;

add_action( 'rest_api_init', function () {
register_rest_route(
'your-plugin/v1',
'/task',
array(
'methods' => 'POST',
'callback' => __NAMESPACE__ . '\save_editorial_task',
'args' => array(
'task' => array(
'required' => true,
'type' => 'EditorialTask',
),
),
)
);
}

donde definimos el endpoint your-plugin/v1/task para peticiones de tipo POST y en el que indicamos que esperamos un objeto task en el cuerpo de la petición (ten en cuenta que el type no vale para nada; es puramente informativo).

Una vez definido, podemos llamarlo desde nuestro cliente web así:

declare const task: EditorialTask;
await apiFetch( {
path: '/your-plugin/v1/task',
method: 'POST',
data: { task },
} );

lo cual estaría bien. Pero también podríamos llamarlo así:

await apiFetch( {
path: '/your-plugin/v1/task',
method: 'POST',
data: { task: 123 },
} );

o así:

await apiFetch( {
path: '/your-plugin/v1/task',
method: 'POST',
data: { task: false },
} );

y en ambos casos el código seguiría «funcionando».

Es decir, independientemente del valor concreto que usemos en task, la API REST lo aceptará felizmente y, bueno, tirará adelante como si el tipo pasado fuera una tarea editorial. Obviamente, esto es indeseable, porque es probable que en algún punto del código las cosas peten al estar trabajando con un dato que no es del tipo correcto.

Por suerte, y como te comentaba al principio, WordPress nos lo pone fácil para validar ese dato de entrada. Usando validate_callback y sanitize_callback podemos asegurarnos de que los datos son correctos. Con la validación comprobamos si los datos son correctos o no. Con el saneamiento, podemos validarlos y transformarlos si procede.

Siguiendo con nuestro ejemplo, podemos validar que la task recibida es, efectivamente, una tarea editorial pasando una función al validate_callback del atributo task:

namespace Your_Plugin\Another_Example;

add_action( 'rest_api_init', function () {
register_rest_route(
'your-plugin/v1',
'/task',
array(
'methods' => 'POST',
'callback' => __NAMESPACE__ . '\save_editorial_task',
'args' => array(
'task' => array(
'required' => true,
'type' => 'EditorialTask',
'validate_callback' => __NAMESPACE__ . '\validate_editorial_task',
),
),
)
);
}

e implementando las comprobaciones necesarias en la misma:

function validate_editorial_task( $task ) {
if ( ! is_array( $task ) ) {
return false;
}
if ( empty( $task['task'] ) ) {
return false;
}
if ( ! is_string( $task['task'] ) ) {
return false;
}
// ...
return true;
}

Pero ya ves qué peñazo tener que comprobar esto así, con un montón de condiciones y tal, ¿no? Pues es en ese punto cuando podemos meter nuestros esquemas Zod para ahorrar tiempo y hacer un código más inteligible, seguro y agradable.

Validar y sanear datos en la API REST usando Zod

Implementar la función validate_editorial_task anterior utilizando el esquema tipo Zod como el que hemos puesto al abrir el artículo es tan fácil que hasta da vergüenza escribirlo:

function validate_editorial_task( $task ) {
$schema = get_editorial_task_schema();
$result = $schema->save_parse( $task );
return $result['success'];
}

Ya está. Ya tenemos la implementación lista.

¿Y qué pasa si quieres saber por qué un dato fue rechazado por nuestro esquema de validación? Pues con los esquemas tipo Zod también es facilísimo: basta con construir un objeto WP_Error con el error que ha lanzado el proceso de parsing:

function validate_editorial_task( $task ) {
$schema = get_editorial_task_schema();
$result = $schema->save_parse( $task );
return $result['success']
? true
: new WP_Error( 'parse-error', $result['error'] );
}

Ya te dije que esto era facilísimo…

Con esquemas Zod, sanear es mejor que validar

Vamos a plantear un escenario ligeramente diferente. Supón que nuestro esquema de tarea editorial permite que postId sea un number o un string. Y, en caso de que sea un string, este tiene que «parecer» un identificador (vamos, que solo pueden ser dígitos) para, una vez parseado, podamos convertirlo a número. Esto lo podemos conseguir así:

use Nelio\Zod\Zod as Z;

$editorial_task_schema = Z::object( [
'id' => Z::string()->uuid(),
'task' => Z::string()->min( 1 ),
'completed' => Z::boolean(),
'postId' => Z::union( [
Z::number()->positive(),
Z::string()->regex( '/[1-9][0-9]*/' )->transform( 'absint' ),
] )->optional(),
] );

Básicamente, postId es una unión entre los dos tipos que queremos. En caso de haber recibido un número, validamos que sea positivo. En caso de haber recibido una cadena de texto, validamos que sean dígitos y, si está bien, los transformamos a un número usando absint. Es decir, nos da igual si nos han pasado una $task en la que su postId es el número 123 o el texto "123" ; en cualquiera de los dos casos acabaremos con una tarea editorial en la que el atributo es el número 123.

El problema que tenemos usando la validación es que tanto 123 como "123" son valores válidos para postId. Pero nosotros, a la hora de trabajar con una $task en nuestro código, queremos que el valor esté correctamente transformado al valor 123, ¿no crees?

De nuevo, esto tiene fácil solución. Basta con usar sanitize_callback en lugar de validate_callback :

add_action( 'rest_api_init', function () {
register_rest_route(
'your-plugin/v1',
'/task',
array(
'methods' => 'POST',
'callback' => __NAMESPACE__ . '\save_editorial_task',
'args' => array(
'task' => array(
'required' => true,
'type' => 'EditorialTask',
'sanitize_callback' => __NAMESPACE__ . '\sanitize_editorial_task',
),
),
)
);
}

y modificar un poquitito validate_editorial_task para poder convertirla en sanitize_editorial_task :

function sanitize_editorial_task( $task ) {
$schema = get_editorial_task_schema();
$result = $schema->save_parse( $task );
return $result['success']
? $result['data']
: new WP_Error( 'parse-error', $result['error'] );
}

¡Y ahí lo tienes! Una función simple que utiliza un esquema Zod para validar y transformar los datos de entrada del cliente. ¡Para quitarse el sombrero!

Creando una abstracción para el saneamiento de datos

Para acabar, permíteme un regalito.

Si prestas atención a la función de saneamiento que acabamos de crear, verás que con cambiarle cuatro palabritas conseguimos algo que parece genérico:

function sanitize_input_data( $input_data ) {
$schema = get_the_schema();
$result = $schema->save_parse( $input_data );
return $result['success']
? $result['data']
: new WP_Error( 'parse-error', $result['error'] );
}

Me da igual sanear tareas editoriales que entradas, productos, pedidos o lo que quieras. Mientras tengamos el $schema adecuado para ese tipo de datos, la función en sí siempre es «igual». Copiar / pegar, ¿no?

Pues no, no hay que copiar / pegar. Dejemos que sea el ordenador el que trabaje. Basta con crear una función auxiliar que, dado un $schema, construya la función que queremos:

function make_sanitizer( $schema ) {
return function( $data ) use ( &$schema ) {
$result = $schema->save_parse( $input_data );
return $result['success']
? $result['data']
: new WP_Error( 'parse-error', $result['error'] );
};
}

Y luego ya, con eso, podemos crear los «saneadores» que necesitemos como quien hace churros:

add_action( 'rest_api_init', function () {
register_rest_route(
'your-plugin/v1',
'/task',
array(
'methods' => 'POST',
'callback' => __NAMESPACE__ . '\save_editorial_task',
'args' => array(
'task' => array(
'required' => true,
'type' => 'EditorialTask',
'sanitize_callback' => make_sanitizer( get_editorial_task_schema() ),
),
),
)
);
}

Si esto no es una maravilla yo ya no sé qué decirte…

En fin, espero que te haya gustado la entradita de hoy. Si es así, dímelo en los comentarios y/o compártela con tu gente.

¡Nos vemos en la próxima!

Imagen destacada de LOGAN WEAVER | @LGNWVR en Unsplash.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

He leído y acepto la política de privacidad de Nelio Software

Tus datos personales se almacenarán en SiteGround y serán usados por Nelio Software con el único objetivo de publicar tu comentario aquí. Con el envío de este comentario, nos das el consentimiento expreso para ello. Escríbenos para acceder, rectificar, limitar o eliminar tus datos personales.