From 2e8c1e572615604064eb156042160bbd9797cd90 Mon Sep 17 00:00:00 2001
From: matapatos Router vs Endpoint dependencies
For instance, if your endpoints reside inside a plugin with a slug my-plugin
you have to set the dependencies
to ['my-plugin']
otherwise when a request is received for that endpoint my-plugin
will not be loaded.
Under the hood, this plugin generates a config file with all the route dependencies (see example). To have the most up-to-date endpoint dependencies, make sure to either:
wp fastendpoints depends
command or FastEndpoints is an elegant way of writing custom WordPress REST endpoints with a focus on readability and IDE auto completion support.
"},{"location":"#features","title":"Features","text":"register_rest_route
We aim to support versions that haven't reached their end-of-life.
"},{"location":"#installation","title":"Installation","text":"composer require wp-fastendpoints
"},{"location":"#sponsors","title":"Sponsors","text":""},{"location":"release/","title":"Release Notes","text":""},{"location":"release/#v200","title":"v2.0.0","text":"Support for treating plugins as dependencies.
depends(['buddypress''])
- Only loads the BuddyPress plugin for this given endpoint$router->get('/users/(?P<ID>[\\d]+)', function ($ID) {\n return get_user_by('id', $ID);\n})\n->returns('Users/Get')\n->depends(['buddypress']); // Only BuddyPress plugin will be loaded for this REST endpoint
Warn
Make sure to run the following WP-CLI command after a successfull deployment: wp
Three new filters that allows us to customise our JSON schema validator's.
fastendpoints_validator
- Triggered by both middlewaresfastendpoints_schema_validator
- Only triggered for Schema middlewares validatorsfastendpoints_response_validator
- Only triggered for Response middlewares validatorsuse Opis\\JsonSchema\\Validator;\n\nadd_filter('fastendpoints_validator', function (Validator $validator): Validator {\n $formatsResolver = $validator->parser()->getFormatResolver();\n $formatsResolver->registerCallable('integer', 'even', function (int $value): bool {\n return $value % 2 === 0;\n });\n\n return $validator;\n});
Info
For more customisations check the following links: 1. Custom formats, 2. Custom filters, 3. Custom media types and, 4. Custom content encoding
"},{"location":"release/#v121","title":"v1.2.1","text":"Using JSON/opis schema loader and resolver which allows us to reference schemas inside other schemas.
// Now we also need to set a prefix while appending a directory. This prefix\n// will be used to reference schemas from inside another schema.\n$router->appendSchemaDir('/my-dir', 'http://www.example.com');
"},{"location":"release/#v120","title":"v1.2.0","text":"Dependency injection support in main handler, middlewares and permission handlers.
// In the past, the $request parameter was mandatory:\n$router->get('/posts/(?P<ID>[\\d]+)', function (WP_REST_Request $request) {\n return $request->get_param('ID');\n});\n\n// Now you only type what you need\n$router->get('/posts/(?P<ID>[\\d]+)', function ($ID) {\n return $ID;\n});\n// Middleware changes\nclass MyCustomMiddleware extends \\Wp\\FastEndpoints\\Contracts\\Middleware {\n public function onRequest(/* Type what you need e.g. $request */) {\n // Called before handling the request\n }\n public function onResponse(/* Type what you need e.g. $response, $request */) {\n // Called after the request being handled\n }\n}
"},{"location":"release/#v110","title":"v1.1.0","text":"// Middleware example\nclass MyCustomMiddleware extends \\Wp\\FastEndpoints\\Contracts\\Middleware {\n public function onRequest(\\WP_REST_Request $request): ?\\WP_Error {\n // Called before handling the request\n return null;\n }\n public function onResponse(\\WP_REST_Request $request, mixed $response): mixed {\n // Called after the request being handled\n return $response;\n }\n}
"},{"location":"release/#v100","title":"v1.0.0","text":"Initial release - don't use it!
"},{"location":"advanced-user-guide/","title":"Advanced User Guide","text":"The Quick Start should be able to get you a feel of the main features of WP-FastEndpoints.
However, it's possible that the solution for your use case might not be in the Quick Start tutorial. In the next sections we will take a look at further functionalities that WP-FastEndpoints provides.
"},{"location":"advanced-user-guide/dependency-injection/","title":"Dependency Injection","text":"Each REST endpoint has its unique logic. Same goes with the data that it needs to work.
For that reason, WP-FastEndpoints provides dependency injection support for all handlers e.g. permission handlers, main endpoint handler and middlewares.
With dependency injection our endpoints do look much cleaner \u2728\ud83e\uddf9
With dependency injectionNo dependency injection// We only need the ID. So we type $ID\n$router->get('/posts/(?P<ID>[\\d]+)', function ($ID) {\n return get_post($ID);\n});\n\n// We don't need anything. So no arguments are defined :D\n$router->get('/posts/random', function () {\n $allPosts = get_posts();\n return $allPosts ? $allPosts[array_rand($allPosts)] : new WpError(404, 'No posts found');\n});
// Unable to fetch a dynamic parameter. Have to work with the $request argument\n$router->get('/posts/(?P<ID>[\\d]+)', function ($request) {\n return get_post($request->get('ID'));\n});\n\n// Forced to accept $request even if not used :(\n$router->get('/posts/random', function ($request) {\n $allPosts = get_posts();\n return $allPosts ? $allPosts[array_rand($allPosts)] : new WpError(404, 'No posts found');\n});
"},{"location":"advanced-user-guide/middlewares/","title":"Middlewares","text":"Another cool feature of WP-FastEndpoints is the support for middlewares.
Middlewares are pieces of code that can either run before and/or after a request is handled.
At this stage, you might be already familiar with both the schema(...)
and returns(...)
middlewares. However, you can also create your own.
use Wp\\FastEndpoints\\Contracts\\Middleware;\n\nclass MyCustomMiddleware extends Middleware\n{\n /**\n * Create this function if you want that your middleware is\n * triggered when it receives a request and after checking\n * the user permissions.\n */\n public function onRequest(/* Type what you need */)\n {\n return;\n }\n\n /**\n * Create this function when you want your middleware to be\n * triggered before sending a response to the client \n */\n public function onResponse(/* Type what you need */) {\n return;\n }\n}\n\n// Attach middleware to endpoint\n$router->get('/test', function () {\n return true;\n})\n->middleware(new MyCustomMiddleware());
Tip You can create both methods in a middleware: onRequest
and onResponse
. However, to save some CPU cycles only create the one you need [CPU emoji]
If you need you can also take advantage of either WP_Error and WP_REST_Response to send a direct response to the client. See Responses page for more info
"},{"location":"advanced-user-guide/plugins-as-dependencies/","title":"Treat plugins as dependencies","text":"One of the main strengths of WordPress is the wide range of plugins available which allow us to fully customise a website in a short time period. However, every time a plugin is added it can negatively impact the performance of our API endpoints, because even though those endpoints might not need some of the activated plugins to work properly, they will still be loaded.
To address this issue WP-FastEndpoints Depends was created to enable us to treat plugins as REST endpoint dependencies.
"},{"location":"advanced-user-guide/plugins-as-dependencies/#adding-another-plugin","title":"Adding another plugin?? \ud83d\ude31","text":"Yes, this is a plugin! It could seem counterintuitive that adding another plugin could positively impact our API endpoints. However, given that in most cases our API endpoints don't need all the plugins that are active e.g. BuddyPress, Elementor it can actually improve your API endpoints.
"},{"location":"advanced-user-guide/plugins-as-dependencies/#how-it-works","title":"How it works?","text":"Given this plugin needs to be setup as a MU-plugin it will always run before any regular plugin which allow us to decide which plugins are necessary for a given REST endpoint before loading them.
"},{"location":"advanced-user-guide/plugins-as-dependencies/#how-to-use-it","title":"How to use it?","text":"Currently, we support both native WP endpoints and FastEndpoints \ud83d\ude0a
With FastEndpointsNative WP endpoints$router->get('/example/all-plugins', function () {\n return \"Loads all active plugins\";\n});\n\n$router->get('/example/buddypress', function () {\n return \"Only MyPlugin and BuddyPress plugins are loaded\"; \n})->depends(['my-plugin', 'buddypress']);
// Loads all active plugins\nregister_rest_route('native/v1', 'example/all-plugins', [\n 'methods' => 'GET',\n (...)\n]);\n\n// Only MyPlugin and BuddyPress plugins are loaded\nregister_rest_route('native/v1', 'example/buddypress', [\n 'methods' => 'GET',\n 'depends' => ['my-plugin', 'buddypress'],\n (...)\n]);
Tip By default, if no dependencies are specified in an endpoint it assumes that all active plugins needs to be loaded. This behaviour could be overridden for a given set of WP-FastEndpoint's by setting router dependencies e.g. $router->depends(['my-plugin'])
With WP-FastEndpoint's we are able to either define global endpoint dependencies via router dependencies or specific endpoint dependencies.
One common scenario where router dependencies might be useful is when we want to change the default behaviour of loading all active plugins per endpoint.
$router = new \\Wp\\FastEndpoints\\Router('my-api', 'v1');\n$router->depends(['my-plugin']); // All endpoints and sub-routers would have this dependency
Danger
When adding dependencies to endpoints, make sure to at least include the given plugin that holds those endpoints. For instance, if your endpoints reside inside a plugin with a slug my-plugin
you have to set the dependencies to ['my-plugin']
otherwise when a request is received for that endpoint my-plugin
will not be loaded.
Under the hood, this plugin generates a config file with all the route dependencies (see example). To have the most up-to-date endpoint dependencies, make sure to either:
wp fastendpoints depends
command or In WP-FastEndpoints an endpoint can have multiple optional handlers attached to:
hasCap(...)
or permission(...)
- Used to check for user permissionsschema(...)
- Validates the request payloadreturns(...)
- Makes sure that the proper response is sent to the clientmiddleware(...)
- Any other custom logic that you might want to runWhen a request is received the first handlers to run are the permissions handlers. Permission handlers are called by WordPress via permission_callback
.
In contrast to WordPress, you can have one or multiple permission handlers attached to the same endpoint.
NoteIn the background all permission handlers are wrapped into one callable which is later on used as permission_callback
by the endpoint
These handlers will then be called in the same order as they were attached. For instance:
$router->get('/test', function () {return true;})\n->hasCap('read') # Called first\n->hasCap('edit_posts') # Called second if the first one was successful\n->permission('__return_true') # Called last if both the first and second were successful
"},{"location":"advanced-user-guide/request-life-cycle/#middlewares","title":"Middlewares","text":"If all the permission handlers are successful the next set of handlers that run are the middlewares which implement the onRequest
function.
Remember that a middleware can implement onRequest
and/or onResponse
functions. The first one, runs before the main endpoint handler and the later one should run after the main endpoint handler.
Warning
Please bear in mind that if either a WP_Error or a WP_REST_Response is returned by the main endpoint handler following middlewares will not run. See Responses page for more info.
"},{"location":"advanced-user-guide/request-life-cycle/#onrequest","title":"onRequest","text":"Same as with the permission handlers, middlewares are called with the same order that they were attached.
class OnRequestMiddleware extends \\Wp\\FastEndpoints\\Contracts\\Middleware\n{\n public function onRequest(/* Type what you need */){\n return;\n }\n}\n\n$router->post('/test', function () {return true;})\n->middleware(OnRequestMiddleware()) # Called first\n->schema('Basics/Bool'); # Called second
"},{"location":"advanced-user-guide/request-life-cycle/#onresponse","title":"onResponse","text":"Likewise, middlewares implementing onResponse functions will be triggered in the same order as they were attached.
class OnResponseMiddleware extends \\Wp\\FastEndpoints\\Contracts\\Middleware\n{\n public function onResponse(/* Type what you need */){\n return;\n }\n}\n\n$router->post('/test', function () {return true;})\n->returns('Basics/Bool') # Called first\n->middleware(OnResponseMiddleware()); # Called second
"},{"location":"advanced-user-guide/responses/","title":"Responses","text":"When building an API sometimes we want to return a response directly to the client. For example:
$router->get('/posts/(?P<ID>[\\d]+)', function ($ID) {\n return get_post($ID);\n})\n->returns('Posts/Get'); // It will raise a 422 HTTP error when we are unable to find a post
The code above, will raise a 422 HTTP status code error when ever we are unable to find a given post. This is where returning a message directly to the client can be useful.
"},{"location":"advanced-user-guide/responses/#early-return","title":"Early return","text":"To trigger those scenarios we can either return a WP_Error or a WP_REST_Response.
WP_REST_ResponseWP_Error$router->get('/posts/(?P<ID>[\\d]+)', function ($ID) {\n $post = get_post($ID);\n return $post ?: new WP_REST_Response(\"No posts found\", 404);\n})\n->returns('Posts/Get'); // This will not be triggered if no posts are found
$router->get('/posts/(?P<ID>[\\d]+)', function ($ID) {\n $post = get_post($ID);\n return $post ?: new WpError(404, \"No posts found\");\n})\n->returns('Posts/Get'); // This will not be triggered if no posts are found
"},{"location":"advanced-user-guide/responses/#difference-between-returning-wp_rest_response-or-wp_error","title":"Difference between returning WP_REST_Response or WP_Error","text":"The main difference between returning a WP_Error or a WP_REST_Response is regarding the JSON returned in the body.
WP_REST_ResponseWP_Error\"No posts found\"
{\n \"error\": 404,\n \"message\": \"No posts found\",\n \"data\": {\n \"status\": 404\n }\n}
"},{"location":"advanced-user-guide/json-schemas/multiple-root-dirs/","title":"Multiple Root Dirs","text":"For most projects, all JSON schemas might be kept inside a single root directory, like:
my-plugin\n\u2502\n\u2514\u2500\u2500\u2500src\n\u2502 \u2502\n\u2502 \u2514\u2500\u2500\u2500Api\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Schemas // Root dir\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Posts\n\u2502 \u2502 \u2502 \u2502 (...)\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Users\n\u2502 \u2502 \u2502 (...)
However, when your API starts to grow you might end up having the need for multiple root directories.
"},{"location":"advanced-user-guide/json-schemas/multiple-root-dirs/#example","title":"Example","text":"Let's imagine that your API consists on two different versions: v1 and v2, like the following:
my-plugin\n\u2502\n\u2514\u2500\u2500\u2500src\n\u2502 \u2502\n\u2502 \u2514\u2500\u2500\u2500Api\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500v1\n\u2502 \u2502 \u2502 \u2514\u2500\u2500\u2500Schemas // V1 JSON schemas root dir\n\u2502 \u2502 \u2502 \u2502\n\u2502 \u2502 \u2502 \u2514\u2500\u2500\u2500Posts\n\u2502 \u2502 \u2502 \u2502 (...)\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500v2\n\u2502 \u2502 \u2514\u2500\u2500\u2500Schemas // V2 JSON schemas root dir\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Posts\n\u2502 \u2502 \u2502 (...)
In this case scenario your code would look something like this:
$router->appendSchemaDir(MY_PLUGIN_DIR.'Api/v1/Schemas', 'https://www.wp-fastendpoints.com/v1');\n$router->appendSchemaDir(MY_PLUGIN_DIR.'Api/v2/Schemas', 'https://www.wp-fastendpoints.com/v2');
Then in all your endpoints you will have to specify the full schema prefix. It's important that you specify the full prefix because we can't guarantee the order or even if the same schema directory is returned all the time.
Using v1 schemasUsing v2 schemas$router->get('/test', function(){return true;})\n->returns('https://www.wp-fastendpoints.com/v1/Posts/Get.json');
$router->get('/test', function(){return true;})\n->returns('https://www.wp-fastendpoints.com/v2/Posts/Get.json');
"},{"location":"advanced-user-guide/json-schemas/references/","title":"References","text":"References is another great feature from opis/json-schema.
With references, you are able to point to another JSON schema inside of schema. This can be useful to reuse the same schema multiple times.
Tip
Bear in mind that when referencing a schema the full prefix must be used e.g. https://www.wp-fastendpoints.com/Posts/Get.json
Take a look at their References Docs \u00bb for more information
"},{"location":"advanced-user-guide/json-schemas/validator/","title":"Validator","text":"WP-FastEndpoints uses opis/json-schema for JSON schema validation.
The reason we don't use the default WordPress JSON schema validation functionality is because it's quite outdated: it only partially supports JSON schema draft 4. opis/json-schema on the other side, does support the latest JSON schema drafts.
"},{"location":"advanced-user-guide/json-schemas/validator/#customising-validator","title":"Customising validator","text":"One of the coolest features of opis/json-schema is that is super flexible, and supports:
These, can be super useful when ever you need some custom functionality in your JSON schemas.
"},{"location":"advanced-user-guide/json-schemas/validator/#available-hooks","title":"Available hooks","text":"There are three WordPress filter hooks that you can use to customise the JSON schema validators used in WP-FastEndpoints:
fastendpoints_validator
- Triggered by both middlewaresfastendpoints_schema_validator
- Only triggered for Schema middlewares validatorsfastendpoints_response_validator
- Only triggered for Response middlewares validatorsImagine we only want to accept even numbers. To solve this issue, we might want to create a new custom format for integers, called even
, which checks if a given number is even, like:
use Opis\\JsonSchema\\Validator;\n\n/**\n * Adds custom format resolvers to all JSON validators: request payload schema and response.\n *\n * @see fastendpoints_schema_validator - To update only the request payload schema validator, or\n * @see fastendpoints_response_validator - To update only the response validator\n */\nadd_filter('fastendpoints_validator', function (Validator $validator): Validator {\n $formatsResolver = $validator->parser()->getFormatResolver();\n $formatsResolver->registerCallable('integer', 'even', function (int $value): bool {\n return $value % 2 === 0;\n });\n\n return $validator;\n});
Here is an example of a JSON schema using our custom even
format:
{\n \"type\": \"integer\",\n \"format\": \"even\"\n}
More examples can be found in Custom Formats docs \u00bb
"},{"location":"quick-start/","title":"Quick Start","text":"To better exemplify the benefits of using FastEndpoints we are going to build an API for manipulating blog posts.
This API will be able to:
Full source code can be found at matapatos/wp-fastendpoints-my-plugin \u00bb
"},{"location":"quick-start/#plugin-code-structure","title":"Plugin code structure \ud83d\udd28","text":"To hold this API we are going to create a plugin called MyPLugin - don't forget that logic shouldn't be contained in a theme - with the following structure:
my-plugin\n\u2502 my-plugin.php # Registers the plugin provider\n\u2502 composer.json\n\u2502\n\u2514\u2500\u2500\u2500src\n\u2502 \u2502 constants.php\n\u2502 \u2502\n\u2502 \u2514\u2500\u2500\u2500Api\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Routers\n\u2502 \u2502 \u2502 \u2502 Posts.php # Holds our custom endpoints\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Schemas\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Posts\n\u2502 \u2502 \u2502 CreateOrUpdate.json # Validates request payload\n\u2502 \u2502 \u2502 Get.json # Validates responses and discards unwanted fields\n\u2502 \u2502\n\u2502 \u2502\n\u2502 \u2514\u2500\u2500\u2500Providers\n\u2502 \u2502 ApiServiceProvider.php # Registers all routers\n\u2502 \u2502 MyPluginProvider.php # Bootstraps our plugin\n\u2502 \u2502 ProviderContract.php\n\u2502\n\u2514\u2500\u2500\u2500tests
"},{"location":"quick-start/json-schemas/","title":"JSON Schemas","text":"For this scenario, we are going to create two JSON schemas: 1) for validating the request payload and another 2) to discard unwanted fields from responses (e.g. sensitive information).
"},{"location":"quick-start/json-schemas/#request-payload-createupdate","title":"Request payload (create/update)","text":"For the payload we decided to accept the following fields: 1) post_title, 2) post_status, 3) post_type and 4) optionally post_content
{\n \"type\": \"object\",\n \"properties\": {\n \"post_title\": {\n \"type\": \"string\"\n },\n \"post_status\": {\n \"enum\": [\"publish\", \"draft\", \"private\"],\n \"default\": \"publish\"\n },\n \"post_type\": {\n \"const\": \"post\"\n },\n \"post_content\": {\n \"type\": \"string\",\n \"contentMediaType\": \"text/html\"\n }\n },\n \"required\": [\"post_title\", \"post_status\", \"post_type\"]\n}
"},{"location":"quick-start/json-schemas/#response-retrieve","title":"Response (retrieve)","text":"For the response we decided to only return the following fields: 1) post_title and 2) optionally post_excerpt
{\n \"type\": \"object\",\n \"properties\": {\n \"post_title\": {\n \"type\": \"string\"\n },\n \"post_excerpt\": {\n \"type\": \"string\",\n \"contentMediaType\": \"text/html\"\n }\n },\n \"required\": [\"post_title\"]\n}
"},{"location":"quick-start/router/","title":"Router","text":"The first thing we need to do is to create a Router.
use Wp\\FastEndpoints\\Router;\n\n// Dependency injection to enable us to mock router in tests\n$router = $router ?? new Router('posts');
A router is a class which allow us to attach and register endpoints.
An application can have one or multiple routers. One main benefit of using multiple routers is to group endpoints by same namespace and (optionally) same version. For instance, in this tutorial we are going to create a main router with a base namespace my-plugin
and a version of v1
which will add /my-plugin/v1/
to the beginning of each attached endpoint from all sub-routers.
With the posts router in place we can now start attaching our endpoints. We start adding the one responsible to create a new blog post.
$router->post('/', function (\\WP_REST_Request $request, \\WP_REST_Response $response): int|\\WP_Error {\n $response->set_status(201);\n $payload = $request->get_params();\n\n return wp_insert_post($payload, true);\n})\n ->schema('Posts/CreateOrUpdate')\n ->hasCap('publish_posts');
When a request is received by this endpoint the following happens:
Info
In this scenario we are not using a JSON schema to discard fields because the wp_insert_post either returns the ID of the post or a WP_Error which is already what we want \ud83d\ude0a
"},{"location":"quick-start/router/#retrieve-a-post","title":"Retrieve a post","text":"Some endpoints however do need to return more complex objects. And in those cases JSON schemas can be of a great help.
JSON schemas can help us to make sure that we are returning all the required fields as well as to avoid retrieving sensitive information. The last one is configurable.
use Wp\\FastEndpoints\\Helpers\\WpError;\n\n$router->get('(?P<ID>[\\d]+)', function ($ID) {\n $post = get_post($ID);\n\n return $post ?: new WpError(404, 'Post not found');\n})\n ->returns('Posts/Get')\n ->hasCap('read');
In this case, we didn't set a JSON schema on purpose because we only need the post_id which is already parsed by the regex rule - we could have made that rule to match only positive integers though \ud83e\udd14
Going back to the endpoint, this is what happens if a request comes in:
Note
The WpError is just a subclass of WP_Error which automatically set's the HTTP status code of the response
"},{"location":"quick-start/router/#update-a-post","title":"Update a post","text":"Checking for user capabilities such as publish_posts
and read
is cool. However, in the real world we sometimes also need to check for a particular resource.
$router->put('(?P<ID>[\\d]+)', function (\\WP_REST_Request $request): int|\\WP_Error {\n $payload = $request->get_params();\n\n return wp_update_post($payload, true);\n})\n ->schema('Posts/CreateOrUpdate')\n ->hasCap('edit_post', '<ID>');
The code above is not that different from the one for creating a post. However, in the last line hasCap('edit_post', '<ID>')
the second parameter is a special one for FastEndpoints which will try to replace it by the ID parameter.
Warning
FastEndpoints will only replace the {PARAM_NAME} if that parameter exists in the request payload. Otherwise, will not touch it. Also, bear in mind that the first stage in an endpoint is checking the user capabilities. As such, at that time the request params have not been already validated by the request payload schema.
"},{"location":"quick-start/router/#delete-a-post","title":"Delete a post","text":"use Wp\\FastEndpoints\\Helpers\\WpError;\n\n$router->delete('(?P<ID>[\\d]+)', function ($ID) {\n $post = wp_delete_post($postId);\n\n return $post ?: new WpError(500, 'Unable to delete post');\n})\n ->returns('Posts/Get')\n ->hasCap('delete_post', '<ID>');
"},{"location":"quick-start/router/#everything-together","title":"Everything together","text":"\"\"\"\nApi/Endpoints/Posts.php\n\"\"\"\ndeclare(strict_types=1);\n\nnamespace MyPlugin\\Api\\Routers;\n\nuse Wp\\FastEndpoints\\Helpers\\WpError;\nuse Wp\\FastEndpoints\\Router;\n\n// Dependency injection to enable us to mock router in the tests\n$router = $router ?? new Router('posts');\n\n// Creates a post\n$router->post('/', function (\\WP_REST_Request $request): int|\\WP_Error {\n $payload = $request->get_params();\n\n return wp_insert_post($payload, true);\n})\n ->schema('Posts/CreateOrUpdate')\n ->hasCap('publish_posts');\n\n// Fetches a single post\n$router->get('(?P<ID>[\\d]+)', function ($ID) {\n $post = get_post($ID);\n\n return $post ?: new WpError(404, 'Post not found');\n})\n ->returns('Posts/Get')\n ->hasCap('read');\n\n// Updates a post\n$router->put('(?P<ID>[\\d]+)', function (\\WP_REST_Request $request): int|\\WP_Error {\n $payload = $request->get_params();\n\n return wp_update_post($payload, true);\n})\n ->schema('Posts/CreateOrUpdate')\n ->hasCap('edit_post', '<ID>');\n\n// Deletes a post\n$router->delete('(?P<ID>[\\d]+)', function ($ID) {\n $post = wp_delete_post($postId);\n\n return $post ?: new WpError(500, 'Unable to delete post');\n})\n ->returns('Posts/Get')\n ->hasCap('delete_post', '<ID>');\n\n// IMPORTANT: If no service provider is used make sure to set a version to the $router and call\n// the following function here:\n// $router->register();\n\n// Used later on by the ApiProvider\nreturn $router;
"},{"location":"quick-start/service-provider/","title":"Service Provider","text":"Now that we have our posts router built the last main three bits missing are the following:
rest_api_init
hook for registering all the endpoints.\"\"\"\nsrc/Providers/ApiProvider.php\n\"\"\"\ndeclare(strict_types=1);\n\nnamespace MyPlugin\\Providers;\n\nuse Wp\\FastEndpoints\\Router;\n\nclass ApiProvider implements ProviderContract\n{\n protected Router $appRouter;\n\n public function register(): void\n {\n $this->appRouter = new Router('my-plugin', 'v1');\n $this->appRouter->appendSchemaDir(\\SCHEMAS_DIR, 'http://www.my-plugin.com');\n foreach (glob(\\ROUTERS_DIR.'/*.php') as $filename) {\n $router = require $filename;\n $this->appRouter->includeRouter($router);\n }\n $this->appRouter->register();\n }\n}
Tip
Adding the schema directory to the main router will share it across all sub-routers.
"},{"location":"quick-start/service-provider/#its-running","title":"It's running","text":"\ud83c\udf89 Congrats you just created your first set of REST FastEndpoints
Now let's see how to test it out! \ud83d\ude04
Full source code can be found at matapatos/wp-fastendpoints-my-plugin \u00bb
"},{"location":"quick-start/tests/","title":"Testing","text":"For testing our WP-FastEndpoints router we are going to use pest/php.
Pest is a testing framework that makes it super easy to test functionality in PHP, that's why we are going to use it here. However, if you have a preference for some other testing framework, the some principles should apply \ud83d\ude0a
Full source code can be found at matapatos/wp-fastendpoints-my-plugin \u00bb
"},{"location":"quick-start/tests/#testing-dependencies","title":"Testing dependencies","text":"First, let's add all the necessary testing dependencies:
composer require mockery/mockery --dev # For mocking classes/functions\ncomposer require dingo-d/wp-pest --dev # Adds Pest support for integration tests
"},{"location":"quick-start/tests/#testing-structure","title":"Testing structure","text":"For testing our plugin, we are going to assume the following structure:
my-plugin\n\u2502 my-plugin.php\n\u2502 composer.json\n\u2502\n\u2514\u2500\u2500\u2500src\n\u2502 (...)\n\u2502\n\u2514\u2500\u2500\u2500tests\n \u2502 bootstrap.php # Loads WordPress for integration tests\n \u2502 Helpers.php # (optional) Helper functions\n \u2502 Pest.php # Pest configuration file\n \u2502\n \u2514\u2500\u2500\u2500Integration\n \u2502 PostsApiTest.php\n \u2502\n \u2514\u2500\u2500\u2500Unit\n PostsApiTest.php
"},{"location":"quick-start/tests/integration/","title":"Integration Tests","text":"Integration tests, are a bit tricky to set up.
The following needs to happen in order to successfully run them:
rest_api_init
hookHowever, thanks to wp-pest most of this trouble is no longer an issue. By simply running the command bellow, it will automatically pull the WordPress version you want and also set up the tests directory for you.
./vendor/bin/wp-pest setup plugin --plugin-slug my-plugin --wp-version 6.4.4
Tip
If you use matapatos/wp-fastendpoints-my-plugin you can use the already configured composer setup:wp:6.x
commands
If you take a closer look at the resultant tests structure you might notice that is slightly different from matapatos/wp-fastendpoints-my-plugin. These changes are not mandatory and so, feel free to skip this section \u23e9
The main reason of these differences is to allow us to run tests without the need to always specify a group of tests. Those changes include:
/**\n* tests/Helpers.php\n */ \nnamespace MyPlugin\\Tests;\n\nclass Helpers\n{\n /**\n * Checks if weather we want to run integration tests or not\n */\n public static function isIntegrationTest(): bool\n {\n return isset($GLOBALS['argv']) && in_array('--group=integration', $GLOBALS['argv'], true);\n }\n}
/**\n* tests/Integration/*Test.php\n */ \nnamespace MyPlugin\\Tests\\Integration;\n\nuse MyPlugin\\Tests\\Helpers;\n\n// Needs to add this check to every Integration test file\nif (! Helpers::isIntegrationTest()) {\n return;\n}
"},{"location":"quick-start/tests/integration/#our-integration-test","title":"Our integration test \ud83d\ude43","text":"Now that everything is configured we can start creating integration tests:
test('Create a new post', function () {\n // Create user with correct permissions\n $userId = $this::factory()->user->create();\n $user = get_user_by('id', $userId);\n $user->add_cap('publish_posts');\n // Make request as that user\n wp_set_current_user($userId);\n $request = new \\WP_REST_Request('POST', '/my-plugin/v1/posts');\n $request->set_body_params([\n 'post_title' => 'My testing message',\n 'post_status' => 'publish',\n 'post_type' => 'post',\n 'post_content' => '<p>Message body</p>',\n ]);\n $response = $this->server->dispatch($request);\n expect($response->get_status())->toBe(201);\n $postId = $response->get_data();\n // Check that the post details are correct\n expect(get_post($postId))\n ->toBeInstanceOf(\\WP_Post::class)\n ->toHaveProperty('post_title', 'My testing message')\n ->toHaveProperty('post_status', 'publish')\n ->toHaveProperty('post_type', 'post')\n ->toHaveProperty('post_content', '<p>Message body</p>');\n})->group('api', 'posts');
Here, we take advantage of the existent testing factories to create a single user with the necessary capability to publish posts. Then, we make mimic a REST request from that given user, and lastly, we check if that blog post was created.
"},{"location":"quick-start/tests/unit/","title":"Unit Tests","text":"As an example of a unit test, we are going add a test to check the 1) request payload schema used and 2) the necessary user permissions on the endpoint that allows a user to create a new blog post.
We could have separated each assertion in its own unit test but for the sake of simplicity we are going to make both of them in the same test.
test('Creating post endpoint has correct permissions and schema', function () {\n // Create endpoint mock\n $endpoint = Mockery::mock(Endpoint::class);\n // Assert that the request payload schema passed is correct\n $endpoint\n ->shouldReceive('schema')\n ->once()\n ->with('Posts/CreateOrUpdate')\n ->andReturnSelf();\n // Assert that user permissions are correct\n $endpoint\n ->shouldReceive('hasCap')\n ->once()\n ->with('publish_posts');\n // To ignore all the other endpoints\n $ignoreEndpoint = Mockery::mock(Endpoint::class)\n ->shouldIgnoreMissing(Mockery::self());\n // Create router. Make sure that var name matches your router variable\n $router = Mockery::mock(Router::class)\n ->shouldIgnoreMissing($ignoreEndpoint);\n // Assert that router endpoint is called\n $router\n ->shouldReceive('post')\n ->once()\n ->with('/', Mockery::type('callable'))\n ->andReturn($endpoint);\n // Needed to attach endpoints\n require \\ROUTERS_DIR.'/Posts.php';\n})->group('api', 'posts');
The reason we are able to make the assertions above is due to this line. Specially, regarding this part $router ??
. This allows us to replace our original router with our mocked version.
Nothing magical happening here, just pure PHP code! \ud83e\ude84
"}]} \ No newline at end of file +{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"WP-FastEndpoints","text":"FastEndpoints is an elegant way of writing custom WordPress REST endpoints with a focus on readability and IDE auto completion support.
"},{"location":"#features","title":"Features","text":"register_rest_route
We aim to support versions that haven't reached their end-of-life.
"},{"location":"#installation","title":"Installation","text":"composer require wp-fastendpoints
"},{"location":"#sponsors","title":"Sponsors","text":""},{"location":"release/","title":"Release Notes","text":""},{"location":"release/#v200","title":"v2.0.0","text":"Support for treating plugins as dependencies.
depends(['buddypress''])
- Only loads the BuddyPress plugin for this given endpoint$router->get('/users/(?P<ID>[\\d]+)', function ($ID) {\n return get_user_by('id', $ID);\n})\n->returns('Users/Get')\n->depends(['buddypress']); // Only BuddyPress plugin will be loaded for this REST endpoint
Warn
Make sure to run the following WP-CLI command after a successfull deployment: wp
Three new filters that allows us to customise our JSON schema validator's.
fastendpoints_validator
- Triggered by both middlewaresfastendpoints_schema_validator
- Only triggered for Schema middlewares validatorsfastendpoints_response_validator
- Only triggered for Response middlewares validatorsuse Opis\\JsonSchema\\Validator;\n\nadd_filter('fastendpoints_validator', function (Validator $validator): Validator {\n $formatsResolver = $validator->parser()->getFormatResolver();\n $formatsResolver->registerCallable('integer', 'even', function (int $value): bool {\n return $value % 2 === 0;\n });\n\n return $validator;\n});
Info
For more customisations check the following links: 1. Custom formats, 2. Custom filters, 3. Custom media types and, 4. Custom content encoding
"},{"location":"release/#v121","title":"v1.2.1","text":"Using JSON/opis schema loader and resolver which allows us to reference schemas inside other schemas.
// Now we also need to set a prefix while appending a directory. This prefix\n// will be used to reference schemas from inside another schema.\n$router->appendSchemaDir('/my-dir', 'http://www.example.com');
"},{"location":"release/#v120","title":"v1.2.0","text":"Dependency injection support in main handler, middlewares and permission handlers.
// In the past, the $request parameter was mandatory:\n$router->get('/posts/(?P<ID>[\\d]+)', function (WP_REST_Request $request) {\n return $request->get_param('ID');\n});\n\n// Now you only type what you need\n$router->get('/posts/(?P<ID>[\\d]+)', function ($ID) {\n return $ID;\n});\n// Middleware changes\nclass MyCustomMiddleware extends \\Wp\\FastEndpoints\\Contracts\\Middleware {\n public function onRequest(/* Type what you need e.g. $request */) {\n // Called before handling the request\n }\n public function onResponse(/* Type what you need e.g. $response, $request */) {\n // Called after the request being handled\n }\n}
"},{"location":"release/#v110","title":"v1.1.0","text":"// Middleware example\nclass MyCustomMiddleware extends \\Wp\\FastEndpoints\\Contracts\\Middleware {\n public function onRequest(\\WP_REST_Request $request): ?\\WP_Error {\n // Called before handling the request\n return null;\n }\n public function onResponse(\\WP_REST_Request $request, mixed $response): mixed {\n // Called after the request being handled\n return $response;\n }\n}
"},{"location":"release/#v100","title":"v1.0.0","text":"Initial release - don't use it!
"},{"location":"advanced-user-guide/","title":"Advanced User Guide","text":"The Quick Start should be able to get you a feel of the main features of WP-FastEndpoints.
However, it's possible that the solution for your use case might not be in the Quick Start tutorial. In the next sections we will take a look at further functionalities that WP-FastEndpoints provides.
"},{"location":"advanced-user-guide/dependency-injection/","title":"Dependency Injection","text":"Each REST endpoint has its unique logic. Same goes with the data that it needs to work.
For that reason, WP-FastEndpoints provides dependency injection support for all handlers e.g. permission handlers, main endpoint handler and middlewares.
With dependency injection our endpoints do look much cleaner \u2728\ud83e\uddf9
With dependency injectionNo dependency injection// We only need the ID. So we type $ID\n$router->get('/posts/(?P<ID>[\\d]+)', function ($ID) {\n return get_post($ID);\n});\n\n// We don't need anything. So no arguments are defined :D\n$router->get('/posts/random', function () {\n $allPosts = get_posts();\n return $allPosts ? $allPosts[array_rand($allPosts)] : new WpError(404, 'No posts found');\n});
// Unable to fetch a dynamic parameter. Have to work with the $request argument\n$router->get('/posts/(?P<ID>[\\d]+)', function ($request) {\n return get_post($request->get('ID'));\n});\n\n// Forced to accept $request even if not used :(\n$router->get('/posts/random', function ($request) {\n $allPosts = get_posts();\n return $allPosts ? $allPosts[array_rand($allPosts)] : new WpError(404, 'No posts found');\n});
"},{"location":"advanced-user-guide/middlewares/","title":"Middlewares","text":"Another cool feature of WP-FastEndpoints is the support for middlewares.
Middlewares are pieces of code that can either run before and/or after a request is handled.
At this stage, you might be already familiar with both the schema(...)
and returns(...)
middlewares. However, you can also create your own.
use Wp\\FastEndpoints\\Contracts\\Middleware;\n\nclass MyCustomMiddleware extends Middleware\n{\n /**\n * Create this function if you want that your middleware is\n * triggered when it receives a request and after checking\n * the user permissions.\n */\n public function onRequest(/* Type what you need */)\n {\n return;\n }\n\n /**\n * Create this function when you want your middleware to be\n * triggered before sending a response to the client \n */\n public function onResponse(/* Type what you need */) {\n return;\n }\n}\n\n// Attach middleware to endpoint\n$router->get('/test', function () {\n return true;\n})\n->middleware(new MyCustomMiddleware());
Tip You can create both methods in a middleware: onRequest
and onResponse
. However, to save some CPU cycles only create the one you need [CPU emoji]
If you need you can also take advantage of either WP_Error and WP_REST_Response to send a direct response to the client. See Responses page for more info
"},{"location":"advanced-user-guide/plugins-as-dependencies/","title":"Treat plugins as dependencies","text":"One of the main strengths of WordPress is the wide range of plugins available which allow us to fully customise a website in a short time period. However, every time a plugin is added it can negatively impact the performance of our API endpoints, because even though those endpoints might not need some of the activated plugins to work properly, they will still be loaded.
To address this issue WP-FastEndpoints Depends was created to enable us to treat plugins as REST endpoint dependencies.
"},{"location":"advanced-user-guide/plugins-as-dependencies/#adding-another-plugin","title":"Adding another plugin?? \ud83d\ude31","text":"Yes, this is a plugin! It could seem counterintuitive that adding another plugin could positively impact our API endpoints. However, given that in most cases our API endpoints don't need all the plugins that are active e.g. BuddyPress, Elementor it can actually improve your API endpoints.
"},{"location":"advanced-user-guide/plugins-as-dependencies/#how-it-works","title":"How it works?","text":"Given this plugin needs to be setup as a MU-plugin it will always run before any regular plugin which allow us to decide which plugins are necessary for a given REST endpoint before loading them.
"},{"location":"advanced-user-guide/plugins-as-dependencies/#how-to-use-it","title":"How to use it?","text":"Currently, we support both native WP endpoints and FastEndpoints \ud83d\ude0a
With FastEndpointsNative WP endpoints$router->get('/example/all-plugins', function () {\n return \"Loads all active plugins\";\n});\n\n$router->get('/example/buddypress', function () {\n return \"Only MyPlugin and BuddyPress plugins are loaded\"; \n})->depends(['my-plugin', 'buddypress']);
// Loads all active plugins\nregister_rest_route('native/v1', 'example/all-plugins', [\n 'methods' => 'GET',\n (...)\n]);\n\n// Only MyPlugin and BuddyPress plugins are loaded\nregister_rest_route('native/v1', 'example/buddypress', [\n 'methods' => 'GET',\n 'depends' => ['my-plugin', 'buddypress'],\n (...)\n]);
Tip By default, if no dependencies are specified in an endpoint it assumes that all active plugins needs to be loaded. This behaviour could be overridden for a given set of WP-FastEndpoint's by setting router dependencies e.g. $router->depends(['my-plugin'])
With WP-FastEndpoint's we are able to either define global endpoint dependencies via router dependencies or specific endpoint dependencies.
One common scenario where router dependencies might be useful is when we want to change the default behaviour of loading all active plugins per endpoint.
$router = new \\Wp\\FastEndpoints\\Router('my-api', 'v1');\n$router->depends(['my-plugin']); // All endpoints and sub-routers would have this dependency
Danger
When adding dependencies to endpoints, make sure to at least include the given plugin that holds those endpoints. For instance, if your endpoints reside inside a plugin with a slug my-plugin
you have to set the dependencies to ['my-plugin']
otherwise when a request is received for that endpoint my-plugin
will not be loaded.
Under the hood, this plugin generates a config file with all the route dependencies (see example). To have the most up-to-date endpoint dependencies, make sure to either:
wp fastendpoints depends
command or In WP-FastEndpoints an endpoint can have multiple optional handlers attached to:
hasCap(...)
or permission(...)
- Used to check for user permissionsschema(...)
- Validates the request payloadreturns(...)
- Makes sure that the proper response is sent to the clientmiddleware(...)
- Any other custom logic that you might want to runWhen a request is received the first handlers to run are the permissions handlers. Permission handlers are called by WordPress via permission_callback
.
In contrast to WordPress, you can have one or multiple permission handlers attached to the same endpoint.
NoteIn the background all permission handlers are wrapped into one callable which is later on used as permission_callback
by the endpoint
These handlers will then be called in the same order as they were attached. For instance:
$router->get('/test', function () {return true;})\n->hasCap('read') # Called first\n->hasCap('edit_posts') # Called second if the first one was successful\n->permission('__return_true') # Called last if both the first and second were successful
"},{"location":"advanced-user-guide/request-life-cycle/#middlewares","title":"Middlewares","text":"If all the permission handlers are successful the next set of handlers that run are the middlewares which implement the onRequest
function.
Remember that a middleware can implement onRequest
and/or onResponse
functions. The first one, runs before the main endpoint handler and the later one should run after the main endpoint handler.
Warning
Please bear in mind that if either a WP_Error or a WP_REST_Response is returned by the main endpoint handler following middlewares will not run. See Responses page for more info.
"},{"location":"advanced-user-guide/request-life-cycle/#onrequest","title":"onRequest","text":"Same as with the permission handlers, middlewares are called with the same order that they were attached.
class OnRequestMiddleware extends \\Wp\\FastEndpoints\\Contracts\\Middleware\n{\n public function onRequest(/* Type what you need */){\n return;\n }\n}\n\n$router->post('/test', function () {return true;})\n->middleware(OnRequestMiddleware()) # Called first\n->schema('Basics/Bool'); # Called second
"},{"location":"advanced-user-guide/request-life-cycle/#onresponse","title":"onResponse","text":"Likewise, middlewares implementing onResponse functions will be triggered in the same order as they were attached.
class OnResponseMiddleware extends \\Wp\\FastEndpoints\\Contracts\\Middleware\n{\n public function onResponse(/* Type what you need */){\n return;\n }\n}\n\n$router->post('/test', function () {return true;})\n->returns('Basics/Bool') # Called first\n->middleware(OnResponseMiddleware()); # Called second
"},{"location":"advanced-user-guide/responses/","title":"Responses","text":"When building an API sometimes we want to return a response directly to the client. For example:
$router->get('/posts/(?P<ID>[\\d]+)', function ($ID) {\n return get_post($ID);\n})\n->returns('Posts/Get'); // It will raise a 422 HTTP error when we are unable to find a post
The code above, will raise a 422 HTTP status code error when ever we are unable to find a given post. This is where returning a message directly to the client can be useful.
"},{"location":"advanced-user-guide/responses/#early-return","title":"Early return","text":"To trigger those scenarios we can either return a WP_Error or a WP_REST_Response.
WP_REST_ResponseWP_Error$router->get('/posts/(?P<ID>[\\d]+)', function ($ID) {\n $post = get_post($ID);\n return $post ?: new WP_REST_Response(\"No posts found\", 404);\n})\n->returns('Posts/Get'); // This will not be triggered if no posts are found
$router->get('/posts/(?P<ID>[\\d]+)', function ($ID) {\n $post = get_post($ID);\n return $post ?: new WpError(404, \"No posts found\");\n})\n->returns('Posts/Get'); // This will not be triggered if no posts are found
"},{"location":"advanced-user-guide/responses/#difference-between-returning-wp_rest_response-or-wp_error","title":"Difference between returning WP_REST_Response or WP_Error","text":"The main difference between returning a WP_Error or a WP_REST_Response is regarding the JSON returned in the body.
WP_REST_ResponseWP_Error\"No posts found\"
{\n \"error\": 404,\n \"message\": \"No posts found\",\n \"data\": {\n \"status\": 404\n }\n}
"},{"location":"advanced-user-guide/json-schemas/multiple-root-dirs/","title":"Multiple Root Dirs","text":"For most projects, all JSON schemas might be kept inside a single root directory, like:
my-plugin\n\u2502\n\u2514\u2500\u2500\u2500src\n\u2502 \u2502\n\u2502 \u2514\u2500\u2500\u2500Api\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Schemas // Root dir\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Posts\n\u2502 \u2502 \u2502 \u2502 (...)\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Users\n\u2502 \u2502 \u2502 (...)
However, when your API starts to grow you might end up having the need for multiple root directories.
"},{"location":"advanced-user-guide/json-schemas/multiple-root-dirs/#example","title":"Example","text":"Let's imagine that your API consists on two different versions: v1 and v2, like the following:
my-plugin\n\u2502\n\u2514\u2500\u2500\u2500src\n\u2502 \u2502\n\u2502 \u2514\u2500\u2500\u2500Api\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500v1\n\u2502 \u2502 \u2502 \u2514\u2500\u2500\u2500Schemas // V1 JSON schemas root dir\n\u2502 \u2502 \u2502 \u2502\n\u2502 \u2502 \u2502 \u2514\u2500\u2500\u2500Posts\n\u2502 \u2502 \u2502 \u2502 (...)\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500v2\n\u2502 \u2502 \u2514\u2500\u2500\u2500Schemas // V2 JSON schemas root dir\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Posts\n\u2502 \u2502 \u2502 (...)
In this case scenario your code would look something like this:
$router->appendSchemaDir(MY_PLUGIN_DIR.'Api/v1/Schemas', 'https://www.wp-fastendpoints.com/v1');\n$router->appendSchemaDir(MY_PLUGIN_DIR.'Api/v2/Schemas', 'https://www.wp-fastendpoints.com/v2');
Then in all your endpoints you will have to specify the full schema prefix. It's important that you specify the full prefix because we can't guarantee the order or even if the same schema directory is returned all the time.
Using v1 schemasUsing v2 schemas$router->get('/test', function(){return true;})\n->returns('https://www.wp-fastendpoints.com/v1/Posts/Get.json');
$router->get('/test', function(){return true;})\n->returns('https://www.wp-fastendpoints.com/v2/Posts/Get.json');
"},{"location":"advanced-user-guide/json-schemas/references/","title":"References","text":"References is another great feature from opis/json-schema.
With references, you are able to point to another JSON schema inside of schema. This can be useful to reuse the same schema multiple times.
Tip
Bear in mind that when referencing a schema the full prefix must be used e.g. https://www.wp-fastendpoints.com/Posts/Get.json
Take a look at their References Docs \u00bb for more information
"},{"location":"advanced-user-guide/json-schemas/validator/","title":"Validator","text":"WP-FastEndpoints uses opis/json-schema for JSON schema validation.
The reason we don't use the default WordPress JSON schema validation functionality is because it's quite outdated: it only partially supports JSON schema draft 4. opis/json-schema on the other side, does support the latest JSON schema drafts.
"},{"location":"advanced-user-guide/json-schemas/validator/#customising-validator","title":"Customising validator","text":"One of the coolest features of opis/json-schema is that is super flexible, and supports:
These, can be super useful when ever you need some custom functionality in your JSON schemas.
"},{"location":"advanced-user-guide/json-schemas/validator/#available-hooks","title":"Available hooks","text":"There are three WordPress filter hooks that you can use to customise the JSON schema validators used in WP-FastEndpoints:
fastendpoints_validator
- Triggered by both middlewaresfastendpoints_schema_validator
- Only triggered for Schema middlewares validatorsfastendpoints_response_validator
- Only triggered for Response middlewares validatorsImagine we only want to accept even numbers. To solve this issue, we might want to create a new custom format for integers, called even
, which checks if a given number is even, like:
use Opis\\JsonSchema\\Validator;\n\n/**\n * Adds custom format resolvers to all JSON validators: request payload schema and response.\n *\n * @see fastendpoints_schema_validator - To update only the request payload schema validator, or\n * @see fastendpoints_response_validator - To update only the response validator\n */\nadd_filter('fastendpoints_validator', function (Validator $validator): Validator {\n $formatsResolver = $validator->parser()->getFormatResolver();\n $formatsResolver->registerCallable('integer', 'even', function (int $value): bool {\n return $value % 2 === 0;\n });\n\n return $validator;\n});
Here is an example of a JSON schema using our custom even
format:
{\n \"type\": \"integer\",\n \"format\": \"even\"\n}
More examples can be found in Custom Formats docs \u00bb
"},{"location":"quick-start/","title":"Quick Start","text":"To better exemplify the benefits of using FastEndpoints we are going to build an API for manipulating blog posts.
This API will be able to:
Full source code can be found at matapatos/wp-fastendpoints-my-plugin \u00bb
"},{"location":"quick-start/#plugin-code-structure","title":"Plugin code structure \ud83d\udd28","text":"To hold this API we are going to create a plugin called MyPLugin - don't forget that logic shouldn't be contained in a theme - with the following structure:
my-plugin\n\u2502 my-plugin.php # Registers the plugin provider\n\u2502 composer.json\n\u2502\n\u2514\u2500\u2500\u2500src\n\u2502 \u2502 constants.php\n\u2502 \u2502\n\u2502 \u2514\u2500\u2500\u2500Api\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Routers\n\u2502 \u2502 \u2502 \u2502 Posts.php # Holds our custom endpoints\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Schemas\n\u2502 \u2502 \u2502\n\u2502 \u2502 \u2514\u2500\u2500\u2500Posts\n\u2502 \u2502 \u2502 CreateOrUpdate.json # Validates request payload\n\u2502 \u2502 \u2502 Get.json # Validates responses and discards unwanted fields\n\u2502 \u2502\n\u2502 \u2502\n\u2502 \u2514\u2500\u2500\u2500Providers\n\u2502 \u2502 ApiServiceProvider.php # Registers all routers\n\u2502 \u2502 MyPluginProvider.php # Bootstraps our plugin\n\u2502 \u2502 ProviderContract.php\n\u2502\n\u2514\u2500\u2500\u2500tests
"},{"location":"quick-start/json-schemas/","title":"JSON Schemas","text":"For this scenario, we are going to create two JSON schemas: 1) for validating the request payload and another 2) to discard unwanted fields from responses (e.g. sensitive information).
"},{"location":"quick-start/json-schemas/#request-payload-createupdate","title":"Request payload (create/update)","text":"For the payload we decided to accept the following fields: 1) post_title, 2) post_status, 3) post_type and 4) optionally post_content
{\n \"type\": \"object\",\n \"properties\": {\n \"post_title\": {\n \"type\": \"string\"\n },\n \"post_status\": {\n \"enum\": [\"publish\", \"draft\", \"private\"],\n \"default\": \"publish\"\n },\n \"post_type\": {\n \"const\": \"post\"\n },\n \"post_content\": {\n \"type\": \"string\",\n \"contentMediaType\": \"text/html\"\n }\n },\n \"required\": [\"post_title\", \"post_status\", \"post_type\"]\n}
"},{"location":"quick-start/json-schemas/#response-retrieve","title":"Response (retrieve)","text":"For the response we decided to only return the following fields: 1) post_title and 2) optionally post_excerpt
{\n \"type\": \"object\",\n \"properties\": {\n \"post_title\": {\n \"type\": \"string\"\n },\n \"post_excerpt\": {\n \"type\": \"string\",\n \"contentMediaType\": \"text/html\"\n }\n },\n \"required\": [\"post_title\"]\n}
"},{"location":"quick-start/router/","title":"Router","text":"The first thing we need to do is to create a Router.
use Wp\\FastEndpoints\\Router;\n\n// Dependency injection to enable us to mock router in tests\n$router = $router ?? new Router('posts');
A router is a class which allow us to attach and register endpoints.
An application can have one or multiple routers. One main benefit of using multiple routers is to group endpoints by same namespace and (optionally) same version. For instance, in this tutorial we are going to create a main router with a base namespace my-plugin
and a version of v1
which will add /my-plugin/v1/
to the beginning of each attached endpoint from all sub-routers.
With the posts router in place we can now start attaching our endpoints. We start adding the one responsible to create a new blog post.
$router->post('/', function (\\WP_REST_Request $request, \\WP_REST_Response $response): int|\\WP_Error {\n $response->set_status(201);\n $payload = $request->get_params();\n\n return wp_insert_post($payload, true);\n})\n ->schema('Posts/CreateOrUpdate')\n ->hasCap('publish_posts');
When a request is received by this endpoint the following happens:
Info
In this scenario we are not using a JSON schema to discard fields because the wp_insert_post either returns the ID of the post or a WP_Error which is already what we want \ud83d\ude0a
"},{"location":"quick-start/router/#retrieve-a-post","title":"Retrieve a post","text":"Some endpoints however do need to return more complex objects. And in those cases JSON schemas can be of a great help.
JSON schemas can help us to make sure that we are returning all the required fields as well as to avoid retrieving sensitive information. The last one is configurable.
use Wp\\FastEndpoints\\Helpers\\WpError;\n\n$router->get('(?P<ID>[\\d]+)', function ($ID) {\n $post = get_post($ID);\n\n return $post ?: new WpError(404, 'Post not found');\n})\n ->returns('Posts/Get')\n ->hasCap('read');
In this case, we didn't set a JSON schema on purpose because we only need the post_id which is already parsed by the regex rule - we could have made that rule to match only positive integers though \ud83e\udd14
Going back to the endpoint, this is what happens if a request comes in:
Note
The WpError is just a subclass of WP_Error which automatically set's the HTTP status code of the response
"},{"location":"quick-start/router/#update-a-post","title":"Update a post","text":"Checking for user capabilities such as publish_posts
and read
is cool. However, in the real world we sometimes also need to check for a particular resource.
$router->put('(?P<ID>[\\d]+)', function (\\WP_REST_Request $request): int|\\WP_Error {\n $payload = $request->get_params();\n\n return wp_update_post($payload, true);\n})\n ->schema('Posts/CreateOrUpdate')\n ->hasCap('edit_post', '<ID>');
The code above is not that different from the one for creating a post. However, in the last line hasCap('edit_post', '<ID>')
the second parameter is a special one for FastEndpoints which will try to replace it by the ID parameter.
Warning
FastEndpoints will only replace the {PARAM_NAME} if that parameter exists in the request payload. Otherwise, will not touch it. Also, bear in mind that the first stage in an endpoint is checking the user capabilities. As such, at that time the request params have not been already validated by the request payload schema.
"},{"location":"quick-start/router/#delete-a-post","title":"Delete a post","text":"use Wp\\FastEndpoints\\Helpers\\WpError;\n\n$router->delete('(?P<ID>[\\d]+)', function ($ID) {\n $post = wp_delete_post($postId);\n\n return $post ?: new WpError(500, 'Unable to delete post');\n})\n ->returns('Posts/Get')\n ->hasCap('delete_post', '<ID>');
"},{"location":"quick-start/router/#everything-together","title":"Everything together","text":"\"\"\"\nApi/Endpoints/Posts.php\n\"\"\"\ndeclare(strict_types=1);\n\nnamespace MyPlugin\\Api\\Routers;\n\nuse Wp\\FastEndpoints\\Helpers\\WpError;\nuse Wp\\FastEndpoints\\Router;\n\n// Dependency injection to enable us to mock router in the tests\n$router = $router ?? new Router('posts');\n\n// Creates a post\n$router->post('/', function (\\WP_REST_Request $request): int|\\WP_Error {\n $payload = $request->get_params();\n\n return wp_insert_post($payload, true);\n})\n ->schema('Posts/CreateOrUpdate')\n ->hasCap('publish_posts');\n\n// Fetches a single post\n$router->get('(?P<ID>[\\d]+)', function ($ID) {\n $post = get_post($ID);\n\n return $post ?: new WpError(404, 'Post not found');\n})\n ->returns('Posts/Get')\n ->hasCap('read');\n\n// Updates a post\n$router->put('(?P<ID>[\\d]+)', function (\\WP_REST_Request $request): int|\\WP_Error {\n $payload = $request->get_params();\n\n return wp_update_post($payload, true);\n})\n ->schema('Posts/CreateOrUpdate')\n ->hasCap('edit_post', '<ID>');\n\n// Deletes a post\n$router->delete('(?P<ID>[\\d]+)', function ($ID) {\n $post = wp_delete_post($postId);\n\n return $post ?: new WpError(500, 'Unable to delete post');\n})\n ->returns('Posts/Get')\n ->hasCap('delete_post', '<ID>');\n\n// IMPORTANT: If no service provider is used make sure to set a version to the $router and call\n// the following function here:\n// $router->register();\n\n// Used later on by the ApiProvider\nreturn $router;
"},{"location":"quick-start/service-provider/","title":"Service Provider","text":"Now that we have our posts router built the last main three bits missing are the following:
rest_api_init
hook for registering all the endpoints.\"\"\"\nsrc/Providers/ApiProvider.php\n\"\"\"\ndeclare(strict_types=1);\n\nnamespace MyPlugin\\Providers;\n\nuse Wp\\FastEndpoints\\Router;\n\nclass ApiProvider implements ProviderContract\n{\n protected Router $appRouter;\n\n public function register(): void\n {\n $this->appRouter = new Router('my-plugin', 'v1');\n $this->appRouter->appendSchemaDir(\\SCHEMAS_DIR, 'http://www.my-plugin.com');\n foreach (glob(\\ROUTERS_DIR.'/*.php') as $filename) {\n $router = require $filename;\n $this->appRouter->includeRouter($router);\n }\n $this->appRouter->register();\n }\n}
Tip
Adding the schema directory to the main router will share it across all sub-routers.
"},{"location":"quick-start/service-provider/#its-running","title":"It's running","text":"\ud83c\udf89 Congrats you just created your first set of REST FastEndpoints
Now let's see how to test it out! \ud83d\ude04
Full source code can be found at matapatos/wp-fastendpoints-my-plugin \u00bb
"},{"location":"quick-start/tests/","title":"Testing","text":"For testing our WP-FastEndpoints router we are going to use pest/php.
Pest is a testing framework that makes it super easy to test functionality in PHP, that's why we are going to use it here. However, if you have a preference for some other testing framework, the some principles should apply \ud83d\ude0a
Full source code can be found at matapatos/wp-fastendpoints-my-plugin \u00bb
"},{"location":"quick-start/tests/#testing-dependencies","title":"Testing dependencies","text":"First, let's add all the necessary testing dependencies:
composer require mockery/mockery --dev # For mocking classes/functions\ncomposer require dingo-d/wp-pest --dev # Adds Pest support for integration tests
"},{"location":"quick-start/tests/#testing-structure","title":"Testing structure","text":"For testing our plugin, we are going to assume the following structure:
my-plugin\n\u2502 my-plugin.php\n\u2502 composer.json\n\u2502\n\u2514\u2500\u2500\u2500src\n\u2502 (...)\n\u2502\n\u2514\u2500\u2500\u2500tests\n \u2502 bootstrap.php # Loads WordPress for integration tests\n \u2502 Helpers.php # (optional) Helper functions\n \u2502 Pest.php # Pest configuration file\n \u2502\n \u2514\u2500\u2500\u2500Integration\n \u2502 PostsApiTest.php\n \u2502\n \u2514\u2500\u2500\u2500Unit\n PostsApiTest.php
"},{"location":"quick-start/tests/integration/","title":"Integration Tests","text":"Integration tests, are a bit tricky to set up.
The following needs to happen in order to successfully run them:
rest_api_init
hookHowever, thanks to wp-pest most of this trouble is no longer an issue. By simply running the command bellow, it will automatically pull the WordPress version you want and also set up the tests directory for you.
./vendor/bin/wp-pest setup plugin --plugin-slug my-plugin --wp-version 6.4.4
Tip
If you use matapatos/wp-fastendpoints-my-plugin you can use the already configured composer setup:wp:6.x
commands
If you take a closer look at the resultant tests structure you might notice that is slightly different from matapatos/wp-fastendpoints-my-plugin. These changes are not mandatory and so, feel free to skip this section \u23e9
The main reason of these differences is to allow us to run tests without the need to always specify a group of tests. Those changes include:
/**\n* tests/Helpers.php\n */ \nnamespace MyPlugin\\Tests;\n\nclass Helpers\n{\n /**\n * Checks if weather we want to run integration tests or not\n */\n public static function isIntegrationTest(): bool\n {\n return isset($GLOBALS['argv']) && in_array('--group=integration', $GLOBALS['argv'], true);\n }\n}
/**\n* tests/Integration/*Test.php\n */ \nnamespace MyPlugin\\Tests\\Integration;\n\nuse MyPlugin\\Tests\\Helpers;\n\n// Needs to add this check to every Integration test file\nif (! Helpers::isIntegrationTest()) {\n return;\n}
"},{"location":"quick-start/tests/integration/#our-integration-test","title":"Our integration test \ud83d\ude43","text":"Now that everything is configured we can start creating integration tests:
test('Create a new post', function () {\n // Create user with correct permissions\n $userId = $this::factory()->user->create();\n $user = get_user_by('id', $userId);\n $user->add_cap('publish_posts');\n // Make request as that user\n wp_set_current_user($userId);\n $request = new \\WP_REST_Request('POST', '/my-plugin/v1/posts');\n $request->set_body_params([\n 'post_title' => 'My testing message',\n 'post_status' => 'publish',\n 'post_type' => 'post',\n 'post_content' => '<p>Message body</p>',\n ]);\n $response = $this->server->dispatch($request);\n expect($response->get_status())->toBe(201);\n $postId = $response->get_data();\n // Check that the post details are correct\n expect(get_post($postId))\n ->toBeInstanceOf(\\WP_Post::class)\n ->toHaveProperty('post_title', 'My testing message')\n ->toHaveProperty('post_status', 'publish')\n ->toHaveProperty('post_type', 'post')\n ->toHaveProperty('post_content', '<p>Message body</p>');\n})->group('api', 'posts');
Here, we take advantage of the existent testing factories to create a single user with the necessary capability to publish posts. Then, we make mimic a REST request from that given user, and lastly, we check if that blog post was created.
"},{"location":"quick-start/tests/unit/","title":"Unit Tests","text":"As an example of a unit test, we are going add a test to check the 1) request payload schema used and 2) the necessary user permissions on the endpoint that allows a user to create a new blog post.
We could have separated each assertion in its own unit test but for the sake of simplicity we are going to make both of them in the same test.
test('Creating post endpoint has correct permissions and schema', function () {\n // Create endpoint mock\n $endpoint = Mockery::mock(Endpoint::class);\n // Assert that the request payload schema passed is correct\n $endpoint\n ->shouldReceive('schema')\n ->once()\n ->with('Posts/CreateOrUpdate')\n ->andReturnSelf();\n // Assert that user permissions are correct\n $endpoint\n ->shouldReceive('hasCap')\n ->once()\n ->with('publish_posts');\n // To ignore all the other endpoints\n $ignoreEndpoint = Mockery::mock(Endpoint::class)\n ->shouldIgnoreMissing(Mockery::self());\n // Create router. Make sure that var name matches your router variable\n $router = Mockery::mock(Router::class)\n ->shouldIgnoreMissing($ignoreEndpoint);\n // Assert that router endpoint is called\n $router\n ->shouldReceive('post')\n ->once()\n ->with('/', Mockery::type('callable'))\n ->andReturn($endpoint);\n // Needed to attach endpoints\n require \\ROUTERS_DIR.'/Posts.php';\n})->group('api', 'posts');
The reason we are able to make the assertions above is due to this line. Specially, regarding this part $router ??
. This allows us to replace our original router with our mocked version.
Nothing magical happening here, just pure PHP code! \ud83e\ude84
"}]} \ No newline at end of file