diff --git a/docs/data/sponsors.yml b/docs/data/sponsors.yml new file mode 100644 index 0000000..eda4759 --- /dev/null +++ b/docs/data/sponsors.yml @@ -0,0 +1,4 @@ +companies: + - url: https://www.myspanishnow.com/ + title: "One-to-one online Spanish lessons to connect with your family and friends." + img: https://avatars.githubusercontent.com/u/128193617?s=400&u=47f022a3fad60ddade1add885f56eb9ceb830145&v=4 diff --git a/docs/docs/index.md b/docs/docs/index.md new file mode 100644 index 0000000..7d27015 --- /dev/null +++ b/docs/docs/index.md @@ -0,0 +1,50 @@ +--- +hide: + - navigation +--- + +WordPress REST endpoints made easy +

+ GitHub Actions Workflow Status (main) + Code Coverage + Latest Version + Supported WordPress Versions + Software License +

+ +------ +**FastEndpoints** is an elegant way of writing custom WordPress REST endpoints with a focus on readability and IDE auto completion support. + +## Features + +- Decouples request validation from main logic +- Removes unwanted fields from responses +- Middlewares support +- IDE auto completion support +- No magic router. It uses WordPress [`register_rest_route`](https://developer.wordpress.org/reference/functions/register_rest_route/) +- Support for newer JSON schema drafts thanks to [json/opis](https://opis.io/json-schema/2.x/) + +## Requirements + +- PHP 8.1+ +- WordPress 6.x +- [opis/json-schema](https://opis.io/json-schema/2.x/) +- [php-di/invoker](https://packagist.org/packages/php-di/invoker) + +We aim to support versions that haven't reached their end-of-life. + +## Installation + +```bash +composer require wp-fastendpoints +``` + +## Sponsors + +{% if sponsors %} +{% for sponsor in sponsors.companies -%} + +{% endfor -%} +{% endif %} + + diff --git a/docs/docs/quick-start/index.md b/docs/docs/quick-start/index.md new file mode 100644 index 0000000..3e90a30 --- /dev/null +++ b/docs/docs/quick-start/index.md @@ -0,0 +1,44 @@ +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: + +* Create +* Retrieve +* Update and +* Delete a blog post + +Full source code can be found at **[matapatos/wp-fastendpoints-my-plugin Β»](https://github.com/matapatos/wp-fastendpoints-my-plugin)** + +## Plugin code structure πŸ”¨ + +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 +β”‚ my-plugin.php # Registers the plugin provider +β”‚ composer.json +β”‚ +└───src +β”‚ β”‚ constants.php +β”‚ β”‚ +β”‚ └───Api +β”‚ β”‚ β”‚ +β”‚ β”‚ └───Routers +β”‚ β”‚ β”‚ β”‚ Posts.php # Holds our custom endpoints +β”‚ β”‚ β”‚ +β”‚ β”‚ └───Schemas +β”‚ β”‚ β”‚ +β”‚ β”‚ └───Posts +β”‚ β”‚ β”‚ CreateOrUpdate.json # Validates our request payload +β”‚ β”‚ β”‚ Get.json # Discards unwanted fields from our response +β”‚ β”‚ +β”‚ β”‚ +β”‚ └───Providers +β”‚ β”‚ ApiServiceProvider.php # Registers all routers +β”‚ β”‚ MyPluginProvider.php # Bootstraps our plugin +β”‚ β”‚ ProviderContract.php +β”‚ +└───tests +``` + diff --git a/docs/docs/quick-start/json-schemas.md b/docs/docs/quick-start/json-schemas.md new file mode 100644 index 0000000..4477194 --- /dev/null +++ b/docs/docs/quick-start/json-schemas.md @@ -0,0 +1,48 @@ +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). + +### Request payload (create/update) + +For the payload we decided to accept the following fields: 1) _post_title_, 2) _post_status_, 3) _post_type_ and 4) optionally _post_content_ + +```json +{ + "type": "object", + "properties": { + "post_title": { + "type": "string" + }, + "post_status": { + "enum": ["publish", "draft", "private"], + "default": "publish" + }, + "post_type": { + "const": "post" + }, + "post_content": { + "type": "string", + "contentMediaType": "text/html" + } + }, + "required": ["post_title", "post_status", "post_type"] +} +``` + +### Response (retrieve) + +For the response we decided to only return the following fields: 1) _post_title_ and 2) optionally _post_excerpt_ + +```json +{ + "type": "object", + "properties": { + "post_title": { + "type": "string" + }, + "post_excerpt": { + "type": "string", + "contentMediaType": "text/html" + } + }, + "required": ["post_title"] +} +``` diff --git a/docs/docs/quick-start/router.md b/docs/docs/quick-start/router.md new file mode 100644 index 0000000..9411e91 --- /dev/null +++ b/docs/docs/quick-start/router.md @@ -0,0 +1,184 @@ +The first thing we need to do is to create a Router. + +```php +post('/', function (\WP_REST_Request $request): int|\WP_Error { + $payload = $request->get_params(); + + return wp_insert_post($payload, true); +}) + ->schema('Posts/CreateOrUpdate') + ->hasCap('publish_posts'); +``` + +When a request is received by this endpoint the following happens: + +1) Firstly, the user permissions are checked - Makes sure that the user has [*publish_posts*](https://wordpress.org/documentation/article/roles-and-capabilities/#publish_posts) capability +2) Then, if successful, it validates the request payload by using the *Posts/CreateOrUpdate* schema. + We still didn't specify where the endpoints should look for the schemas, but don't worry we are getting into that in a moment +3) Lastly, if the validation process also passes the handler is called. + +!!! info + In this scenario we are not using a JSON schema to discard fields because the [_wp_insert_post_](https://developer.wordpress.org/reference/functions/wp_insert_post/) + either returns the ID of the post or a WP_Error which is already what we want 😊 + +#### Retrieve a post + +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. + +```php +get('(?P[\d]+)', function ($ID) { + $post = get_post($ID); + + return $post ?: new WpError(404, 'Post not found'); +}) + ->returns('Posts/Get') + ->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 πŸ€” + +Going back to the endpoint, this is what happens if a request comes in: + +1) Firstly, it checks the user has [_read_](https://wordpress.org/documentation/article/roles-and-capabilities/#read) + capability - one of the lowest WordPress users capabilities +2) If so, it then calls the handler which either retrieves the post data (e.g. array or object) + or a [_WpError_](https://github.com/matapatos/wp-fastendpoints/blob/main/src/Helpers/WpError.php) + in case that is not found. If a WpError or WP_Error is returned it stops further code execution + and returns that error message to the client - avoiding triggering response schema validation for example. +3) Lastly, if the post data is returned by the handler the response schema will be triggered + and will check the response according to the given schema (e.g. _Posts/Get_) + +!!! note + The [WpError](https://github.com/matapatos/wp-fastendpoints/blob/main/src/Helpers/WpError.php) + is just a subclass of WP_Error which automatically set's the HTTP status code of the response + +#### Update a post + +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. + +```php +put('(?P[\d]+)', function (\WP_REST_Request $request): int|\WP_Error { + $payload = $request->get_params(); + + return wp_update_post($payload, true); +}) + ->schema('Posts/CreateOrUpdate') + ->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', '{post_id}')` the second parameter is a special one for FastEndpoints +which will try to replace it by the _post_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. + +#### Delete a post + +```php +delete('(?P[\d]+)', function ($ID) { + $post = wp_delete_post($postId); + + return $post ?: new WpError(500, 'Unable to delete post'); +}) + ->returns('Posts/Get') + ->hasCap('delete_post', '{ID}'); +``` + +### Everything together + +```php +post('/', function (\WP_REST_Request $request): int|\WP_Error { + $payload = $request->get_params(); + + return wp_insert_post($payload, true); +}) + ->schema('Posts/CreateOrUpdate') + ->hasCap('publish_posts'); + +// Fetches a single post +$router->get('(?P[\d]+)', function ($ID) { + $post = get_post($ID); + + return $post ?: new WpError(404, 'Post not found'); +}) + ->returns('Posts/Get') + ->hasCap('read'); + +// Updates a post +$router->put('(?P[\d]+)', function (\WP_REST_Request $request): int|\WP_Error { + $payload = $request->get_params(); + + return wp_update_post($payload, true); +}) + ->schema('Posts/CreateOrUpdate') + ->hasCap('edit_post', '{ID}'); + +// Deletes a post +$router->delete('(?P[\d]+)', function ($ID) { + $post = wp_delete_post($postId); + + return $post ?: new WpError(500, 'Unable to delete post'); +}) + ->returns('Posts/Get') + ->hasCap('delete_post', '{ID}'); + +// IMPORTANT: If no service provider is used make sure to set a version to the $router and call +// the following function here: +// $router->register(); + +// Used later on by the ApiProvider +return $router; +``` \ No newline at end of file diff --git a/docs/docs/quick-start/service-provider.md b/docs/docs/quick-start/service-provider.md new file mode 100644 index 0000000..63143b5 --- /dev/null +++ b/docs/docs/quick-start/service-provider.md @@ -0,0 +1,46 @@ +Now that we have our posts router built the last main three bits missing are the following: + +1) create a main router to hold all sub-routers (e.g. posts router) +2) specifying where to look for the JSON schemas (one or multiple directories) and +3) lastly register the router. This is what adds the `rest_api_init` hook for registering all + the endpoints. + +```php +appRouter = new Router('my-plugin', 'v1'); + $this->appRouter->appendSchemaDir(\SCHEMAS_DIR, 'http://www.my-plugin.com'); + foreach (glob(\ROUTERS_DIR.'/*.php') as $filename) { + $router = require $filename; + $this->appRouter->includeRouter($router); + } + $this->appRouter->register(); + } +} +``` + +!!! tip + Adding the schema directory to the main router will share it across + all sub-routers. + +## It's running + +πŸŽ‰ Congrats you just created your first set of REST FastEndpoints + +Now let's see [how to test it out](https://github.com/matapatos/wp-fastendpoints/wiki/Testing)! πŸ˜„ + +Full source code can be found at **[matapatos/wp-fastendpoints-my-plugin Β»](https://github.com/matapatos/wp-fastendpoints-my-plugin)** \ No newline at end of file diff --git a/docs/docs/quick-start/tests/index.md b/docs/docs/quick-start/tests/index.md new file mode 100644 index 0000000..70bcf18 --- /dev/null +++ b/docs/docs/quick-start/tests/index.md @@ -0,0 +1,40 @@ +For testing our **WP-FastEndpoints** router we are going to use [pest/php](https://pestphp.com/). + +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 😊 + +Full source code can be found at [**matapatos/wp-fastendpoints-my-plugin Β»**](https://github.com/matapatos/wp-fastendpoints-my-plugin) + +## Testing dependencies + +First, let's add all the necessary testing dependencies: + +```bash +composer require mockery/mockery --dev # For mocking classes/functions +composer require dingo-d/wp-pest --dev # Adds Pest support for integration tests +``` + +## Testing structure + +For testing our plugin, we are going to assume the following structure: + +``` +my-plugin +β”‚ my-plugin.php +β”‚ composer.json +β”‚ +└───src +β”‚ (...) +β”‚ +└───tests + β”‚ bootstrap.php # Loads WordPress for integration tests + β”‚ Helpers.php # (optional) Helper functions + β”‚ Pest.php # Pest configuration file + β”‚ + └───Integration + β”‚ PostsApiTest.php + β”‚ + └───Unit + PostsApiTest.php +``` diff --git a/docs/docs/quick-start/tests/integration.md b/docs/docs/quick-start/tests/integration.md new file mode 100644 index 0000000..b06df79 --- /dev/null +++ b/docs/docs/quick-start/tests/integration.md @@ -0,0 +1,104 @@ +Integration tests, are a bit tricky to set up. + +The following needs to happen in order to successfully run them: + +1) Load WordPress +2) Replace the default _TestCase_ class with one with enhanced WordPress functionalities + (e.g. to easily create users or posts) +3) Create the REST server and boot it using the [`rest_api_init`](https://developer.wordpress.org/reference/hooks/rest_api_init/) + hook + +### _wp-pest_ to the rescue 🦸 + +However, thanks to [wp-pest](https://github.com/dingo-d/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. + +```bash +./vendor/bin/wp-pest setup plugin --plugin-slug my-plugin --wp-version 6.4.4 +``` + +!!! tip + If you use [matapatos/wp-fastendpoints-my-plugin](https://github.com/matapatos/wp-fastendpoints-my-plugin?tab=readme-ov-file#setup-wordpress) + you can use the already configured `composer setup:wp:6.x` commands + +#### Optional changes + +If you take a closer look at the resultant tests structure you might notice that is slightly +different from the one previously mentioned. These changes are not mandatory and so, feel free +to skip this section ⏩ + +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: + +```php +user->create(); + $user = get_user_by('id', $userId); + $user->add_cap('publish_posts'); + // Make request as that user + wp_set_current_user($userId); + $request = new \WP_REST_Request('POST', '/my-plugin/v1/posts'); + $request->set_body_params([ + 'post_title' => 'My testing message', + 'post_status' => 'publish', + 'post_type' => 'post', + 'post_content' => '

Message body

', + ]); + $response = $this->server->dispatch($request); + expect($response->get_status())->toBe(200); + $postId = $response->get_data(); + // Check that the post details are correct + expect(get_post($postId)) + ->toBeInstanceOf(\WP_Post::class) + ->toHaveProperty('post_title', 'My testing message') + ->toHaveProperty('post_status', 'publish') + ->toHaveProperty('post_type', 'post') + ->toHaveProperty('post_content', '

Message body

'); +})->group('api', 'posts'); +``` + +Here, we take advantage of the existent [testing factories](https://make.wordpress.org/core/handbook/testing/automated-testing/writing-phpunit-tests/#fixtures-and-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. diff --git a/docs/docs/quick-start/tests/unit.md b/docs/docs/quick-start/tests/unit.md new file mode 100644 index 0000000..376fcad --- /dev/null +++ b/docs/docs/quick-start/tests/unit.md @@ -0,0 +1,41 @@ +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. + +```php +shouldReceive('schema') + ->once() + ->with('Posts/CreateOrUpdate') + ->andReturnSelf(); + // Assert that user permissions are correct + $endpoint + ->shouldReceive('hasCap') + ->once() + ->with('publish_posts'); + // Create router. Make sure that var name matches your router variable + $router = Mockery::mock(Router::class) + ->makePartial(); + // Assert that router endpoint is called + $router + ->shouldReceive('post') + ->once() + ->with('/', Mockery::type('callable')) + ->andReturn($endpoint); + // Needed to attach endpoints + require \ROUTERS_DIR.'/Posts.php'; +})->group('api', 'posts'); +``` + +The reason we are able to make the assertions above is +[due to this line](https://github.com/matapatos/wp-fastendpoints/wiki/Quick-start#the-actual-code---srcapirouterspostsphp). +Specially, regarding this part ```$router ??```. This allows us to replace our original router with our mocked version. + +As you might notice, this is just pure PHP code nothing magical! πŸͺ„ diff --git a/docs/docs/reference/index.md b/docs/docs/reference/index.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/docs/release.md b/docs/docs/release.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml new file mode 100644 index 0000000..36ad49a --- /dev/null +++ b/docs/mkdocs.yml @@ -0,0 +1,61 @@ +site_name: WP-FastEndpoints +site_description: FastEndpoints is an elegant way of writing custom WordPress REST endpoints with a focus on readability and IDE auto completion support +site_url: https://matapatos.github.io/wp-fastendpoints/ +watch: + - data + - docs + - images +theme: + name: material + palette: + primary: indigo + features: + - search.suggest + - search.highlight + - content.tabs.link + - navigation.indexes + - content.tooltips + - navigation.path + - content.code.annotate + - content.code.copy + - content.code.select + - navigation.tabs + - navigation.footer + icon: + repo: fontawesome/brands/github-alt + language: en +repo_name: matapatos/wp-fastendpoints +repo_url: https://github.com/matapatos/wp-fastendpoints +edit_uri: https://github.com/matapatos/wp-fastendpoints/tree/main/docs/docs +plugins: + search: null + macros: + include_yaml: + - sponsors: data/sponsors.yml +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.highlight: + anchor_linenums: true + pygments_lang_class: true +nav: + - WP-FastEndpoints: index.md + - Quick Start: + - Quick Start: quick-start/index.md + - JSON Schemas: quick-start/json-schemas.md + - Router: quick-start/router.md + - Service Provider: quick-start/service-provider.md + - Testing: + - Testing: quick-start/tests/index.md + - Integration Tests: quick-start/tests/integration.md + - Unit Tests: quick-start/tests/unit.md + - Reference: reference/index.md + - Release Notes: release.md +extra: + social: + - icon: fontawesome/brands/github-alt + link: https://github.com/matapatos/wp-fastendpoints + - icon: fontawesome/brands/linkedin + link: https://www.linkedin.com/in/andre-gil/