Requests & Responses

Reading WP_REST_Request, returning WP_REST_Response or WP_Error, and controlling status codes and headers from a callback.

Syntaxfunction my_callback( WP_REST_Request $request ): WP_REST_Response|WP_Error

Every REST callback receives one argument — a WP_REST_Request — and returns either data, a WP_REST_Response, or a WP_Error. The server serialises whatever it gets into JSON and an HTTP status.

Reading the request

WP_REST_Request merges URL parameters, the query string, the form body and the JSON body into one bag. $request->get_param( 'id' ) is the longhand; because the class implements ArrayAccess, $request['id'] is the identical shorthand. When a name appears in more than one place the body wins, so use get_query_params(), get_json_params(), get_body_params() or get_url_params() when the source matters. Headers come from get_header( 'X-WP-Nonce' ).

Returning a response

Returning a plain array is shorthand: the server wraps it in new WP_REST_Response( $data, 200 ). Write the longhand when you need a different status or a header. WP_REST_Response extends WP_HTTP_Response, so it gives you set_status( $code ), header( $key, $value, $replace = true ) and set_data( $data ), plus add_link() for HAL _links. rest_ensure_response() normalises anything into a response object and passes a WP_Error straight through — use it when wrapping an existing function.

Errors

Return a WP_Error; do not throw. Its third argument sets the HTTP status via the status key, and the code and message are serialised into the body as code and message.

Example

Example · php
<?php
/**
 * Callback for POST /wp-json/acme/v1/orders/<id>/refund
 */
function acme_refund_order( WP_REST_Request $request ) {
    // Shorthand and longhand for the same value. ArrayAccess is sugar for
    // get_param(); both search URL params, query string, body and JSON.
    $order_id = (int) $request['id'];
    $order_id = (int) $request->get_param( 'id' );

    // When the source matters -- e.g. you will only accept a JSON body and not
    // a value smuggled in on the query string -- read that bag directly.
    $body   = $request->get_json_params();
    $amount = isset( $body['amount'] ) ? (float) $body['amount'] : 0.0;

    $order = acme_get_order( $order_id );

    if ( ! $order ) {
        // Without the status key this would surface as a 500.
        return new WP_Error(
            'acme_order_not_found',
            __( 'No order with that ID.', 'acme' ),
            array( 'status' => 404 )
        );
    }

    if ( $amount > $order->balance ) {
        return new WP_Error(
            'acme_refund_too_large',
            __( 'Refund exceeds the remaining balance.', 'acme' ),
            array( 'status' => 422, 'balance' => $order->balance )
        );
    }

    $refund = acme_create_refund( $order, $amount );

    // Longhand, because 201 and a Location header are worth being explicit about.
    $response = new WP_REST_Response( $refund, 201 );
    $response->header( 'Location', rest_url( "acme/v1/refunds/{$refund['id']}" ) );
    $response->add_link( 'order', rest_url( "acme/v1/orders/{$order_id}" ) );

    return $response;
}

// The shorthand equivalent of a plain 200: returning the array alone is the
// same as returning new WP_REST_Response( $refund, 200 ).
// return $refund;

When to use it

  • Returning 201 and a Location header after a custom endpoint creates a record.
  • Distinguishing a JSON body from query-string parameters of the same name.
  • Setting Cache-Control on an expensive read endpoint fronted by a CDN.
  • Turning a validation failure into a 422 with machine-readable detail instead of a 500.

More examples

Paginating a custom collection the way core does

Matching core's convention means any client already written against wp/v2 can page your collection with no special-casing. X-WP-Total and X-WP-TotalPages are also in the default CORS expose list, so browser JavaScript on another origin can actually read them.

Example · php
<?php
function acme_list_books( WP_REST_Request $request ) {
    $page     = (int) $request->get_param( 'page' );
    $per_page = (int) $request->get_param( 'per_page' );

    $query = new WP_Query(
        array(
            'post_type'      => 'book',
            'post_status'    => 'publish',
            'posts_per_page' => $per_page,
            'paged'          => $page,
        )
    );

    $data = array_map(
        function ( WP_Post $post ) {
            return array(
                'id'    => $post->ID,
                'title' => get_the_title( $post ),
            );
        },
        $query->posts
    );

    $response = rest_ensure_response( $data );

    // Core's controllers publish paging totals as headers, not in the body.
    // Clients (including apiFetch consumers) expect these exact names.
    $response->header( 'X-WP-Total', (string) $query->found_posts );
    $response->header( 'X-WP-TotalPages', (string) $query->max_num_pages );

    return $response;
}

Wrapping an existing function safely

This is the pattern for putting a REST face on code you did not write for REST. The ordering matters: rest_ensure_response() deliberately lets WP_Error through untouched, so any method call on its result has to come after an is_wp_error() check.

Example · php
<?php
/**
 * acme_build_report() is old code: it returns an array on success and a
 * WP_Error on failure, and knows nothing about REST.
 */
function acme_report_endpoint( WP_REST_Request $request ) {
    $result = acme_build_report( $request->get_param( 'range' ) );

    // rest_ensure_response() returns a WP_Error unchanged and wraps anything
    // else in a WP_REST_Response. So check for the error FIRST -- calling
    // ->header() on the return value would fatal on the error path.
    if ( is_wp_error( $result ) ) {
        return $result;
    }

    $response = rest_ensure_response( $result );

    // Let a CDN cache this for five minutes. REST responses are sent with
    // no-cache headers by default, so you must opt in explicitly.
    $response->header( 'Cache-Control', 'public, max-age=300' );

    return $response;
}

Discussion

  • Be the first to comment on this lesson.