aivec/wordpress-router

WordPress request router. Middleware, JWT, and nonce checks included.

v7.3.0 2022-08-01 08:05 UTC

README

This package provides a routing library for WordPress with WordPress specific wrappers such as nonce verification and user role checking. The backbone of this package uses FastRoute, a small and succinct route resolver. FastRoute is also the route resolver used by the popular micro-framework Slim.

The Problem

Routing in WordPress is a pain for plugin authors. It relies solely on $_POST object keys to resolve routes if you go with WordPress' traditional way of registering AJAX handlers via admin-ajax.php. You could use WordPress' REST API, but you don't have control of when routes are resolved. This is important to developers who create extensions for other plugins where the load order is out of their control. This package also differs from WordPress' implementation in that it doesn't provide validate and sanitize callbacks, opting instead for generic middlewares.

Features

This library provides many features to streamline the provisioning of routes, as well as some optional default middlewares. The main features are as follows:

  • Role based route registration (editor, administrator, etc.)
  • Automatic nonce verification
  • URL parameters (NOT REGEX 😁)
  • Passthru routing (non-AJAX routes)
  • Helpers for generating HTML forms
  • JWT route registration
  • JWT settings page for automatic key pair generation

Installation

Install with composer:

$ composer require aivec/wordpress-router

If you plan on using this package in a plugin, we highly recommend namespacing it with mozart. If you don't, things may break in an impossible to debug way. You have been warned.

Usage Guide

Public Route

A public route refers to a route without nonce verification. A public route is accessible by anyone, from anywhere.

use Aivec\WordPress\Routing\Router;
use Aivec\WordPress\Routing\WordPressRouteCollector;

// First, we declare our routes by extending the `Router` class:
class Routes extends Router {

    /**
     * This is where we define each route
     */
    public function declareRoutes(WordPressRouteCollector $r) {
        $r->addPublicRoute('GET', '/hamburger', function () {
            return 'Here is a public hamburger.';
        });
    }
}

// Next, we instantiate the `Routes` class with a unique namespace and listen for requests
$routes = new Routes('/mynamespace');
$routes->dispatcher->listen();

Calling the Public Route

You can test the route from the command line, like so:

$ curl -X GET http://www.my-site.com/mynamespace/hamburger
'Here is a public hamburger.'

Or, you can use jQuery's ajax function to send a request from a script loaded into a WordPress page:

jQuery.ajax("http://www.my-site.com/mynamespace/hamburger", {
  success(data) {
    var response = JSON.parse(data);

    console.log(response); // Here is a public hamburger.
  },
});

Private Route

A private route refers to a route with nonce verification.

use Aivec\WordPress\Routing\Router;
use Aivec\WordPress\Routing\WordPressRouteCollector;

// First, extend the `Router` class:
class Routes extends Router {

    /**
     * This is where we define each route
     */
    public function declareRoutes(WordPressRouteCollector $r) {
        /**
         * `add` is the default way to register a route with nonce verification
         */
        $r->add('POST', '/hamburger', function () {
            return 'Here is a private hamburger.';
        });
    }
}

After declaring our routes, we instantiate the Routes class with a unique namespace.

This time, we pass in a nonce key and nonce name as the second and third argument, respectively.

Since nonce handling requires WordPress core functions, we must instantiate the Routes class after core functions have been loaded. You can use the init hook, or any other appropriate hook to ensure core functions are loaded.

$routes = null;
add_action('init', function () use ($routes) {
    $routes = new Routes('/mynamespace', 'nonce-key', 'nonce-name');
    $routes->dispatcher->listen();
});

Calling the Private Route

In general, private routes are called via AJAX from a JavaScript file on the WordPress site. To do this, we must make the nonce available to the script in which we want to call the route.

Leveraging wp_localize_script, we can use a helper method from the Routes class to inject the nonce variables:

add_action('wp_enqueue_scripts', function () use ($routes) {
    wp_enqueue_script(
        'my-script',
        site_url() . '/wp-content/plugins/my-plugin/my-script.js',
        [],
        '1.0.0',
        false
    );

    wp_localize_script('my-script', 'myvars', $routes->getScriptInjectionVariables());
});

Now, my-script.js will have the nonce variables we need to make the call.

// my-script.js
jQuery.ajax(`${myvars.endpoint}/hamburger`, {
  method: "POST",
  data: {
    [myvars.nonceKey]: myvars.nonce,
  },
  success(data) {
    var response = JSON.parse(data);

    console.log(response); // Here is a private hamburger.
  },
});

URL Parameters

Curly braces are used to define a URL parameter.

URL parameters are parsed and then inserted into an $args variable, which is always the first parameter given to the handler function.

$r->add('POST', '/hamburger/{burgername}', function (array $args) {
    return 'Here is a ' . $args['burgername'] . ' hamburger.';
});
// my-script.js
jQuery.ajax(`${myvars.endpoint}/hamburger/mushroom`, {
  method: "POST",
  data: {
    [myvars.nonceKey]: myvars.nonce,
  },
  success(data) {
    var response = JSON.parse(data);

    console.log(response); // Here is a mushroom hamburger.
  },
});

You can define as many parameters as you want.

$r->add('POST', '/hamburger/{burgername}/{count}', function (array $args) {
    return 'Here are ' . $args['count'] . ' ' . $args['burgername'] . ' hamburgers.';
});

You can also limit the type of parameter accepted, as well as provide your own patterns for more granular control.

// Matches /user/42, but not /user/xyz
$r->add('POST', '/user/{id:\d+}', .....);

// Matches /user/foobar, but not /user/foo/bar
$r->add('GET', '/user/{name}', .....);

// Matches /user/foo/bar as well
$r->add('GET', '/user/{name:.+}', .....);

There are many possibilities for route definitions. For detailed information about how routes are resolved, refer here.

Form Data

The router expects POST requests to be sent with a content type of application/x-www-form-urlencoded. Form data is sent as a JSON encoded string as the value of a payload key in the body of the request.

// $payload contains the decoded JSON key-value array
$r->add('POST', '/hamburger', function (array $args, array $payload) {
    $ingredients = join(' and ', $payload['ingredients']);
    return 'I want ' . $ingredients . ' on my hamburger.';
});
// my-script.js
jQuery.ajax(`${myvars.endpoint}/hamburger`, {
  method: "POST",
  data: {
    [myvars.nonceKey]: myvars.nonce,
    payload: JSON.stringify({
      ingredients: ["pickles", "onion"],
    }),
  },
  success(data) {
    var response = JSON.parse(data);

    console.log(response); // I want pickles and onion on my hamburger.
  },
});

Making Everything Easier

As we've seen above, private routes require a nonce key-value pair to be present in the body of a POST request. You may have noticed that we excluded GET requests in those examples. This is because GET requests don't have body content, which means that the nonce variables must be set as URL query parameters. This whole process is tedious, and we can do better.

For people transpiling their JavaScript, we recommend using axios with our helper library. This completely abstracts nonce handling and JSON encoding, as well as automatically setting nonce variables in the request regardless of the request method (GET, POST, PUT, etc.).

The following is the Form Data example, rewritten using these libraries:

// my-script.js
import axios from "axios";
import { createRequestBody } from "@aivec/reqres-utils";

axios
  .post(
    `${myvars.endpoint}/hamburger`,
    createRequestBody(myvars, {
      ingredients: ["pickles", "onion"],
    })
  )
  .then(({ data }) => {
    console.log(data);
  });