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.

Categories
Uncategorized

The number of people walking around Disneyland® with $200.00 plastic light swords is too damn high!

Correction: I have been told the handles are made of metal materials. Sentiment still stands.

Categories
Uncategorized

Hello, You Have Recently Written a Long Form Article Using Gutenberg

Please use the number that best indicates your pain level.

9

It could always be worse.

Categories
Uncategorized

Slack Geekbot Lifehack

Mark yourself as away and Geekbot never bothers you.

Categories
Uncategorized

Dryer Outage Postmortem

Seattle, Washington. Tuesday, December 10th, 2019 the Collins household discovered the control panel of the LG DLE2516W did not respond to button pushes.

DuckDuckGo was consulted. LG support provides troubleshooting steps.

  • Unplug unit.
  • Press & hold power button for 5 seconds.
  • Press & hold start button for 5 seconds.
  • Plug in unit.

Instructions were followed with no change in behavior. Unit still unresponsive.

Household administrator researches potential fixes. Likely culprits:

  • Circuit tripped at circuit breaker.
  • Faulty door switch.
  • Blown high temperature fuse in heater.

Multimeter is not on premises. Multimeter, door switch, and high temperature fuse purchased from Amazon.com.

Thursday, December 12th, 2019. Door switch arrives. Replaced on unit. No change. Collins leave on trip to Portland, Oregon.

Tuesday, December 17th, 2019. Multimeter and fuse arrive. High voltage outlet and unit power supply measure 240v.

To access high temperature fuse entire unit must be disassembled.

Unit disassembled. Multimeter finds fuse to be in working order. Replaced anyway. Ducts inspected and reveal no blockage.

Unit reassembled. Control board lights up when power button pressed. When start is pressed a relay clicks but motor does not engage (queue Picard).

Unit disassembled. Previous reassembly found to be performed by incompetent technician. Assembler received cursing. Curser and cursee roles were fulfilled by the same person.

Unit reassembled. Powers on. Motor starts.

There was much rejoicing.

Steps to prevent future outages: clean the lint from the filter.

Categories
Uncategorized

Dead Calm on the Strait

Categories
Uncategorized

Sunrise at Lopez Island

Getting up early to prepare to cross the Strait of Juan de Fuca.

Categories
Uncategorized

Guérilla Marketing

Time to try Pho Tran on The Ave.

Categories
Uncategorized

Poulsbo Viking Fest

Tracked route from Shilsole to Poulsbo and back.

After some back and forth about the weather we decided to shove off and sail to Poulsbo for the Viking Fest.

Ballard does its own festival but we thought we’d up the ante by sailing from one Norwegian festival to another.

We left the dock just before noon and arrived in Poulsbo at 14:05.

To guarantee we’d arrive for the festivities we motored the whole way there. In the channels Nirvana goes just over 6 knots with the 9.9hp motor opened up. We had the current at our stern as we entered Agate Pass and reached 10.2 knots over ground.

We all watched the parade as it marched through the main street of Poulsbo. From what I can tell it’s a town proud if its Viking heritage, outdoors kids clubs, high school marching bands, and beauty pageant winners.

On the return trip I hoisted the sails at Point Bolin and got a couple tacks in before motoring back through Agate pass. We were leaving Madison Bay as the sun was setting so we dropped the sails and motored back to help keep the passengers from being out in too cold of an evening.

The winds really picked up as we crossed the Puget Sound and would have made for a fun crossing under sail.

Well over 4 hours on the water to log towards the captain’s license!

Categories
Uncategorized

Harmony Amphitheater

Harmony Amphitheater with Finn.