Exposing a WordPress function via REST API

The WordPress REST1 API2 was released on version 4.7.0 and it makes possible to send and receive data from endpoints (URL) to retrieve, update and create the content of your site.

For example, we can get the post content of post ID 1 by sending an HTTP GET request to the endpoint /wp/v2/posts/1:

GET https://my-site.test/wp-json/wp/v2/posts/1

Although WordPress has endpoints for posts, pages, taxonomies and other built-in WordPress data types, there are situations where these endpoints won’t address what is needed to do or you will have to make multiple requests to achieve a specific goal. That way, WordPress offers ways to extend the REST API.

To create a new endpoint you have to register it on rest_api_init action and call the function register_rest_route. In the example below, we are adding the endpoint my-namespace/v1/insert_or_update and the function my_custom_insert_or_update to respond when an HTTP POST request is sent to this endpoint.

add_action(
	'rest_api_init',
	function () {
		register_rest_route(
			'my-namespace/v1',
			'/insert_or_update',
			array(
				'methods'  => 'POST',
				'callback' => 'my_custom_insert_or_update',
			)
		);
	}
);

function my_custom_insert_or_update() {
	return 'it is working';
}

That way, sending the request above the return will be “it is working”.

POST https://blog.test/wp-json/my-namespace/v1/insert_or_update

Suppose that you need to insert or update a post based on metadata stored in the key my_custom_meta_key. That is, if you find a post with the metadata, the post will be updated. Otherwise, you will insert a new post.

Different from the initial version of the function my_custom_insert_or_update where we just return a string, in the shown scenario we will insert or update data within our website based on an HTTP request sent. We should update our endpoint to process only authenticated requests. To achieve that we will use the parameter permission_callback in the register_rest_route function. To simplify we will check only if the user has permission to edit other posts.

add_action(
	'rest_api_init',
	function () {
		register_rest_route(
			'my-namespace/v1',
			'/insert_or_update',
			array(
				'methods'             => 'POST',
				'callback'            => 'my_custom_insert_or_update',
				'permission_callback' => fn() => current_user_can( 'edit_others_posts' ),
			)
		);
	}
);

After inserting the parameter permission_callback, the HTTP POST request to our new endpoint will return a message saying we are not authorized.

POST https://blog.test/wp-json/my-namespace/v1/insert_or_update

### return

{
  "code": "rest_forbidden",
  "message": "Sorry, you are not allowed to do that.",
  "data": {
    "status": 401
  }
}

We need to send an authenticated request. To achieve that we will create an application password (available from version 5.6) for our WordPress user. The application password is created on the user edit page (wp-admin -> Users -> Edit User).

The request now has the header Authorization (using Basic Auth3).

POST https://blog.test/wp-json/my-namespace/v1/insert_or_update
Authorization: Basic admin:AgPJ CdUB IIGH iKAP 6ot7 8syu

### or base64 encoded

POST https://blog.test/wp-json/my-namespace/v1/insert_or_update
Authorization: Basic YWRtaW46QWdQSiBDZFVCIElJR0ggaUtBUCA2b3Q3IDhzeXU=

Now the endpoint accepts only authenticated requests, we can focus on the logic to insert or update a post based on metadata stored in the my_custom_meta_key key.

To do that, we can follow the steps:

  1. Try to retrieve the post using the class WP_Query4
  2. If we find the post, we will update
  3. If not, we will insert a new one

In both cases (insert ou update), we will use the function wp_insert_post5. On the HTTP request, we will send the same data expected by the parameter $postarr. So, we are exposing this function via REST API on the endpoint that we created.

Let’s implement the first step i.e. trying to retrieve the post using the WP_Query class based on the metadata stored in the my_custom_meta_key key.

In all requests to our endpoint, the utilized arguments to try to retrieve the post will be the same. What will change is the value (inside the meta_query). We can read these arguments that way: we want to find 1 post (posts_per_page) of the type post or page (post_type) that has the metadata specified in the meta_query key. If we find this post, only the ID will be returned (fields)

$query = new WP_Query(
	array(
		'fields'                 => 'ids',
		'post_type'              => array( 'post', 'page' ),
		'posts_per_page'         => 1,
		'no_found_rows'          => true,
		'update_post_meta_cache' => false,
		'update_post_term_cache' => false,
		'meta_query'             => array(
			array(
				'key'   => 'my_custom_meta_key',
				'value' => 'meta_value',
			),
		),
	)
);

We need to tell our endpoint that we expect the argument my_custom_meta_value in the HTTP request. For that, when we can the function register_rest_route we will pass one more parameter called args.

add_action(
	'rest_api_init',
	function () {
		$args = array(
			'my_custom_meta_value' => array(
				'description'       => 'My custom meta value to search for',
				'type'              => 'string',
				'required'          => true,
				'validate_callback' => fn( $param ) => is_string( $param ),
				'sanitize_callback' => 'sanitize_text_field',
			),
		);

		register_rest_route(
			'my-namespace/v1',
			'/insert_or_update',
			array(
				'methods'             => 'POST',
				'callback'            => 'my_custom_insert_or_update',
				'permission_callback' => fn() => current_user_can( 'edit_others_posts' ),
				'args'                => $args,
			)
		);
	}
);

Note that we added a description, the type, if it’s required, a function to validate our argument and another function to sanitize (removing characters that can potentially be dangerous to our system) the received value. In the validation (validate_callback), I just check if the value is a string and in the sanitization I use a native WordPress function called sanitize_text_field6.

Let’s update the function my_custom_insert_or_update to use the parameter my_custom_meta_value when we are trying to retrieve the post using the class WP_Query. Also, we will add the variable $post_id to store the post ID found or zero (if it wasn’t found). Since we didn’t insert any posts yet, the value will be zero.

function my_custom_insert_or_update( $request ) {
	$my_custom_meta_value = $request['my_custom_meta_value'];

	$query = new WP_Query(
		array(
			'fields'                 => 'ids',
			'post_type'              => array( 'post', 'page' ),
			'posts_per_page'         => 1,
			'no_found_rows'          => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
			'meta_query'             => array(
				array(
					'key'   => 'my_custom_meta_key',
					'value' => $my_custom_meta_value,
				),
			),
		)
	);
	
	$post_id = $query->posts[0] ?? 0;
}

To finally call the function wp_insert_post we need to pass the parameter $postarr with the post data (title, content, post type, etc). It will be done by passing one more argument in the HTTP request. So let’s insert this new argument to the variable $args.

add_action(
	'rest_api_init',
	function () {
		$args = array(
			'my_custom_meta_value' => array( ... ),
			// postarr arg
			'postarr'              => array(
				'description'       => 'The post data',
				'type'              => 'array',
				'required'          => true,
				'validate_callback' => fn( $param ) => is_array( $param ),
				'sanitize_callback' => function( $param ) {
					foreach ( $param as $key => $value ) {
						if ( 'post_content' === $key ) {
							$param[ $key ] = wp_kses_post( $value );
							continue;
						}

						$param[ $key ] = sanitize_text_field( $value );
					}

					return $param;
				},
			),
		);

		register_rest_route( ... );
	}
);

Note that in the validation we just check if the parameter value is an array. In the sanitization, the key post_content has a special treatment because we want to keep the HTML tags that can be inserted in the post content and that way we use the function wp_kses_post7.

Let’s add the variable $postarr in the function my_custom_insert_or_update to hold the data sent in the request. The variable $default_data was inserted with some default data. That means we can omit e.g. the key post_status in the HTTP request and the default value will be “publish”. We use the function wp_parse_args8 to merge the values that come from the HTTP request with the default values defined in the variable $default_data.

Lastly, we add the meta value sent in the request. This is important because it creates a link between the new post that will be created and the data sent. For example, if we send the same request but with a different title, the post will be updated instead of inserted. Imagine that you are importing posts from another system to WordPress, but the users are still inserting the data while the shift doesn’t happen. Probably we will run the import more than one time to insert new posts and to update what was already inserted.

function my_custom_insert_or_update( $request ) {
	. . .
	
	$post_id = $query->posts[0] ?? 0;
	
	$default_data = array(
		'ID'          => $post_id,
		'post_status' => 'publish',
		'post_type'   => 'post',
	);

	$postarr = wp_parse_args(
		$request['postarr'],
		$default_data
	);

	$postarr['meta_input']['my_custom_meta_key'] = $my_custom_meta_value;
}

Now we need to call the function wp_insert_post with the $postarr variable. Also, it’s necessary to return an HTTP status9 to indicate if the HTTP request was successfully finished (HTTP 200) or not (HTTP 500).

To indicate that a request was successfully finished we will return a WP_REST_Response10 class instance with the inserted/updated post ID. To indicate an error, we will return the WP_Error11 object that is returned by the function wp_insert_post on failure. WordPress converts a WP_Error object to WP_REST_Response with a 500 status code.

function my_custom_insert_or_update( $request ) {
	. . .
	
	$result = wp_insert_post( $postarr, true );

	if ( is_wp_error( $result ) ) {
		return $result;
	}

	return new WP_REST_Response( $result );
}

Let’s test the following scenarios of our endpoint:

  • Inserting a new post
  • Updating a post
  • Handling an error when inserting/updating a post

To test inserting a new post, we can send the following HTTP request:

POST https://blog.test/wp-json/my-namespace/v1/insert_or_update
Authorization: Basic YWRtaW46QWdQSiBDZFVCIElJR0ggaUtBUCA2b3Q3IDhzeXU=
Content-Type: application/json

{
	"my_custom_meta_value": "test_value",
	"postarr": {
		"post_title": "Lorem ipsum",
		"post_content": "<p>Ut enim ad minim veniam, quis nostrud consequat.</p>"
	}
}

// Return
HTTP/1.1 200 OK

213 // post ID

To check if the post was inserted we can send a request to the endpoint GET /wp/v2/posts/<id>

GET https://blog.test/wp-json/wp/v2/posts/213?_fields=id,title,content

// Return
{
  "id": 213,
  "title": {
    "rendered": "Lorem ipsum"
  },
  "content": {
    "rendered": "<p>Ut enim ad minim veniam, quis nostrud consequat.<\/p>\n",
    "protected": false
  }
}

To test updating a post, we will send the same request to insert but with a different value to the key “title”. Remember that the value stored on the metadata my_custom_meta_key is used as a reference to determine whether a post has already been inserted or not.

POST https://blog.test/wp-json/my-namespace/v1/insert_or_update
Authorization: Basic YWRtaW46QWdQSiBDZFVCIElJR0ggaUtBUCA2b3Q3IDhzeXU=
Content-Type: application/json

{
	"my_custom_meta_value": "test_value",
	"postarr": {
		"post_title": "Excepteur sint",
		"post_content": "<p>Ut enim ad minim veniam, quis nostrud consequat.</p>"
	}
}

// Return
HTTP/1.1 200 OK

213 // post ID

We can check if a post was updated by sending a request to the endpoint GET /wp/v2/posts/<id>

GET https://blog.test/wp-json/wp/v2/posts/213?_fields=id,title

// Return
{
  "id": 213,
  "title": {
    "rendered": "Excepteur sint"
  }
}

Lastly, to test how our endpoint handles an error on inserting/updating a post, we will send a request with an ID that doesn’t exist to be updated. This makes the wp_insert_post function to return an error.

POST https://blog.test/wp-json/my-namespace/v1/insert_or_update
Authorization: Basic YWRtaW46QWdQSiBDZFVCIElJR0ggaUtBUCA2b3Q3IDhzeXU=
Content-Type: application/json

{
	"my_custom_meta_value": "test_value",
	"postarr": {
		"ID": "12345",
		"post_title": "Excepteur sint",
		"post_content": "<p>Ut enim ad minim veniam, quis nostrud consequat.</p>"
	}
}

// Return
HTTP/1.1 500 Internal Server Error

{
  "code": "invalid_post",
  "message": "Invalid post ID.",
  "data": null
}

Transforming into a plugin

The code produced in this post can be used as a plugin. To do that it’s necessary to copy the code below into the file wp-content/plugins/my-custom-insert-or-update-post-endpoint/my-custom-insert-or-update-post-endpoint.php. Remember to activate the plugin.

<?php
/**
 * Plugin Name:       My custom insert or update post endpoint.
 * Description:       Add the `/my-namespace/v1/insert_or_update` endpoint to the WP REST API.
 * Version:           1.0.0
 * Requires at least: 4.7.0
 * Requires PHP:      7.4
 * Author:            Ramon Ahnert
 * Author URI:        https://nomar.dev/
 * License:           GPL v2 or later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 */

namespace My_Custom_Insert_Update_Endpoint;

add_action(
	'rest_api_init',
	function () {
		$args = array(
			'my_custom_meta_value' => array(
				'description'       => 'My custom meta value to search for',
				'type'              => 'string',
				'required'          => true,
				'validate_callback' => fn( $param ) => is_string( $param ),
				'sanitize_callback' => 'sanitize_text_field',
			),
			'postarr'              => array(
				'description'       => 'The post data',
				'type'              => 'array',
				'required'          => true,
				'validate_callback' => fn( $param ) => is_array( $param ),
				'sanitize_callback' => function( $param ) {
					foreach ( $param as $key => $value ) {
						if ( 'post_content' === $key ) {
							$param[ $key ] = wp_kses_post( $value );
							continue;
						}

						$param[ $key ] = sanitize_text_field( $value );
					}

					return $param;
				},
			),
		);

		register_rest_route(
			'my-namespace/v1',
			'/insert_or_update',
			array(
				'methods'             => 'POST',
				'callback'            => 'my_custom_insert_or_update',
				'permission_callback' => fn() => current_user_can( 'edit_others_posts' ),
				'args'                => $args,
			)
		);
	}
);

/**
 * Handle `/my-namespace/v1/insert_or_update` endpoint.
 *
 * @param WP_REST_Request $request Full data about the request.
 * @return WP_REST_Response|WP_Error
 */
function my_custom_insert_or_update( $request ) {
	$my_custom_meta_value = $request['my_custom_meta_value'];

	$query = new WP_Query(
		array(
			'fields'                 => 'ids',
			'post_type'              => array( 'post', 'page' ),
			'posts_per_page'         => 1,
			'no_found_rows'          => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
			'meta_query'             => array(
				array(
					'key'   => 'my_custom_meta_key',
					'value' => $my_custom_meta_value,
				),
			),
		)
	);

	$post_id = $query->posts[0] ?? 0;

	$default_data = array(
		'ID'          => $post_id,
		'post_status' => 'publish',
		'post_type'   => 'post',
	);

	$postarr = wp_parse_args(
		$request['postarr'],
		$default_data
	);

	$postarr['meta_input']['my_custom_meta_key'] = $my_custom_meta_value;

	$result = wp_insert_post( $postarr, true );

	if ( is_wp_error( $result ) ) {
		return $result;
	}

	return new WP_REST_Response( $result );
}

Footnotes

  1. REpresentational State Transfer ↩︎
  2. Application Programming Interface ↩︎
  3. The ‘Basic’ HTTP Authentication Scheme ↩︎
  4. https://developer.wordpress.org/reference/classes/wp_query/ ↩︎
  5. https://developer.wordpress.org/reference/functions/wp_insert_post/ ↩︎
  6. https://developer.wordpress.org/reference/functions/sanitize_text_field/ ↩︎
  7. https://developer.wordpress.org/reference/functions/wp_kses_post/ ↩︎
  8. https://developer.wordpress.org/reference/functions/wp_parse_args/ ↩︎
  9. https://developer.mozilla.org/en-US/docs/Web/HTTP/Status ↩︎
  10. https://developer.wordpress.org/reference/classes/wp_rest_response/ ↩︎
  11. https://developer.wordpress.org/reference/classes/wp_error/ ↩︎