| By Antonius Hegyes on December 17, 2024 | One of the areas where WordPress shines is content management. This is also corroborated by the fact that it is the world’s leading Content Management System, or CMS, by numbers. The most popular way of displaying the content hosted by WordPress is through a frontend, like through blog posts and pages. That is wonderful for human visitors but there are other ways of consuming that content –– for example, web APIs. In this article, we’ll explore how we can leverage WordPress in order to power a central API for projects like phone apps, browser extensions, or the frontends of other WordPress sites! Following along All the steps described in this article were made in WordPress Playground. If you want to see the end result and maybe sometimes skip ahead as we go, download this ZIP file, and perform these steps: Visit https://playground.wordpress.net/ Restore the site from the ZIP file Log in into the admin area What is a web API? API stands for Application Programming Interface and it’s a way for software applications to communicate with each other in a standardized fashion. A web API is simply one that is accessed through “the internet” –– for example, by entering a certain URL in your web browser. There are multiple types of web APIs, and one common way to group them is the protocol they use. In this article, we’ll be implementing two APIs, one based on the REST protocol, and another based on the GraphQL protocol. Other protocols you might have heard of include SOAP, RPC, or gRPC. WordPress actually includes a built-in REST API which powers the Gutenberg Block Editor. As of October 2024, the popular WPGraphQL plugin has become a canonical plugin paving the road for an official GraphQL API as well. What data we’ll be modeling By the end of this article, we’ll have built a WordPress site that allows users to login in order to add/update/delete data entries which will be queryable both via REST routes and a GraphQL endpoint. The data entries will collectively represent a company’s organizational chart –– things like employees, teams, and offices. While a little bland, the concepts can be applied to absolutely anything else. Optional: Trimming down the frontend While an optional step, it makes a lot of sense to do this if your site won’t be serving any content via pages but exclusively through APIs. Once you have a hosted WordPress website, you can start by installing a minimalist WordPress theme like Blank Canvas and deleting every single demo post and page on your site. Continue by using the site editor to include information on the homepage for visitors who find it unintentionally. For example, add your business’ name and logo, and tell them that they probably landed there in error. You can also include a button linking to the admin area for maintainers of the content. Something along the lines of: One way to prevent your site from being found in search engine results is by checking the Discourage search engines from indexing this site in your site’s settings. If you would rather fully lock down the frontend and not even have the homepage described above, you can add the following code snippet either to a plugin like Code Snippets or to your child theme’s functions.php file: /** * Disables the frontend for non-logged-in users. */ add_action( 'template_redirect', static function (): void { $authorization_required_code = \WP_Http::UNAUTHORIZED; // 401 if ( ! is_user_logged_in() ) { \status_header( $authorization_required_code ); die( \get_status_header_desc( $authorization_required_code ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } } Custom post types and taxonomies Now it’s time to focus on the website’s admin area and the data modeling part of this tutorial. The most straightforward way of compartmentalizing your data is by using WordPress’ built-in functionality of custom post types and custom taxonomies. While there are many ways to do this, for the purposes of this tutorial, we’ll organize our data like this: An employee custom post type A team custom taxonomy An office custom post type In order to create these custom data types, you can either add custom code to your site, or use a plugin (like in this video). A very popular plugin for creating custom post types and taxonomies using the admin interface is Custom Post Type UI – and that is what we’ll be using in this tutorial. Here is the JSON configuration for importing the data into your installation: For custom post types {"employee":{"name":"employee","label":"Employees","singular_label":"Employee","description":"","public":"false","publicly_queryable":"false","show_ui":"true","show_in_nav_menus":"false","delete_with_user":"false","show_in_rest":"false","rest_base":"","rest_controller_class":"","rest_namespace":"","has_archive":"false","has_archive_string":"","exclude_from_search":"true","capability_type":"post","hierarchical":"false","can_export":"true","rewrite":"false","rewrite_slug":"","rewrite_withfront":"true","query_var":"false","query_var_slug":"","menu_position":"","show_in_menu":"true","show_in_menu_string":"","menu_icon":"dashicons-id","register_meta_box_cb":null,"supports":["title","thumbnail","excerpt","revisions"],"taxonomies":[],"labels":{"menu_name":"Employees","all_items":"All Employees","add_new":"Add new","add_new_item":"Add new Employee","edit_item":"Edit Employee","new_item":"New Employee","view_item":"View Employee","view_items":"View Employees","search_items":"Search Employees","not_found":"No Employees found","not_found_in_trash":"No Employees found in trash","parent":"Parent Employee:","featured_image":"Profile image for this Employee","set_featured_image":"Set profile image for this Employee","remove_featured_image":"Remove profile image for this Employee","use_featured_image":"Use as profile image for this Employee","archives":"Employee archives","insert_into_item":"Insert into Employee","uploaded_to_this_item":"Upload to this Employee","filter_items_list":"Filter Employees list","items_list_navigation":"Employees list navigation","items_list":"Employees list","attributes":"Employees attributes","name_admin_bar":"Employee","item_published":"Employee published","item_published_privately":"Employee published privately.","item_reverted_to_draft":"Employee reverted to draft.","item_trashed":"Employee trashed.","item_scheduled":"Employee scheduled","item_updated":"Employee updated.","parent_item_colon":"Parent Employee:"},"custom_supports":"","enter_title_here":"First and Last Names","show_in_graphql":"1","graphql_single_name":"Employee","graphql_plural_name":"Employees"},"office":{"name":"office","label":"Offices","singular_label":"Office","description":"","public":"false","publicly_queryable":"false","show_ui":"true","show_in_nav_menus":"false","delete_with_user":"false","show_in_rest":"false","rest_base":"","rest_controller_class":"","rest_namespace":"","has_archive":"false","has_archive_string":"","exclude_from_search":"true","capability_type":"post","hierarchical":"false","can_export":"false","rewrite":"false","rewrite_slug":"","rewrite_withfront":"true","query_var":"true","query_var_slug":"","menu_position":"","show_in_menu":"true","show_in_menu_string":"","menu_icon":"dashicons-admin-home","register_meta_box_cb":null,"supports":["title","thumbnail","revisions"],"taxonomies":[],"labels":{"menu_name":"Offices","all_items":"All Offices","add_new":"Add new","add_new_item":"Add new Office","edit_item":"Edit Office","new_item":"New Office","view_item":"View Office","view_items":"View Offices","search_items":"Search Offices","not_found":"No Offices found","not_found_in_trash":"No Offices found in trash","parent":"Parent Office:","featured_image":"Featured image for this Office","set_featured_image":"Set featured image for this Office","remove_featured_image":"Remove featured image for this Office","use_featured_image":"Use as featured image for this Office","archives":"Office archives","insert_into_item":"Insert into Office","uploaded_to_this_item":"Upload to this Office","filter_items_list":"Filter Offices list","items_list_navigation":"Offices list navigation","items_list":"Offices list","attributes":"Offices attributes","name_admin_bar":"Office","item_published":"Office published","item_published_privately":"Office published privately.","item_reverted_to_draft":"Office reverted to draft.","item_trashed":"Office trashed.","item_scheduled":"Office scheduled","item_updated":"Office updated.","parent_item_colon":"Parent Office:"},"custom_supports":"","enter_title_here":"Add Office","show_in_graphql":"1","graphql_single_name":"Office","graphql_plural_name":"Offices"}} For custom taxonomies {"team":{"name":"team","label":"Teams","singular_label":"Team","description":"","public":"false","publicly_queryable":"false","hierarchical":"false","show_ui":"true","show_in_menu":"true","show_in_nav_menus":"false","query_var":"false","query_var_slug":"","rewrite":"false","rewrite_slug":"","rewrite_withfront":"0","rewrite_hierarchical":"0","show_admin_column":"true","show_in_rest":"false","show_tagcloud":"false","sort":"false","show_in_quick_edit":"","rest_base":"","rest_controller_class":"","rest_namespace":"","labels":{"menu_name":"Teams","all_items":"All Teams","edit_item":"Edit Team","view_item":"View Team","update_item":"Update Team name","add_new_item":"Add new Team","new_item_name":"New Team name","parent_item":"Parent Team","parent_item_colon":"Parent Team:","search_items":"Search Teams","popular_items":"Popular Teams","separate_items_with_commas":"Separate Teams with commas","add_or_remove_items":"Add or remove Teams","choose_from_most_used":"Choose from the most used Teams","not_found":"No Teams found","no_terms":"No Teams","items_list_navigation":"Teams list navigation","items_list":"Teams list","back_to_items":"Back to Teams","name_field_description":"The name is how it appears on your site.","parent_field_description":"Assign a parent term to create a hierarchy. The term Jazz, for example, would be the parent of Bebop and Big Band.","slug_field_description":"The slug is the URL-friendly version of the name. It is usually all lowercase and contains only letters, numbers, and hyphens.","desc_field_description":"The description is not prominent by default; however, some themes may show it."},"meta_box_cb":"","default_term":"","object_types":["employee"],"show_in_graphql":"1","graphql_single_name":"Team","graphql_plural_name":"Teams"}} At this point, your WordPress admin interface might look something like this: To Gutenberg or not to Gutenberg The Gutenberg block editor is functional, adaptable, and easy to use, and you should be using it to edit your traditional WordPress posts and pages. However, when it comes to CPTs without a frontend, there might not be any content to warrant the use of a performant editor like Gutenberg. If you are positive that all of the information you need is not HTML-based, then it might make sense to disable Gutenberg for these CPTs and default back to the classic post editor that was the standard before WordPress 5.0. The simplest way to disable Gutenberg support for a CPT is to set the show_in_rest argument to false when registering it (as we’ve done above). Alternatively, if you want to keep the built-in REST routes that WordPress provides for every CPT, you can add this code to your child theme: /** * Disables the block editor for certain CPTs. */ add_filter( 'use_block_editor_for_post_type', static function( bool $use_block_editor, string $post_type ): bool { if ( in_array( $post_type, array( 'employee', 'office' ), true ) ) { $use_block_editor = false; } return $use_block_editor; }, 10, 2 ); Custom Fields Now that we have our basic data types in place, we need to start populating them with entries. Before we do that, we need to ensure that we can record all the necessary data on each entry, and for that we will need to build custom fields. The easiest way to add custom fields to your custom post types is to register them with custom-fields support. When you then edit a post, it will include a metabox like this: While this type of “key-value” interface can be enough, you might want to build a more user-friendly interface with fields like checkboxes, dropdowns, media selectors, and so on. A popular way to add those types of custom fields is the Meta Box plugin, which, as mentioned above, is what we’ll be using in this tutorial. Using their online custom fields generator, we got the PHP code needed to register the fields we wanted and then added them to Code Snippets. Using a fake data generator, we populated the custom post types with a bit of seed data: Other UI customizations While we won’t explore any further UI customization options in this tutorial, we wanted to note that it’s possible to use various WordPress filters to tweak things like: The default Add title placeholder on new posts (e.g., to First and Last Names) The columns hidden or visible by default on the CPT list table view Various other labels and messages throughout the admin interface Access control Before we start looking into making the data available via API, it’s time to think about who should have access to it. The custom post types and taxonomies mentioned above were registered in such a way that any logged-in user with the ability to edit regular WordPress blog posts will also have the ability to edit these. However, it’s possible to make that much more granular. You can create custom user roles with custom capabilities in order to ensure that the UI is as clean-as-possible in order to promote focused-work for the users doing the data maintenance. This is particularly important if you anticipate a very high number of entries, especially on an ongoing basis. While it is possible to control this entirely with custom code, a way to maintain a simpler overview of access management is provided by Access Policies implemented by the Advanced Access Manager plugin. For example, you can create a separate access policy for each CPT you create. Then you may assign the policy either to a role or to individual users in order to maintain full control over who may add new Employee entries or even just edit existing ones. Deleting entries can be a capability reserved only for administrators. Here is an example of how a policy named Employees CPT – Full Control and assigned only to Administrator users can look like: { "Version": "1.0.0", "Dependency": { "wordpress": ">=6.6.2", "advanced-access-manager": ">=6.9.42" }, "Statement": [ { "Effect": "allow", "Resource": [ "Capability:edit_employees", "Capability:edit_others_employees", "Capability:edit_private_employees", "Capability:edit_published_employees", "Capability:read_private_employees", "Capability:publish_employees", "Capability:delete_employees", "Capability:delete_private_employees", "Capability:delete_published_employees", "Capability:delete_others_employees" ] } ] } Here is an example of what the admin interface can look like for a dummy operator user that has the Data Entry Operator user roles (cloned from the Subscriber role) with two AAM Access Policies attached – one for each custom CPT: Notice how the lack of most menu items makes it easier to focus solely on the data-entry aspect. The policies can be made more granular, for example, to also restrict who may delete an entry or create new ones. Custom REST routes While WordPress will automatically create REST routes for every CPT as long as it is registered with the show_in_rest argument set to true, you can also create your own custom rest routes that are better suited for serving the CPT content in a way that makes more sense to your use-case. The easiest and most standard way to achieve this is by extending one of the REST API controller classes. For maximum control over the output, you may want to extend the base WP_REST_Controller class itself. You can choose to have your routes publicly accessible if the permission_callback argument is set to the __return_true function or you can choose to lock down calls using any permission scheme you want. The recommended way of locking down access is behind a capability check, i.e. a call to current_user_can. You can use the AAM Access Policies mentioned above to grant or withdraw permission from individual roles or users, and you can use WordPress’ application passwords to authenticate API requests. Hint: even if you decide that GET (read) requests should/can be publicly available, we still recommend that any POST // PUT // DELETE (create, update, delete) requests always be guarded by a current_user_can check. Here is a REST controller that we added to Code Snippets in order to be able to list the employees on the site and fetch them by ID: add_action( 'rest_api_init', function() { if ( ! class_exists( 'WP_REST_Controller' ) ) { return; } class Employees_Controller extends WP_REST_Controller { protected $namespace = 'custom/v1'; protected $rest_base = 'employees'; public function register_routes(): void { register_rest_route( $this->namespace, "/$this->rest_base", array( array( 'methods' => WP_REST_Server::READABLE, 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'callback' => array( $this, 'get_items' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, "/$this->rest_base/(?P<employee_id>[\d]+)", array( 'args' => array( 'employee_id' => array( 'description' => __( 'Unique identifier for the employee.', 'psapi-features' ), 'type' => 'integer', ), ), array( 'methods' => WP_REST_Server::READABLE, 'permission_callback' => array( $this, 'get_item_permissions_check' ), 'callback' => array( $this, 'get_item' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } public function get_items_permissions_check( $request ): WP_Error|bool { return true; // This information is public. You probably want to do a `current_user_can` check. } public function get_item_permissions_check( $request ): WP_Error|bool { return $this->get_items_permissions_check( $request ); // Same as for listing all. Can be different. } public function get_items( $request ): WP_Error|WP_REST_Response { $response = array(); $employees = new WP_Query( $this->prepare_posts_query_args( $request ) ); foreach ( $employees->posts as $employee ) { $data = $this->prepare_item_for_response( $employee, $request ); $response[] = $this->prepare_response_for_collection( $data ); } $response = rest_ensure_response( $response ); $response->header( 'X-WP-Total', $employees->found_posts ); $response->header( 'X-WP-TotalPages', $employees->max_num_pages ); foreach ( $this->prepare_link_headers( $request, $employees->max_num_pages ) as $key => $value ) { $response->link_header( $key, $value ); } return $response; } public function get_item( $request ): WP_Error|WP_REST_Response { $employee = get_post( $request['employee_id'] ); if ( ! $employee ) { return new WP_Error( 'rest_not_found', __( 'No employee found for the given identifier.', 'wpcom-demo' ), array( 'status' => 404 ) ); } $response = $this->prepare_item_for_response( $employee, $request ); return rest_ensure_response( $response ); } public function prepare_item_for_response( $item, $request ): WP_Error|WP_REST_Response { $fields = $this->get_fields_for_response( $request ); $data = array(); if ( rest_is_field_included( 'id', $fields ) ) { $data['id'] = $item->ID; } if ( rest_is_field_included( 'name', $fields ) ) { $data['name'] = $item->post_title; } if ( rest_is_field_included( 'picture', $fields ) ) { $picture = get_the_post_thumbnail_url( $item, 'full' ); $data['picture'] = empty( $picture ) ? null : $picture; } $data = rest_sanitize_value_from_schema( $data, $this->get_item_schema() ); $response = rest_ensure_response( $data ); if ( rest_is_field_included( '_links', $fields ) ) { $response->add_links( $this->prepare_links( $item, $request ) ); } return $response; } public function get_item_schema(): array { if ( $this->schema ) { return $this->add_additional_fields_schema( $this->schema ); } $this->schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'employee', 'type' => 'object', 'properties' => array( 'id' => array( 'description' => __( 'Unique identifier for the employee.', 'wpcom-demo' ), 'type' => 'integer', 'readonly' => true, ), 'name' => array( 'description' => __( 'The name of the employee.', 'wpcom-demo' ), 'type' => 'string', 'required' => true, ), 'picture' => array( 'description' => __( 'URL to the employee profile picture.', 'wpcom-demo' ), 'type' => array( 'string', 'null' ), 'format' => 'uri', 'required' => true, ), ) ); return $this->add_additional_fields_schema( $this->schema ); } protected function prepare_posts_query_args( WP_REST_Request $request ): array { return array( 'post_type' => 'employee', 'post_status' => 'publish', 'order' => $request['order'], 'orderby' => $request['orderby'], 'posts_per_page' => $request['per_page'], 'paged' => $request['page'], 's' => $request['search'] ?? '', 'tax_query' => $this->prepare_posts_taxonomy_query_args( $request ), // phpcs:ignore WordPress.DB.SlowDBQuery ); } protected function prepare_posts_taxonomy_query_args( WP_REST_Request $request ): array { $tax_query = array(); if ( $request['team'] ?? false ) { $tax_query[] = array( 'taxonomy' => 'team', 'field' => 'slug', 'terms' => array( $request['team'] ), ); } return $tax_query; } protected function prepare_link_headers( WP_REST_Request $request, int $max_pages ): array { $link_headers = array(); $base = add_query_arg( urlencode_deep( $request->get_query_params() ), rest_url( $request->get_route() ) ); $next_page = $request['page'] < $max_pages ? ( $request['page'] + 1 ) : null; if ( $next_page ) { $link_headers['next'] = add_query_arg( 'page', $next_page, $base ); } $prev_page = $request['page'] > 1 ? ( $request['page'] - 1 ) : null; if ( $prev_page ) { $link_headers['prev'] = add_query_arg( 'page', $prev_page, $base ); } return $link_headers; } protected function prepare_links( WP_Post $employee, WP_REST_Request $request ): array { $links = array(); if ( ! isset( $request['employee_id'] ) ) { $links['self'] = array( array( 'href' => rest_url( "$this->namespace/$this->rest_base/{$employee->ID}" ), ), ); } else { $links['collection'] = array( array( 'href' => rest_url( "$this->namespace/$this->rest_base" ), ), ); } return $links; } } ( new Employees_Controller() )->register_routes(); } ); Testing your REST routes Your custom REST routes will be available under <your-domain>/wp-json/<route_namespace>/<route>. For example, the path for retrieving the list of employees could look like this: <your-domain>/wp-json/custom/v1/employees?team=marketing Hint: the team query added there will be parsed by WordPress and made available in the controller; you can then choose to either ignore it or filter the results by it – anything you want! The easiest way to test your endpoints, especially if they will require an application password to access, is to use a tool like Postman which lets you test APIs in a very user-friendly manner. Publicly available GET requests can also be tested by simply visiting the URL endpoint in your browser! Querying via GraphQL Now that we are able to fetch the data via REST routes, let’s explore how we might be able to fetch it using GraphQL as well. If you’re unfamiliar with GraphQL, what you need to know is that it’s actually a querying language just like SQL but for APIs. You can read more about it on the official website over at https://graphql.org/. The simplest way to add GraphQL support to our site is by installing the newly-canonical plugin WPGraphQL. It also has a documentation page where you can learn more about what it provides out-of-the-box, and also examples of how to handle much more complex scenarios. If you’ve been paying attention to the JSON configuration of the custom post types shared above, you might’ve already noticed a key named show_in_graphql set to 1 (true/active). That is all we need in order to allow the custom post types we added to be queries using GraphQL. Here is an example of a GraphQL query that can be used to list Employees which you can test in the built-in GraphiQL IDE bundled with the plugin: query GetEmployeesEdges { employees { edges { node { id name: title image: featuredImage { node { sourceUrl } } } } } } Building your own If this sounds like something you want to build for your own business, you can work on it on your own computer using Studio by WordPress.com. You can even share your work with colleagues (for free!) using a demo site, and when you’re ready, any WordPress.com Business plan or higher will be able to host and manage your site. |