Categories
Programming

Strongly Typed WP-API

The first in a series of posts exploring WP-API with statically typed PHP and Functional Programming patterns.

The Context

To expose a resource as an endpoint via WordPress’ WP-API interface one must use register_rest_route.

/**
 * Registers a REST API route.
 *
 * Note: Do not use before the {@see 'rest_api_init'} hook.
 *
 * @since 4.4.0
 * @since 5.1.0 Added a _doing_it_wrong() notice when not called on or after the rest_api_init hook.
 *
 * @param string $namespace The first URL segment after core prefix. Should be unique to your package/plugin.
 * @param string $route     The base URL for route you are adding.
 * @param array  $args      Optional. Either an array of options for the endpoint, or an array of arrays for
 *                          multiple methods. Default empty array.
 * @param bool   $override  Optional. If the route already exists, should we override it? True overrides,
 *                          false merges (with newer overriding if duplicate keys exist). Default false.
 * @return bool True on success, false on error.
 */
function register_rest_route( $namespace, $route, $args = array(), $override = false ) {

The documentation here is incredibly opaque so it’s probably a good idea to have the handbook page open until the API is internalized in your brain.

The $namespace and $route arguments are somewhat clear, however in typical WordPress PHP fashion the bulk of the magic is provided through an opaquely documented @param array $args.

The bare minimum are the keys method and callback and for our purposes will be all that we need. WP_REST_Server provides some handy constants (READABLE, CREATABLE, DELETABLE, EDITABLE) for the methods key so that leaves callback.

What is callback? In PHP terms it’s a callable. Many things in PHP can be a callable. The most commonly used callable for WordPress tends to be a string value that is the name of a function:

function my_callable() {
}
register_rest_route( 'some-namespace', '/some/path', [ 'callback' => 'my_callable' ] );

This would call my_callable, and as is would probably return 200 response with an empty body.

What would me more useful than just callable would be a callable that can define its argument types and return types.

Types and PHP

The ability to verify the correctness of software with strongly typed languages is an obvious benefit to using them.

However, an additional benefit is how the types themselves become the natural documentation to the code.

PHP has supported type hinting for a while:

function totes_not_buggy( WP_REST_Request $request ) WP_REST_Response {
}

With type hints the expectations for totes_not_buggy() are much clearer.

Adding these type hints means at runtime PHP will enforce that only instances of WP_REST_Request will be able to be used with totes_not_buggy(), and that totes_not_buggy() can only return instances of WP_REST_Response.

This sounds good except that this is enforced at runtime. For true type safety we want something better, we want static type analysis. Types should be enforced without running the code.

For this exercise, Psalm will provide static type analysis via PHPDoc annotations.

/**
 * Responds to a REST request with text/plain "You did it!"
 *
 * @param WP_REST_Request $request
 * @return WP_REST_Response
 */
function totes_not_buggy($request) {
   return new WP_REST_Response( 'You did it!', 200, ['content-type' => 'text/plain' );
}

Ok this all sounds nice in theory, how do we check this with Psalm?

To the terminal!

mkdir -p ~/code/wp-api-fun
cd ~/cod/wp-api-fun
composer init

Accept all the defaults and say “no” to the dependencies:

Package name (<vendor>/<name>) [beaucollins/wp-api-fun]: 
Description []: 
Author [Beau Collins <beau@collins.pub>, n to skip]: 
Minimum Stability []: 
Package Type (e.g. library, project, metapackage, composer-plugin) []: 
License []: 
Define your dependencies.
Would you like to define your dependencies (require) interactively [yes]? no
Would you like to define your dev dependencies (require-dev) interactively [yes]? no
{
    "name": "beaucollins/wp-api-fun",
    "authors": [
        {
            "name": "Beau Collins",
            "email": "beau@collins.pub"
        }
    ],
    "require": {}
}
Do you confirm generation [yes]? 

Now install two dependencies:

  • vimeo/psalm to run type checking
  • php-stubs/wordpress-stubs to type check against WordPress APIs
composer require --dev vimeo/psalm php-stubs/wordpress-stubs

Assuming success try to run Psalm:

./vendor/bin/psalm
Could not locate a config XML file in path /Users/beau/code/wp-api-fun/. Have you run 'psalm --init' ?

To keep things simple with composer, define a single PHP file to be loaded for our project at the path ./src/fun.php:

mkdir src
touch src/fun.php

Now inform composer.json where this file is via the "autoload" key:

{
    "name": "beaucollins/wp-api-fun",
    "authors": [
        {
            "name": "Beau Collins",
            "email": "beau@collins.pub"
        }
    ],
    "require": {},
    "require-dev": {
        "vimeo/psalm": "^3.9",
        "php-stubs/wordpress-stubs": "^5.3"
    },
    "autoload": {
        "files": ["src/fun.php"]
    }
}

Generate Psalm’s config file and run it to verify our empty PHP file has zero errors:

./vendor/bin/psalm --init
Calculating best config level based on project files
Calculating best config level based on project files
Scanning files...
Analyzing files...
░
Detected level 1 as a suitable initial default
Config file created successfully. Please re-run psalm.
./vendor/bin/psalm
Scanning files...
Analyzing files...
░
------------------------------
No errors found!
------------------------------
Checks took 0.12 seconds and used 37.515MB of memory
Psalm was unable to infer types in the codebase

For a quick gut-check define totes_not_buggy() in ./src/fun.php:

<?php
// in ./src/fun.php
/**
 * Responds to a REST request with text/plain "You did it!"
 *
 * @param WP_REST_Request $request
 * @return WP_REST_Response
 */
function totes_not_buggy($request) {
   return new WP_REST_Response( 'You did it!', 200, ['content-type' => 'text/plain' );
}

Now analyze with Psalm:

./vendor/bin/psalm
./vendor/bin/psalm
Scanning files...
Analyzing files...
E
ERROR: UndefinedDocblockClass - src/fun.php:6:11 - Docblock-defined class or interface WP_REST_Request does not exist
 * @param WP_REST_Request $request
ERROR: UndefinedDocblockClass - src/fun.php:7:12 - Docblock-defined class or interface WP_REST_Response does not exist
 * @return WP_REST_Response
ERROR: MixedInferredReturnType - src/fun.php:7:12 - Could not verify return type 'WP_REST_Response' for totes_not_buggy
 * @return WP_REST_Response
------------------------------
3 errors found
------------------------------
Checks took 0.15 seconds and used 40.758MB of memory
Psalm was unable to infer types in the codebase

Psalm doesn’t know about WordPress APIs yet. Time to teach it where those are by adding the stubs to ./psalm.xml:

    <stubs>
        <file name="vendor/php-stubs/wordpress-stubs/wordpress-stubs.php" />
    </stubs>
</psalm>

One more run of Psalm:

./vendor/bin/psalm     
Scanning files...
Analyzing files...
░
------------------------------
No errors found!
------------------------------
Checks took 5.10 seconds and used 356.681MB of memory
Psalm was able to infer types for 100% of the codebase

No errors! It knows about WP_REST_Request and WP_REST_Response now.

What happens if they’re used incorrectly like a string for the status code in the WP_REST_Response constructor:

ERROR: InvalidScalarArgument - src/fun.php:10:48 - Argument 2 of WP_REST_Response::__construct expects int, string(200) provided
   return new WP_REST_Response( 'You did it!', '200', ['content-type' => 'text/plain'] );

Nice! Before running the PHP source, Psalm can tell us if it is correct or not. IDE’s that have Psalm integrations show the errors in-place:

Screen capture of Visual Studio Code with fun.php open and the Psalm error displayed in a tool tip.
Visual Studio Code with the Psalm extension enabled showing the InvalidScalarArgument error. ]

Now to answer the question “which type of callable is the register_rest_route() callback option?”

First-Class Functions

With PHP’s type hinting, the best type it can offer for the callback parameter is callable.

This gives no insight into which arguments the callable requires nor what it returns.

With Psalm integrated into the project there are more tools available to better describe this callable type.

callable(Type1, OptionalType2=, SpreadType3...):ReturnType

Using this syntax, the callback option of $args can be described as:

callable(WP_REST_Request):(WP_REST_Response|WP_Error|JSONSerializable)

This line defines a callable that accepts a WP_REST_Request and can return one of WP_REST_Response, WP_Error or JSONSerializable.

Once returned, WP_REST_Server will do what is required to correctly deliver an HTTP response. Anything that conforms to this can be a callback for WP-API. The WP-API world is now more clearly defined:

callable(WP_REST_Request):(WP_REST_Response|WP_Error|JSON_Serializable)

To illustrate this type at work define a function that accepts a callable that will be used with register_rest_route().

Following WordPress conventions, each function name will be prefixed with totes_ as an ad-hoc namespace of sorts (yes, this is completely ignoring PHP namespaces).

/**
 * @param string $path
 * @param (callable(WP_REST_Request):(WP_REST_Response|WP_Error|JSONSerializable)) $handler
 * @return void
 */
function totes_register_api_endpoint( $path, $handler ) {
   register_rest_route( 'totes', $path, [
      'callback' => $handler
   ] );
}
add_action( 'rest_api_init', function() {
   totes_register_api_endpoint('not-buggy', 'totes_not_buggy');
} );

A quick check with Psalm shows no errors:

------------------------------
No errors found!
------------------------------

What happens if the developer has a typo in the string name of the callback totes_not_buggy? Perhaps they accidentally typed totes_not_bugy?

ERROR: UndefinedFunction - src/fun.php:24:45 - Function totes_not_bugy does not exist
   totes_register_api_endpoint('not-buggy', 'totes_not_bugy');

Fantastic!

What happens if the totes_not_buggy function does not conform to the callable(WP_REST_Request):(...) type? Perhaps it returns an int instead:

/**
 * Responds to a REST request with text/plain "You did it!"
 *
 * @param WP_REST_Request $request
 * @return int
 */
function totes_not_buggy( $request ) {
   return new WP_REST_Response("not buggy", 200, ['content-type' => 'text/plain']);
}
ERROR: InvalidArgument - src/fun.php:24:45 - Argument 2 of totes_register_api_endpoint expects callable(WP_REST_Request):(JSONSerializable|WP_Error|WP_REST_Response), string(totes_not_buggy) provided
   totes_register_api_endpoint('not-buggy', 'totes_not_buggy');

The callable string 'totes' no longer conforms to the API. Psalm is catching these bugs before anything is even executed.

But Does it Work?

Psalm says this code is correct, but does this code work? Well, there’s only one way to find out.

First, turn./src/fun.php into a WordPress plugin with the minimal amount of header comments:

<?php
/**
 * Plugin Name: Totes
 */

And boot WordPress via wp-env:

npm install -g @wordpress/env
echo '{"plugins": ["./src/fun.php"]}' > .wp-env.json
wp-env start
curl http://localhost:8889/?rest_route=/ | jq '.routes|keys' | grep totes

There are the endpoints:

curl --silent http://localhost:8889/\?rest_route\=/ | \
  jq '.routes|keys' | \
  grep totes
  "/totes",
  "/totes/not-buggy",
curl http://localhost:8889/\?rest_route\=/totes/not-buggy
"not buggy"

Well it works, but there’s a small problem. It looks like WordPress decided to json_encode() the string literal not buggy so it arrived in quotes as "not buggy" (not very not buggy).

Changing the return of totes_not_buggy to something more JSON compatible works as expected:

-    return new WP_REST_Response("not buggy", 200, ['content-type' => 'text/plain']);
+    return new WP_REST_Response( [ 'status' => 'not-buggy' ] );
curl http://localhost:8889/\?rest_route\=/totes/not-buggy          
{"status":"not-buggy"}

Automate It

Reproducing the steps to run psalm on this codebase is trivial.

With a concise Github Action definition this project can get static analysis on every push. Throw in a annotation service and Pull Request changes are marked with Psalm warnings and exceptions.

Screenshot of an annotated Pull Request on GitHub.

The Github workflow definition defines how to:

  1. Install composer.
  2. Install composer dependencies (with caching).
  3. Run composer check.
  4. Report the Psalm errors.

The Fun Part

This sets up the foundation for a highly productive development environment:

  • Psalm static analysis provides instant feedback on correctness of code.
  • wp-envallows for fast verification of running code.
  • GitHub Actions automates type checking as an ongoing concern.

Coming up: exploring functional programming patterns for WP-API with the help of Psalm.