Police Officer from behind

A couple of months ago we implemented our own version of Zod in PHP. If you didn’t read the post, here’s the gist of it.

Zod is a TypeScript-first schema validation library. It allows you to define a simple schema like this one:

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(),
} );Code language: JavaScript (javascript)

and then validate if some data matches the schema or not:

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;
}Code language: JavaScript (javascript)

What I love about Zod is its simplicity and expressiveness. It’s super easy to set up schemas that anyone can understand and validate data like a pro. And, as discussed in a previous post, it’d be great if we could have something like Zod in our PHP tool-belts (spoiler alert: there’s a package you can get via Composer), so we implemented our own.

Here’s the schema in PHP using our own Zod implementation:

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(),
] );Code language: PHP (php)

And here’s how you’d use it:

$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'];
}Code language: PHP (php)

Pretty neat, huh?

Today I’d like to show you how to use Zod in PHP to validate and sanitize your REST API endpoints. It’ll be a short article, but with plenty of interesting tips. So without any further ado, let’s jump into it right away.

Defining a REST API Endpoint

All the relevant information on WordPress’ REST API and, in particular, on how to define custom endpoints, can be found in the REST API handbook. To keep things simple and make sure this post is self-contained, let’s quickly reproduce the basics here.

Let’s say you want to create a new endpoint to get the title of any given post ID. Well, all you gotta do is call register_rest_route during the rest_api_init action as follows:

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',
    )
  );
}Code language: PHP (php)

where we define a new endpoint /your-plugin/v1/post-title/${id} that, when invoked using a GET request, will return the title of the given post, or an error if the post could not be found. This behavior can be easily achieved by implementing the function get_post_title_by_id:

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;
}Code language: PHP (php)

That’s as simple as it gets!

Basic Validation and Sanitization in the REST API

Of course, reality is usually more complicated than the previous example. More often than not, we have to receive some data from the client and we have to make sure what we get and what we expect match. Luckily for us, WordPress makes it extremely easy as well.

Let’s say, for example, that we want to define an endpoint to save an Editorial Task to our database. The REST API endpoint could be as follows:

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',
        ),
      ),
    )
  );
}Code language: PHP (php)

where we define a new endpoint your-plugin/v1/task that only responds to POST requests and expects a task object as an argument in the request’s body (please note that the type in the previous definition is there for documentation purposes only).

This endpoint could be accessed like this:

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

but, of course, this would also “work”:

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

as well as this:

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

In other words, regardless of the concrete value we use in task, the REST API will take it happily and, well, there’s a high chance we’ll produce an error down the line when the server code expects an Editorial Task but encounters something else instead.

Luckily for us, we can use the validate_callback and sanitize_callback exposed by the REST API to make sure that, whatever data we get from the client, it’s the correct one. The former simply checks if the data has the appropriate shape and returns a boolean value, whereas the latter attempts to transform the data if possible.

In our running example, we can simply validate that the task is an actual Editorial Task by specifying a validate_callback in our endpoint for the task attribute:

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',
        ),
      ),
    )
  );
}Code language: PHP (php)

and implementing the function:

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;
}Code language: PHP (php)

Of course, this is extremely cumbersome… and here’s where our Zod-like schemas can save us a lot of time and make our code safer, easier to understand, and more enjoyable to work with.

Validate and Sanitize Data in the REST API using Zod

Implementing the previous validate_editorial_task function using the Zod-like schema we presented at the beginning of this article only takes a couple of lines:

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

But that only tells us if the $task is correct or not… What if we wanted to know why a certain input data has been rejected by our validation schema? Well, that’s pretty easy as well. All we gotta do is return a WP_Error instance with the error generated by our Zod-like schema:

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'] );
}Code language: PHP (php)

Sanitizing Data with Zod-like Schemas is Even Better

For the sake of the argument, let’s tweak our Editorial Task schema for a moment and let’s accept postIds as either a number or a string. This can be easily achieved by defining the postId as a union type of either a positive number or a string that looks like an ID (using regexes):

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(),
] );Code language: PHP (php)

This schema is, as you can see, able to transform the original data into something else. In particular, we made sure that a string that “looks like an ID” ends up becoming a number. In other words, it doesn’t really matter if the $task we received has its taskId set to the number 123 or the string "123"–in the end, it’ll always be presented as the integer 123.

Clearly, it’d be great if we could then use the transformed $task in our server, don’t you think? To do so, we have to use sanitize_callback instead of 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',
        ),
      ),
    )
  );
}Code language: PHP (php)

and then change our validate_editorial_task into 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'] );
}Code language: PHP (php)

And there you have it! A simple function that uses a Zod-like schema to validate and transform user input data. Lovely!

Abstracting Our Sanitize Callbacks

If you pay attention to the sanitize function we’ve just created, you’ll notice it actually looks pretty generic:

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'] );
}Code language: PHP (php)

See? As long as I don’t mention “editorial tasks” or “tasks” anywhere, this function looks like the perfect recipe to sanitize any input type.

But I don’t want to repeat that snippet over an over again… if only there was an option to automatically build a sanitize function given a Zod schema! And, you guessed it, there is one: let’s create a function that builds this sanitize function:

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'] );
  };
}Code language: PHP (php)

And then let’s use it wherever we want:

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() ),
        ),
      ),
    )
  );
}Code language: PHP (php)

That’s what I call awesomeness!

I hope you liked this article and, if you did, leave us a comment below and share it with your friends and co-workers.

Have a good one!

Featured Image by LOGAN WEAVER | @LGNWVR on Unsplash.

Leave a Reply

Your email address will not be published. Required fields are marked *

I have read and agree to the Nelio Software Privacy Policy

Your personal data will be located on SiteGround and will be treated by Nelio Software with the sole purpose of publishing this comment here. The legitimation is carried out through your express consent. Contact us to access, rectify, limit, or delete your data.