Advanced OOP For WordPress: Customizing REST API Endpoints To Improve WordPress Search

I’ve written a lot about object-oriented PHP and the WordPress REST API for Torque over the past few years. I’ve also touched on using Composer for dependency management and to provide an autoloader, as well as covered unit testing. The basic message of everything I’ve written is that by applying the established best practices of software development to how we develop for WordPress, we can create better plugins.

This is the first of a series of articles, that will pull together these concepts in a practical, functional example. I’ll be walking through creating a WordPress plugin to modify the capabilities of WordPress REST API endpoints so they can be better optimized for search. The plugin is available on Github. You may want to browse the commit log to see how I put it together.

In this series, I’ll cover structuring plugins and classes using modern object-oriented PHP and not only how to make it testable, but also how to write automated tests for it. I will cover the difference between unit tests, integration tests, and acceptance tests and show you how to write and automate running each type. This article begins the series by showing how the use filters to modify the WordPress REST API using an object-oriented approach.

Improving WordPress Search Using The REST API

Plugins like SearchWP or Relevansi, or integrations with ElasticSearch — a technology that uses a totally different stack than WordPress — using Jetpack or ElasticPress, are often used to improve WordPress search. These types of plugins provide better search results and often pair well with faceted-search interface, which is great for eCommerce apps.

Search via the WordPress REST API inherits all of these same problems and the same solution. In this post, I’m going to start by looking at how search works by default, and what the limitations are. Then we’ll look at how to modify the search using two different methods and integrate with SearchWP.

WordPress’ built-in search capabilities often need to be improved using outside services. While this article is about an object-oriented approach to modifying how WordPress REST API routes for posts work, the practical example will be improving search.

When WordPress is used as the back-end for a decoupled front-end such as a native mobile app or web app, probably built using Vue or React or Angular, having quality search via the REST API is important. The code this article covers will help you if your app’s users need to find the right product variation or search content by a complex algorithm based on multiple taxonomies, and you’re writing custom code, not just installing a plugin.

Searching Posts With The WordPress REST API

If you wanted to search for all posts that were of the post type “product” on a site, using the search terms “Taco Shirts” you would make a request to the /wp/v2/product?s=Taco+Shirt endpoint. If you wanted to improve the quality of the results, the solutions I listed above would help.

As we discussed above, WP_Query, what the post endpoints of the WordPress REST API use, is not a great tool for search. More specifically, WP_Query, probably due to its dependence on MySQL, is inferior to specialized search tools that tend to be built using NoSQL databases.

First, let’s look at how we can bypass WP_Query’s interactions with WordPress’ database if a REST API request is being made.

This is the strategy many search plugins take to substitute the results of their own search systems, for what WP_Query would have generated by default. The search system may use the same database. It may also connect to some other database, possibly via an API request, for example to an ElasticSearch or Apache Solr server.

If you look in WordPress core, you’ll find the filter “posts_pre_query” runs right before WP_Query queries the database, but after the SQL query has been prepared. This filter returns null by default. If that value is null, WordPress continues with its default behavior: querying the WordPress database and returning the results as a simple array of WP_Post objects.

On the other hand, if the return value of this filter is an array — hopefully containing WP_Post objects — then WordPress’ default behavior is not used.

Let’s look at how we can use posts_pre_query to return a mock WP_Post. This strategy is very useful for testing, but a more complex version of the same pattern can be used to integrate a separate database with your WordPress site:

/**
 * Replace all WP_Query results with mock posts
 */
add_filter('posts_pre_query',
    function ($postsOrNull, WP_Query $query) {
        //Don't run if posts are already sent
        if (is_null($postsOrNull)) {
            //Create 4 mock posts with different titles
            $mockPosts = [];
            for ($i = 0; $i post_title = "Mock Post $i"; //Fake title will be different for each post, useful for testing.
                $mockPosts[$i]->filter = "raw"; //Bypass sanitzation in get_post, to prevent our mock data from being sanitized out.
            }
            //Return a mock array of mock posts
            return $mockPosts;
        }
        //Always return something, even if its unchanged
        return $postsOrNull;
    },
    //Default priority, 2 arguments
    10, 2
);

In this example, we’re using mock data, but we could be using SearchWP’s query class, or anything else. One other thing to keep in mind about this code is it will run on any WP_Query, not just a WP_Query object created by the WordPress REST API. Let’s modify that so we  don’t use the filter unless it is a WordPress REST API request by adding conditional logic:

<?php
/**
 * Replace all WP_Query results with mock posts, for WordPress REST API requests
 */
add_filter('posts_pre_query',
    function ($postsOrNull, WP_Query $query) {
        //Only run during WordPress REST API requests
        if (defined('REST_REQUEST') && REST_REQUEST) {
            //Don't run if posts are already sent
            if (is_null($postsOrNull)) {
                //Create 4 mock posts with different titles
                $mockPosts = [];
                for ($i = 0; $i post_title = "Mock Post $i"; //Fake title will be different for each post, useful for testing.
                    $mockPosts[$i]->filter = "raw"; //Bypass sanitzation in get_post, to prevent our mock data from being sanitized out.
                }
                //Return a mock array of mock posts
                return $mockPosts;
            }
        }
        //Always return something, even if its unchanged
        return $postsOrNull;
    },
    //Default priority, 2 arguments
    10, 2
);

Modifying WordPress REST API Endpoints Arguments

We just looked at how to change how the search results are generated for WordPress REST API requests. That allows us to optimize our queries for better search, but it is likely to expose a need for a different schema for the endpoints.

For example, what if you wanted to allow the search on your products endpoint to optionally allow additional post type to be included in the search. I covered a different approach to the same problem last year.

Cross-Cutting Concerns

We are about to look at how to modify the allowed endpoint arguments as well as how they are used to create WP_Query arguments. That’s two separate concerns, and the single responsibility principle says we need one class for each concern. But both classes will have shared concerns.

For example, if we want to allow querying by different post types, we need to know what are the public post types, and what their slugs and rest_base arguments are. This is all information we can get from the function get_post_types.

The output of that function is not exactly what we need. So let’s design a class to format the data according to the needs I just listed and give us helper methods to access it.

Think of it as one common shape for all of the post type data we need in a useable container:

setPostTypes($postTypes);
    }
    /**
     * Get an array of "rest_base" values for all public post types
     *
     * @return array
     */
    public function getPostTypeRestBases(): array
    {
        return !empty($this->postTypes) ? array_keys($this->postTypes) : [];
    }
    /**
     * Prepare the post types
     *
     * @param array $postTypes
     */
    protected function setPostTypes(array $postTypes)
    {
        $this->postTypes = [];
        /** @var WP_Post_Type $postType */
        foreach ($postTypes as $postType) {
            if ($postType->show_in_rest) {
                $this->postTypes[$postType->rest_base] = $postType->name;
            }
        }
    }
    /**
     * Convert REST API base to post type slug
     *
     * @param string $restBase
     * @return string|null
     */
    public function restBaseToSlug(string $restBase)
    {
        if (in_array($restBase, $this->getPostTypeRestBases())) {
            return $this->postTypes[$restBase];
        }
        return null;
    }
}

Notice that we didn’t call get_post_types() in the class, instead, we used it as a dependency, injected through the constructor. As a result, this class can be tested without loading WordPress.

This is why I would describe this class as “unit testable”. It relies on no other APIs and we are not worried about side effects. We can test it as one single, isolated unit. Separating concerns and isolating functionality into small parts makes the code maintainable, once we have unit test coverage. I’ll look at how to test this kind of class in my next post.

Keep in mind that this class does rely on WP_Post_Type. My unit tests will not have that class defined, as only integration tests will have WordPress or any other external dependency available. That class is only used to represent data, not to perform any operations. We can, therefore, say its use creates no side effects. As a result, I am comfortable using a mock in place of the real WP_Post_Type in the unit tests.

Speaking of dependency injection, the classes that require objects of this new class, we want to follow the same pattern. Instead of instantiating PreparedPostTypes inside of the classes that need them, we will pass in an instance. This means the classes consuming PreparedPostTypes and PreparedPostType remain isolated and can be tested separately.

It could also lead to code reuse as we have to make that dependency injection possible and have a property for that object. We could use cut and paste, or we could use a PHP Trait, which is a fancy more scalable way to copy methods and properties between classes.

Here is a Trait that establishes a pattern for how we inject the PreparedPostTypes object into other classes

preparedPostTypes = $preparedPostTypes;
    }
}

Our other concern is we need to know some things about a post type in multiple places. For example the post type’s slug. This is a different flavor of a cross-cutting concern than the previous one. The last problem we solved involved dynamic data. Now we just need a single place to change a string or two we use in multiple places.

A class that has class constants solves this for us simply:

<?php
/**
 * Class PostType
 *
 * Post type whose POST wp/v2/ we are hijacking
 *
 */
class PostType
{
    /**
     * Post type slug
     *
     * @TODO Change this to your post type's slug
     */
    const SLUG = 'post';
    /**
     * Post type rest_base
     *
     * @TODO Change this to your post type's rest_base
     */
    const RESTBASE = 'posts';
}

Now we can keep these strings consistent throughout our code. This may seem like an unnecessary step. But my example code works for the posts post type. If you want to change what post type is used this class needs to change and nothing else needs to change. This is following Tom McFarlin’s preferred definition of the single responsibility principle when he writes A class should have only one reason to change.

Modifying REST API Endpoint Schemas

Now we need to modify the schema of a post type’s endpoints. By doing so, WordPress will communicate to REST API endpoints that the post_type argument is allowed, and when requests are parsed, the new endpoint argument is allowed.

Here is our class to add the post_type attribute. Note that it uses the trait UsesPreparedPostTypes we just discussed:

shouldFilter($post_type)) {
            $query_params[self::ARGNAME] = [
                [
                    'default' => PostType::RESTBASE,
                    'description' => __('Post type(s) for search query'),
                    'type' => 'array',
                    //Limit to public post types and allow query by rest base
                    'items' =>
                        [
                            'enum' => $this->preparedPostTypes->getPostTypeRestBases(),
                            'type' => 'string',
                        ],
                ]
            ];
        }
        return $query_params;
    }
    /**
     * Check if this post type's schema should be filtered
     *
     * @param WP_Post_Type $WP_Post_Type
     * @return bool
     */
    public function shouldFilter(WP_Post_Type $WP_Post_Type): bool
    {
        return PostType::SLUG === $WP_Post_Type->name;
    }
}

In the setting for this attribute, we tell WordPress that this attribute is an array attribute and we specify allowed values using the “enum” index of the array.

In “enum” we enumerate the allowed values. In this case, the PreparedPostTypes class provides that array of allowed values since this is a previously solved, cross-cutting concern.

Note this class isn’t coupled to any post type or even this specific use case. We’ll cover how to make this work with our actual requirement. Decoupling this class from the WordPress plugins API makes it reusable and unit testable. We do need to test that interaction with WordPress, but we would cover that in an integration test. I will show you how to do that in a future post.

We will return shortly to what hooks to use to make this class work for a specific post type.

Modifying REST API WP_Query Arguments

The previous section showed how to make a new endpoint attribute post_type available. That doesn’t actually change the WP_Query arguments the WordPress REST API generates. We have everything we need except one last filter.

Post type is one WP_Query argument that core specifically disallows changing via a REST API request. We have a dynamically named filter — “rest_{$post_type}_query” — that can override any WP_Query arguments.

Here is our class that injects our post_type args, which previously were not allowed:

shouldFilter($request)) {
            add_filter('posts_pre_query', [FilterWPQuery::class, 'posts_pre_query'], 10, 2);
            $args['post_type'] = $this->restBasesToPostTypeSlugs($request[ModifySchema::ARGNAME]);
        }
        return $args;
    }
    /**
     * Check if we should filter request args
     *
     * @param WP_REST_Request $request
     * @return bool
     */
    public function shouldFilter(WP_REST_Request $request): bool
    {
        $attributes = $request->get_attributes();
        if (isset($attributes['args'][ModifySchema::ARGNAME])) {
            if ($request->get_param(ModifySchema::ARGNAME)) {
                return true;
            }
        }
        return false;
    }
    /**
     * Convert an array of rest bases to post type slugs
     *
     * @param array $postTypes
     * @return array
     */
    public function restBasesToPostTypeSlugs(array $postTypes): array
    {
        $postTypeSlugs = [];
        foreach ($postTypes as $postTypeRestBase) {
            if ($this->preparedPostTypes->restBaseToSlug($postTypeRestBase)) {
                $postTypeSlugs[] = $this->preparedPostTypes->restBaseToSlug($postTypeRestBase);
            }
        }
        return $postTypeSlugs;
    }
}

Most of this is just validating if we should make the change and then using the get_param method of WP_Rest_Request to get the value from the request. Most of it is automagical because we modify the schema to match first.

Modifying The WP_Query Object For The WordPress REST API Request

I already showed how to do this in the first part of this post. Here is a class that implements the same pattern:

<?php
/**
 * Class FilterWPQuery
 *
 * Changes WP_Query object
 *
 * @package ExamplePlugin
 */
class FilterWPQuery
{
    /**
     * Demonstrates how to use a different way to set the posts that WP_Query returns
     * @uses "posts_pre_query"
     *
     * @param $postsOrNull
     * @param WP_Query $query
     * @return mixed
     */
    public static function posts_pre_query($postsOrNull, $query)
    {
        //Only run during WordPress API requests
        if (defined('REST_REQUEST') && REST_REQUEST) {
            
            //Prevent recursions
            remove_filter('posts_pre_query', [FilterWPQuery::class, 'posts_pre_query'], 10);
            //Don't run if posts are already sent
            if (is_null($postsOrNull)) {
                 //Create 4 mock posts with different titles
                $mockPosts = [];
                for ($i = 0; $i post_title = "Mock Post $i";
                    $mockPosts[$i]->filter = "raw";
                }
                //Return a mock array of mock posts
                return $mockPosts;
            }
            //Always return something, even if its unchanged
            return $postsOrNull;
        }
    }
}
 init.php
<?php
/**
 * Make this all work
 */
add_action('init', function () {
    $postType = PostType::SLUG;
    $preparedPostType = new PreparedPostTypes(get_post_types([], 'objects'));
    $modifySchema = new ModifySchema($preparedPostType);
    add_filter("rest_{$postType}_collection_params", [$modifySchema, 'filterSchema'], 25, 2);
    $modifyQuery = new ModifyQueryArgs($preparedPostType);
    add_filter("rest_{$postType}_query", [$modifyQuery, 'filterQueryArgs'], 25, 2);
});

I hope that you noticed that this code is very strongly tied to WordPress, and will not be testable. It is using WP_Post from WordPress, it’s checking a constant from WordPress, and interacting with WordPress’ Plugins API. We can mock WP_Post and we can set the constant ourselves. But, the plugin’s API, that’s an essential feature to test. In my next few posts, I will cover how to refactor this class so we can use unit tests to cover everything except the effects of removing that filter, and integration tests to check that effect.

I chose to use a static method for two reasons. First, it made it easy to add and remove it in multiple locations. For example, in the class ModifyQuery, I am hooking this filter only when needed:

add_filter('posts_pre_query', [FilterWPQuery::class, 'posts_pre_query'], 10, 2);
            $args['post_type'] = $this->restBasesToPostTypeSlugs($request[ModifySchema::ARGNAME]);
        }
        return $args;
    }
    /**
     * Check if we should filter request args
     *
     * @param WP_REST_Request $request
     * @return bool
     */
    public function shouldFilter(WP_REST_Request $request): bool
    {
        $attributes = $request->get_attributes();
        if (isset($attributes['args'][ModifySchema::ARGNAME])) {
            if ($request->get_param(ModifySchema::ARGNAME)) {
                return true;
            }
        }
        return false;
    }
    /**
     * Convert an array of rest bases to post type slugs
     *
     * @param array $postTypes
     * @return array
     */
    public function restBasesToPostTypeSlugs(array $postTypes): array
    {
        $postTypeSlugs = [];
        foreach ($postTypes as $postTypeRestBase) {
            if ($this->preparedPostTypes->restBaseToSlug($postTypeRestBase)) {
                $postTypeSlugs[] = $this->preparedPostTypes->restBaseToSlug($postTypeRestBase);
            }
        }
        return $postTypeSlugs;
    }
}
 ModifySchema.php
shouldFilter($post_type)) {
            $query_params[self::ARGNAME] = [
                [
                    'default' => PostType::RESTBASE,
                    'description' => __('Post type(s) for search query'),
                    'type' => 'array',
                    //Limit to public post types and allow query by rest base
                    'items' =>
                        [
                            'enum' => $this->preparedPostTypes->getPostTypeRestBases(),
                            'type' => 'string',
                        ],
                ]
            ];
        }
        return $query_params;
    }
    /**
     * Check if this post type's schema should be filtered
     *
     * @param WP_Post_Type $WP_Post_Type
     * @return bool
     */
    public function shouldFilter(WP_Post_Type $WP_Post_Type): bool
    {
        return PostType::SLUG === $WP_Post_Type->name;
    }
}

Also, it is easy to create recursive loops when using this filter. Being able to easily remove it, like in this example code is very nice.

The other reason I chose to use a static method is that function interacts with other APIs. It will never be truly unit testable. This pattern, a class with static methods, makes it very easy to mock the class in integration tests, minimizing the effects of not having strong isolation in this one part of the system.

Making It All Work Together

The code we’ve looked at so far is very decoupled from WordPress. That has a lot of benefits. But it means that as-is, it doesn’t do anything. That’s fine. We’ve only handled the business logic requirements so far. Now we need to look at integration.

That’s not too hard, it’s just a matter of adding some hooks. Which hooks? Exactly the same two hooks we designed our ModifyQuery and ModifySchema classes around. The desire for decoupled business logic doesn’t mean we can’t think of the actual reason we’re writing the code when we design its public interface. Otherwise, we’ll just be adding additional complexity to our code for no reason.

In general, I try and only add software complexity when it makes life easier. I have strayed from this path in the past. We all have, it’s OK, practice forgiveness.

The methods in the classes we’re about to hook to use exactly the same arguments and return types as the hooks. Their job is to dispatch business those values to the other components.

<?php
/**
 * Make this all work
 */
add_action('init', function () {
    $postType = PostType::SLUG;
    $preparedPostType = new PreparedPostTypes(get_post_types([], 'objects'));
    $modifySchema = new ModifySchema($preparedPostType);
    add_filter("rest_{$postType}_collection_params", [$modifySchema, 'filterSchema'], 25, 2);
    $modifyQuery = new ModifyQueryArgs($preparedPostType);
    add_filter("rest_{$postType}_query", [$modifyQuery, 'filterQueryArgs'], 25, 2);
});

Next Up: Testing

That’s close enough for jazz. It will work. Lacking a formal system for adding hooks, this is as best as we can do for initialization. That’s fine. I’ll cover how to create a more sophisticated and extensible bootstrap process in a future post on integration testing with WordPress.

In this post, we’ve looked at creating code to modify the schema, WP_Query argument generation and underlying WP_Query for a post type. I’d encourage you to turn this code into a plugin, using Composer for the autoloader. In my next post, we’ll look at unit tests to cover these classes.

Josh is a WordPress developer and educator. He is the founder of Caldera Labs, makers of awesome WordPress tools including Caldera Forms — a drag and drop, responsive WordPress form builder. He teaches WordPress development at Caldera Learn.

The post Advanced OOP For WordPress: Customizing REST API Endpoints To Improve WordPress Search appeared first on Torque.

Sharing is Awesome, Thank You! :)

Share this Blue 37 post with your friends
close-link