DEV3 Technologies home

REST route with permission callback and schema

Register a REST API route that validates its arguments, authorises with permission_callback and returns a shaped response.

WordPressAPIsrest-apipermission-callbackvalidationjson

Code

<?php
/**
 * REST: /wp-json/acme/v1/bookmarks
 *
 * @package Acme_Core
 */

defined( 'ABSPATH' ) || exit;

add_action( 'rest_api_init', static function (): void {
	register_rest_route( 'acme/v1', '/bookmarks', array(
		array(
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => 'acme_rest_list_bookmarks',
			'permission_callback' => static fn(): bool => is_user_logged_in(),
			'args'                => array(
				'per_page' => array( 'type' => 'integer', 'default' => 10, 'minimum' => 1, 'maximum' => 50, 'sanitize_callback' => 'absint' ),
			),
		),
		array(
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => 'acme_rest_add_bookmark',
			'permission_callback' => static fn(): bool => current_user_can( 'edit_posts' ),
			'args'                => array(
				'post_id' => array( 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint' ),
				'note'    => array( 'type' => 'string', 'default' => '', 'maxLength' => 200, 'sanitize_callback' => 'sanitize_text_field' ),
			),
		),
	) );
} );

function acme_rest_list_bookmarks( WP_REST_Request $request ) {
	$items = (array) get_user_meta( get_current_user_id(), 'acme_bookmarks', true );
	$items = array_slice( array_values( array_filter( $items ) ), 0, (int) $request['per_page'] );
	return rest_ensure_response( array( 'items' => $items, 'total' => count( $items ) ) );
}

function acme_rest_add_bookmark( WP_REST_Request $request ) {
	$post_id = (int) $request['post_id'];

	// Object-level check: the caller may only bookmark posts they can read.
	if ( ! get_post( $post_id ) || ! current_user_can( 'read_post', $post_id ) ) {
		return new WP_Error( 'acme_not_found', __( 'Post not found.', 'acme-core' ), array( 'status' => 404 ) );
	}

	$all             = (array) get_user_meta( get_current_user_id(), 'acme_bookmarks', true );
	$all[ $post_id ] = array( 'post_id' => $post_id, 'note' => (string) $request['note'] );
	update_user_meta( get_current_user_id(), 'acme_bookmarks', $all );

	return new WP_REST_Response( $all[ $post_id ], 201 );
}

How it works

What it solves

Every REST route that lacks a real permission_callback triggers a notice in current WordPress, and a sloppy callback can expose or change data. This snippet shows a read route for logged-in users and a write route with two layers of authorisation.

How it works

Both handlers are declared on one path, distinguished by HTTP method. Each has an inline permission_callback, so authorisation runs before the handler. Arguments carry a type, bounds, a default and a sanitiser, and WordPress validates and coerces them from the schema before your code sees them. The write handler adds an object-level check with read_post, returns a WP_Error carrying a status for failures, and answers 201 on creation.

No output is built from raw input.

Adapting it

Public data can use __return_true for the permission callback, as long as it is intentional. Move the handlers into a controller class extending WP_REST_Controller when you add item routes with {id}. Add a schema callback to document responses. Rename the namespace, key and capability.

Pitfalls

  • Cookie-authenticated requests need the X-WP-Nonce header; application passwords work without it.
  • Capability checks on the route do not replace checks on the specific object.
  • Do not echo $request->get_params() back to the client.
  • Version the namespace; breaking changes should go to v2.

How to use it

Save as inc/rest-routes.php and require it from your plugin or theme.

Call GET /wp-json/acme/v1/bookmarks while logged in, sending the REST nonce header.

POST a JSON body with post_id to create a bookmark.

Licence: GPL-2.0-or-later. This snippet comes from WordPress code and is GPL-2.0-or-later.

Related snippets