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 checkingphp-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:
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.
The Github workflow definition defines how to:
- Install
composer
. - Install
composer
dependencies (with caching). - Run
composer check
. - 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-env
allows 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.