Make WordPress Core

Opened 8 weeks ago

Last modified 5 weeks ago

#64926 new defect (bug)

REST API: GET requests fail object/array schema validation when params are JSON-serialized strings

Reported by: dsmy's profile dsmy Owned by:
Milestone: Awaiting Review Priority: normal
Severity: normal Version:
Component: REST API Keywords: has-patch has-unit-tests
Focuses: javascript, rest-api, php-compatibility Cc:

Description (last modified by westonruter)

The problem

When a REST endpoint declares a parameter with "type": "object" or "type": "array", GET requests cannot pass that parameter correctly. URLSearchParams has no native way to encode nested structures, so the only option is JSON.stringify(), producing ?input={"post_id":123}.

PHP populates $_GET['input'] as a raw string. rest_validate_value_from_schema() receives a string where the schema expects object, and returns a WP_Error, the request is rejected before rest_sanitize_value_from_schema() can coerce it.

rest_sanitize_value_from_schema() already handles this case correctly (calls json_decode() on strings before validating type). The fix is to apply the same coercion in rest_validate_value_from_schema(), or to add a pre-validation JSON-decode pass for GET params that match an object/array schema, mirroring what parse_json_params() does for application/json body requests.

Affected file: wp-includes/rest-api.php — rest_validate_value_from_schema()

Steps to reproduce:

Register an endpoint with a param declared as "type": "object" Call it via GET with ?param{"key":"value"}

Receive a 400: "param is not of type object"

Workaround(see example below): Force POST method, or manually json_decode() the param in the endpoint callback before use.

The closest historical ticket is #42961(https://core.trac.wordpress.org/ticket/42961) ("REST API: Cannot pass empty object url encoded data", closed fixed in 2017) but that was about PHP bracket-notation encoding of empty arrays, not the JSON.stringify + rest_validate_value_from_schema ordering problem.

Workaround

Until a core fix lands, the following filter coerces JSON-string GET params before validation runs:

<?php
add_filter( 'rest_request_before_callbacks', function( $response, $handler, $request ) {
    if ( 'GET' !== $request->get_method() ) {
        return $response;
    }
    foreach ( $handler['args'] ?? array() as $key => $arg_schema ) {
        $type = $arg_schema['type'] ?? '';
        if ( in_array( $type, array( 'object', 'array' ), true ) ) {
            $value = $request->get_param( $key );
            if ( is_string( $value ) ) {
                $decoded = json_decode( $value, true );
                if ( null !== $decoded ) {
                    $request->set_param( $key, $decoded );
                }
            }
        }
    }
    return $response;
}, 10, 3 );

The proper fix belongs in rest_validate_value_from_schema() in wp-includes/rest-api.php, mirroring the json_decode coercion already present in rest_sanitize_value_from_schema().

Change History (29)

#1 follow-up: @abcd95
8 weeks ago

I think the issue is likely in rest_validate_value_from_schema() in rest-api.php.

When a GET request passes an object or array param as a JSON string (e.g. ?filter={"post_id":123}), PHP populates $_GET with a raw string. The validator calls rest_is_object() / rest_is_array() on that string and it neither recognises it, and rejects the request with a 400 before sanitisation can run.

For POST requests with Content-Type: application/json this never occurs because WP_REST_Request::parse_json_params() calls json_decode() on the body before validation. No equivalent step exists for query-string params.

The most sensible fix that I can think of is in rest_validate_value_from_schema(), when the type is object or array and the incoming $value is a string, we can there try to json_decode().

#2 @westonruter
8 weeks ago

  • Description modified (diff)

#3 follow-up: @westonruter
8 weeks ago

  • Description modified (diff)
  • Keywords reporter-feedback added

Thank you for the ticket.

When a REST endpoint declares a parameter with "type": "object" or "type": "array", GET requests cannot pass that parameter correctly. URLSearchParams has no native way to encode nested structures, so the only option is JSON.stringify(), producing ?input={"post_id":123}.

While URLSearchParams may not know how to encode that, doesn't PHP have a convention for this?

To pass an array as foo, you can do ?foo[]=1&foo[]=2&foo[]=3.

To pass an object:?user[name]=Bob&user[pet]=cat.

Does this not work?

#4 in reply to: ↑ 3 ; follow-up: @dsmy
7 weeks ago

Thanks for looking at this, Weston.

Bracket notation (?user[name]=Bob&user[pet]=cat) does work server side, and you're right that PHP has a native convention for it. The problem is on the client side.

From what I've seen URLSearchParams doesn't support bracket notation natively. Constructing it manually for deeply nested objects is tedious, error prone, and there's no standard JavaScript API that bridges the two.

GET and POST handle structured params through different code paths, and only POST has the JSON decode step.

But "use bracket notation and construct it manually in JavaScript" is not a documented pattern, not what developers will naturally do, and not what the REST API's own tooling produces. The JSON.stringify() path is broken where it should reasonably work.

Hope that gives a better insight!

Replying to westonruter:

Thank you for the ticket.

When a REST endpoint declares a parameter with "type": "object" or "type": "array", GET requests cannot pass that parameter correctly. URLSearchParams has no native way to encode nested structures, so the only option is JSON.stringify(), producing ?input={"post_id":123}.

While URLSearchParams may not know how to encode that, doesn't PHP have a convention for this?

To pass an array as foo, you can do ?foo[]=1&foo[]=2&foo[]=3.

To pass an object:?user[name]=Bob&user[pet]=cat.

Does this not work?

Last edited 7 weeks ago by dsmy (previous) (diff)

#5 in reply to: ↑ 1 @dsmy
7 weeks ago

Indeed, weird things happening between GET and POST handling.

The fix in rest_validate_value_from_schema() closes that gap when the schema expects object or array and the incoming value is a string, attempt json_decode() before rejecting. This logic already exists in rest_sanitize_value_from_schema(); the validation step should mirror it.

Appreciate the extra perspective too!

Replying to abcd95:

I think the issue is likely in rest_validate_value_from_schema() in rest-api.php.

When a GET request passes an object or array param as a JSON string (e.g. ?filter={"post_id":123}), PHP populates $_GET with a raw string. The validator calls rest_is_object() / rest_is_array() on that string and it neither recognises it, and rejects the request with a 400 before sanitisation can run.

For POST requests with Content-Type: application/json this never occurs because WP_REST_Request::parse_json_params() calls json_decode() on the body before validation. No equivalent step exists for query-string params.

The most sensible fix that I can think of is in rest_validate_value_from_schema(), when the type is object or array and the incoming $value is a string, we can there try to json_decode().

Last edited 7 weeks ago by dsmy (previous) (diff)

#6 in reply to: ↑ 4 ; follow-up: @westonruter
7 weeks ago

Replying to dsmy:

From what I've seen URLSearchParams doesn't support bracket notation natively. Constructing it manually for deeply nested objects is tedious, error prone, and there's no standard JavaScript API that bridges the two.

What about the qs library? https://github.com/ljharb/qs

#7 in reply to: ↑ 6 @dsmy
7 weeks ago

Didn't try that, honestly was trying to avoid adding a extra dependency to the mix and leverage as much of core to keep things native, then came across the similar issue that I mentioned earlier!

Replying to westonruter:

Replying to dsmy:

From what I've seen URLSearchParams doesn't support bracket notation natively. Constructing it manually for deeply nested objects is tedious, error prone, and there's no standard JavaScript API that bridges the two.

What about the qs library? https://github.com/ljharb/qs

#8 follow-ups: @zieladam
7 weeks ago

URLSearchParams does seem to support that notation:

const p = new URLSearchParams();
p.set('user[name]', 'Bob');
p.set('user[pet]', 'cat');
console.log(p+'');

// user%5Bname%5D=Bob&user%5Bpet%5D=cat

When I request /dump.php?user%5Bname%5D=Bob&user%5Bpet%5D=cat and var_dump($_GET) in there, I get the expected result:

array(1) { ["user"]=> array(2) { ["name"]=> string(3) "Bob" ["pet"]=> string(3) "cat" } }

Does that help?

In any case, transforming arbitrary JSON objects into the bracket notation is lossy. There's to way to distinguish between null and undefined, or string "false" and boolean false, string "10" and number 10, an array ["hello"] and an object {0: "hello"} etc. It's just a wrong tool for encoding arbitrary JSON structures as they won't decode to the initial input. In other words, decode_from_brackets(encode_as_brackets(data)) !== data.

Last edited 7 weeks ago by zieladam (previous) (diff)

#9 in reply to: ↑ 8 @dsmy
7 weeks ago

Thanks @zieladam!

Even granted that URLSearchParams can construct bracket notation manually, it's the wrong tool for encoding arbitrary JSON structures. As you've pointed out, the round-trip is lossy: null vs undefined, boolean false vs string "false", number 10 vs string "10", arrays vs objects with numeric keys all collapse in bracket encoding. decode_from_brackets(encode_as_brackets(data)) !== data.
JSON.stringify() is the only lossless encoding path for structured data in a query string. That path currently breaks in rest_validate_value_from_schema().

To give context This came up while working with block attributes through a custom REST endpoint. We had a read-only endpoint that accepted block attribute filters as an object param. naturally the thing to do on the JS side is JSON.stringify() the attributes and pass them as a query param, especially since that's exactly what works for POST bodies.

Replying to zieladam:

URLSearchParams does seem to support that notation:

const p = new URLSearchParams();
p.set('user[name]', 'Bob');
p.set('user[pet]', 'cat');
console.log(p+'');

// user%5Bname%5D=Bob&user%5Bpet%5D=cat

When I request /dump.php?user%5Bname%5D=Bob&user%5Bpet%5D=cat and var_dump($_GET) in there, I get the expected result:

array(1) { ["user"]=> array(2) { ["name"]=> string(3) "Bob" ["pet"]=> string(3) "cat" } }

Does that help?

In any case, transforming arbitrary JSON objects into the bracket notation is lossy. There's to way to distinguish between null and undefined, or string "false" and boolean false, string "10" and number 10, an array ["hello"] and an object {0: "hello"} etc. It's just a wrong tool for encoding arbitrary JSON structures as they won't decode to the initial input. In other words, decode_from_brackets(encode_as_brackets(data)) !== data.

#10 @desrosj
7 weeks ago

  • Keywords reporter-feedback removed
  • Version trunk deleted

The version field in Trac is for specifying the version of WordPress that introduced an issue.

Removing trunk as this does not seem to be an issue introduced during the 7.0 release cycle.

#11 follow-up: @zieladam
7 weeks ago

@dsmy would you be willing to share more information about your use-case? While I don't have enough context off-hand to comment on possible solutions, I'm thinking that it is weird there is no historical ticket for this.

I wonder whether the REST API was intended for handling complex inputs via GET requests, or was the intention to always use POST. Is it just a documentation issue? POST requests are not subject to HTTP caching, for example, which wouldn't make sense for uniquely generated pages parametrized by complex JSON structures. POST requests also behave differently under CORS etc. etc. Knowing more about your project would be a very helpful framing for this discussion.

Last edited 7 weeks ago by zieladam (previous) (diff)

#12 in reply to: ↑ 11 @dsmy
7 weeks ago

@zieladam Sure thing!

This came up while building a structured MCP layer that exposes WordPress site capabilities (theme tokens, block settings, animation configs, etc.) to an AI agent via declared REST endpoints. Read-only abilities use GET by design for the reasons you mentioned around caching and CORS, write abilities use POST.

The problem surfaces when a read-only ability needs structured input. As ability schemas get richer, params naturally become objects: filter objects, block attribute matchers, config subsets. Declaring those as type: object in the endpoint schema is the right call. Passing them from the JS client via JSON.stringify() is the natural path, and it breaks on GET.

The block attributes case is where the lossiness you raised was the most annoying. Attributes like {"lock":{"move":false,"remove":true}} or {"dropCap":false,"className":"hero"} contain booleans and nested objects that don't survive bracket encoding intact. Boolean false and string "false" are not equivalent when evaluating block state. JSON.stringify() is the only lossless encoding path I naturally found that was already existing in core, and it already works for POST bodies via parse_json_params().

On your question about whether GET was intended for complex inputs: the REST API's own schema system supports type: object and type: array for GET params, which suggests the intent was there. The implementation just didn't close the loop on the JSON string path side of things.

The closest historical ticket I was able to find was this one https://core.trac.wordpress.org/ticket/42961

Hope this helps!

Replying to zieladam:

@dsmy would you be willing to share more information about your use-case? While I don't have enough context off-hand to comment on possible solutions, I'm thinking that it is weird there is no historical ticket for this.

I wonder whether the REST API was intended for handling complex inputs via GET requests, or was the intention to always use POST. Is it just a documentation issue? POST requests are not subject to HTTP caching, for example, which wouldn't make sense for uniquely generated pages parametrized by complex JSON structures. POST requests also behave differently under CORS etc. etc. Knowing more about your project would be a very helpful framing for this discussion.

#13 in reply to: ↑ 8 @westonruter
7 weeks ago

Replying to zieladam:

In any case, transforming arbitrary JSON objects into the bracket notation is lossy. There's to way to distinguish between null and undefined, or string "false" and boolean false, string "10" and number 10, an array ["hello"] and an object {0: "hello"} etc. It's just a wrong tool for encoding arbitrary JSON structures as they won't decode to the initial input. In other words, decode_from_brackets(encode_as_brackets(data)) !== data.

Nevertheless, the REST API sanitization should be handling this in most cases. For example, if you have a nested property which is defined as an int in the schema, but you the property via user[age]=21 then the string "21" will be recognized as an integer via rest_is_integer() and rest_sanitize_value_from_schema() will cast it to an int. The same goes for booleans. Since undefined can't be represented in JSON, then this wouldn't be a problem. So I think for most of the time, it would not be lossy.

This ticket was mentioned in PR #11371 on WordPress/wordpress-develop by @liaison.


7 weeks ago
#14

  • Keywords has-patch added; needs-patch removed

Description
This PR addresses #64926, where REST API GET requests fail validation when parameters defined as object or array are passed as JSON-encoded strings (e.g., via JSON.stringify).

The Problem
Currently, rest_validate_value_from_schema() receives these parameters as raw strings from $_GET. Since the schema expects an object or array, it triggers a rest_invalid_type error (400 Bad Request) before any sanitization or decoding can occur. While POST requests handle this via parse_json_params(), no equivalent coercion exists for query-string parameters.

The Fix
This PR introduces JSON coercion in both rest_validate_value_from_schema() and rest_sanitize_value_from_schema().

If the schema expects a structured type but receives a string, it attempts to json_decode().

Uses json_last_error() === JSON_ERROR_NONE to ensure only valid JSON is coerced, maintaining safety for regular strings.

Supports multi-type schemas (e.g., ['string', 'object']).

No changes to function signatures, ensuring backward compatibility.

How to Test
Register a test endpoint with an object type parameter:

PHP
register_rest_route( 'my-test/v1', '/schema-test', array(

'methods' => 'GET',
'callback' => function( $request ) {

return array( 'success' => true, 'data' => $request->get_param( 'config' ) );

},
'args' => array(

'config' => array( 'type' => 'object' ),

),

) );
Test Case A: Valid JSON Object

URL: /wp-json/my-test/v1/schema-test?config={"id":123,"name":"Gemini"}

Expected Result: {"success":true,"data":{"id":123,"name":"Gemini"}} (Integers preserved).

Test Case B: Empty Object

URL: /wp-json/my-test/v1/schema-test?config={}

Expected Result: {"success":true,"data":[]} (PHP decodes empty JSON object as array).

Test Case C: Invalid JSON (Safety Check)

URL: /wp-json/my-test/v1/schema-test?config={id:123}

Expected Result: 400 Bad Request (Correctly rejected because it's not valid JSON and doesn't match type object).

Screenshots/Logs
Tested in a local WordPress development environment.

Before Patch: Returns rest_invalid_type error for all JSON string inputs in GET.

After Patch: Successfully decodes and validates structured data while maintaining strictness for invalid JSON.

Types of changes
[x] Bug fix (non-breaking change which fixes an issue)

[ ] New feature (non-breaking change which adds functionality)

[ ] Performance improvement

#15 follow-up: @liaison
7 weeks ago

Subject: Update on #64926 - GitHub PR submitted
Hi @westonruter @zieladam,

I’ve submitted a PR to address the validation/sanitization gap for structured data in GET requests. Based on our earlier discussion, here is the rationale behind the implementation:

  1. Rationale & Consensus

While PHP's bracket notation (?user[name]=Bob) exists, it's not the ideal tool for modern decoupled clients or AI-agent integrations (like MCP) for several reasons:

Lossy Encoding: As noted by @zieladam, bracket notation collapses types (e.g., boolean false becomes string "false", null becomes empty string), which breaks logic for strict schemas like block attributes.

DX & Consistency: JSON.stringify() is the standard lossless path for structured data in JS. Since REST API already supports this via parse_json_params() for POST bodies, GET requests should ideally mirror this behavior to provide a consistent developer experience.

  1. PR Philosophy & Implementation

The PR introduces JSON Coercion at the entry point of both rest_validate_value_from_schema() and rest_sanitize_value_from_schema():

Validation & Sanitization: We decode the string early so it can pass the is_object or is_array checks, preventing the rest_invalid_type (400) error and ensuring the controller receives the correct PHP structured type.

Safety & Side-Effect Prevention:

Used json_last_error() to ensure only valid JSON is coerced.

Added a prefix check ({ or [). This is crucial to prevent unintended decoding of numeric strings (e.g., numeric slugs), which previously caused Undefined index errors in some Template Controller tests.

Maintained compatibility by using 0 === strpos() for PHP 7.x environments.

The PR is now passing all REST API unit tests and adheres to WP Coding Standards.

GitHub PR: https://github.com/WordPress/wordpress-develop/pull/11371

Last edited 7 weeks ago by liaison (previous) (diff)

#16 @liaison
7 weeks ago

Subject: Test Instructions for #64926

For anyone reviewing, here is a reproducible test case to verify the fix:

  1. Register a Test Endpoint

PHP

<?php
register_rest_route( 'my-test/v1', '/schema-test', [
    'methods'  => 'GET',
    'callback' => fn( $req ) => [ 'success' => true, 'data' => $req->get_param( 'config' ) ],
    'args'     => [ 'config' => [ 'type' => 'object' ] ],
] );

  1. Test Scenarios

Valid JSON Object: > GET /wp-json/my-test/v1/schema-test?config={"id":123,"active":true}

Before: 400 Bad Request ("config is not of type object").

After: 200 OK with data: { id: 123, active: true } (Types preserved).

Empty Object:
GET /wp-json/my-test/v1/schema-test?config={}

Result: 200 OK (Correctly coerced).

Invalid JSON (Safety):
GET /wp-json/my-test/v1/schema-test?config={id:123}

Result: 400 Bad Request (Correctly rejected by core validation because decoding failed).

The PR passes all grunt phpunit tests and adheres to WP Coding Standards (Tabs used, no trailing whitespace).

@dsmy commented on PR #11371:


7 weeks ago
#17

Appreciate you taking the time for this @liaisontw

#18 in reply to: ↑ 15 ; follow-up: @westonruter
7 weeks ago

Replying to liaison:

While PHP's bracket notation (?user[name]=Bob) exists, it's not the ideal tool for modern decoupled clients or AI-agent integrations (like MCP) for several reasons:

Lossy Encoding: As noted by @zieladam, bracket notation collapses types (e.g., boolean false becomes string "false", null becomes empty string), which breaks logic for strict schemas like block attributes.

I'm not saying that adding JSON parsing of fields is a bad idea, but as I shared in my comment above, it's not necessarily a lossy encoding. The problem with your example endpoint is that it lacks a schema for the object properties. If I modify your example as follows to add properties:

<?php
add_action( 'rest_api_init', function () {
        register_rest_route( 'my-test/v1', '/schema-test', [
                'methods'  => 'GET',
                'callback' => fn( WP_REST_Request $req ) => [ 'success' => true, 'data' => $req->get_param( 'config' ) ],
                'args'     => [
                        'config' => [
                                'type' => 'object',
                                'properties' => [
                                        'id' => [
                                                'type' => 'integer',
                                        ],
                                        'active' => [
                                                'type' => 'boolean',
                                        ]
                                ],
                        ]
                ],
        ] );
} );

Before adding properties, a request to /wp-json/my-test/v1/schema-test?config[id]=123&config[active]=true would return:

{"success":true,"data":{"id":"123","active":"true"}}

But after adding properties then it returns the expected types:

{"success":true,"data":{"id":123,"active":true}}
Last edited 7 weeks ago by westonruter (previous) (diff)

#19 in reply to: ↑ 18 @liaison
7 weeks ago

Replying to westonruter:

Replying to liaison:

While PHP's bracket notation (?user[name]=Bob) exists, it's not the ideal tool for modern decoupled clients or AI-agent integrations (like MCP) for several reasons:

Lossy Encoding: As noted by @zieladam, bracket notation collapses types (e.g., boolean false becomes string "false", null becomes empty string), which breaks logic for strict schemas like block attributes.

I'm not saying that adding JSON parsing of fields is a bad idea, but as I shared in my comment above, it's not necessarily a lossy encoding. The problem with your example endpoint is that it lacks a schema for the object properties. If I modify your example as follows to add properties:

<?php
add_action( 'rest_api_init', function () {
        register_rest_route( 'my-test/v1', '/schema-test', [
                'methods'  => 'GET',
                'callback' => fn( WP_REST_Request $req ) => [ 'success' => true, 'data' => $req->get_param( 'config' ) ],
                'args'     => [
                        'config' => [
                                'type' => 'object',
                                'properties' => [
                                        'id' => [
                                                'type' => 'integer',
                                        ],
                                        'active' => [
                                                'type' => 'boolean',
                                        ]
                                ],
                        ]
                ],
        ] );
} );

Before adding properties, a request to /wp-json/my-test/v1/schema-test?config[id]=123&config[active]=true would return:

{"success":true,"data":{"id":"123","active":"true"}}

But after adding properties then it returns the expected types:

{"success":true,"data":{"id":123,"active":true}}

Thanks for the detailed example, @westonruter.

You're right that defining properties in the schema handles type conversion perfectly for bracket notation. It's a solid part of the REST API's validation logic.

The main goal here is to improve the "Decoupled Developer Experience." While bracket notation works, it gets messy with deeply nested objects or when using dynamic additionalProperties where we can't pre-define types. Supporting JSON in GET requests provides a cleaner, more reliable interface for modern JS clients and AI tools. It lets them pass objects directly without having to manually encode everything into PHP-style bracket strings.

For example, a deeply nested structure like ?a[b][c][d][e]=value can quickly approach URL length limits and is much more complex for frontend developers to construct than a simple ?config={...}.

@liaison commented on PR #11371:


7 weeks ago
#20

Hi @dsmy, thanks for the encouragement!

I've shared some technical feedback on the Trac ticket regarding @westonruter's example. While I agree that schema definitions handle basic type conversion, I believe this PR is essential for the reasons you previously highlighted:

"As ability schemas get richer, params naturally become objects... Passing them from the JS client via JSON.stringify() is the natural path, and it breaks on GET."

With the rise of AI Agents and MCP (Model Context Protocol), API consumers are moving away from manual parameter construction. AI models naturally produce JSON, and forcing them to serialize structured thoughts into PHP-style brackets [] adds unnecessary complexity. Supporting JSON in GET requests allows these modern agents to interact with WordPress more reliably and with less middleware.

The latest implementation now includes a prefix check to ensure we only attempt decoding for structured data (starting with { or [), which also resolves the numeric slug issue. Looking forward to your thoughts!

#21 follow-up: @abcd95
6 weeks ago

IMO, placing the decode at the top of the function misses anyOf/oneOf schemas with no top-level type, and the $type resolves to '' and the block is skipped on the initial call. It only works because recursive calls from rest_find_any_matching_schema() re-enter with the sub-schema's type set.

Moving the decode inside the switch (validation) and before rest_sanitize_array()/rest_sanitize_object() (sanitization) ensures it fires after type resolution, regardless of schema.

#22 in reply to: ↑ 21 @liaison
6 weeks ago

Replying to abcd95:

IMO, placing the decode at the top of the function misses anyOf/oneOf schemas with no top-level type, and the $type resolves to '' and the block is skipped on the initial call. It only works because recursive calls from rest_find_any_matching_schema() re-enter with the sub-schema's type set.

Moving the decode inside the switch (validation) and before rest_sanitize_array()/rest_sanitize_object() (sanitization) ensures it fires after type resolution, regardless of schema.

Thanks @abcd95. The assessment is correct. Moving the decoding logic after the type resolution ensures full compatibility with schemas that rely solely on anyOf or oneOf without a top-level type declaration.

<?php
function rest_sanitize_value_from_schema( $value, $args, $param = '' ) {
...
   if ( 'array' === $args['type'] ) {
        if ( is_string( $value ) ) {
            $trimmed_value = trim( $value );
            if ( str_starts_with( $trimmed_value, '[' ) ) {
                $decoded = json_decode( $value, true );
                if ( json_last_error() === JSON_ERROR_NONE ) {
                    $value = $decoded;
                }
            }
        }

        $value = rest_sanitize_array( $value );

    ...         
    }

    if ( 'object' === $args['type'] ) {
        if ( is_string( $value ) ) {
            $trimmed_value = trim( $value );
            if ( str_starts_with( $trimmed_value, '{' ) ) {
                $decoded = json_decode( $value, true );
                if ( json_last_error() === JSON_ERROR_NONE ) {
                    $value = $decoded;
                }
            }
        }

        $value = rest_sanitize_object( $value );
        ....
    }
...
}

function rest_validate_value_from_schema( $value, $args, $param = '' ) {
...
                case 'object':
                        if ( is_string( $value ) ) {
                                $trimmed_value = trim( $value );
                                if ( str_starts_with( $trimmed_value, '{' ) ) {
                                        $decoded = json_decode( $value, true );
                                        if ( json_last_error() === JSON_ERROR_NONE ) {
                                                $value = $decoded;
                                        }
                                }
                        }
                        $is_valid = rest_validate_object_value_from_schema( $value, $args, $param );
                        break;
                case 'array':
                        if ( is_string( $value ) ) {
                                $trimmed_value = trim( $value );
                                if ( str_starts_with( $trimmed_value, '[' ) ) {
                                        $decoded = json_decode( $value, true );
                                        if ( json_last_error() === JSON_ERROR_NONE ) {
                                                $value = $decoded;
                                        }
                                }
                        }
                        $is_valid = rest_validate_array_value_from_schema( $value, $args, $param );
                        break;
...
}

#23 @abcd95
6 weeks ago

This is what I came up wtih -

diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php
index c524f9e22a..e62c1f2586 100644
--- a/src/wp-includes/rest-api.php
+++ b/src/wp-includes/rest-api.php
@@ -2243,9 +2243,32 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) {
 			$is_valid = rest_validate_boolean_value_from_schema( $value, $param );
 			break;
 		case 'object':
+			/*
+			 * A JSON-encoded string (e.g. from a GET query parameter) should be
+			 * decoded before validation, mirroring what parse_json_params() does
+			 * for application/json request bodies.
+			 */
+			if ( is_string( $value ) ) {
+				$decoded = json_decode( $value, true );
+				if ( null !== $decoded && JSON_ERROR_NONE === json_last_error() ) {
+					$value = $decoded;
+				}
+			}
 			$is_valid = rest_validate_object_value_from_schema( $value, $args, $param );
 			break;
 		case 'array':
+			/*
+			 * A JSON-encoded string (e.g. ?ids=[1,2,3]) should be decoded before
+			 * validation. This takes priority over the comma-separated-value
+			 * fallback in rest_is_array() / wp_parse_list(), which cannot
+			 * preserve value types.
+			 */
+			if ( is_string( $value ) && str_starts_with( ltrim( $value ), '[' ) ) {
+				$decoded = json_decode( $value, true );
+				if ( is_array( $decoded ) && JSON_ERROR_NONE === json_last_error() ) {
+					$value = $decoded;
+				}
+			}
 			$is_valid = rest_validate_array_value_from_schema( $value, $args, $param );
 			break;
 		case 'number':
@@ -2833,6 +2856,19 @@ function rest_sanitize_value_from_schema( $value, $args, $param = '' ) {
 	}
 
 	if ( 'array' === $args['type'] ) {
+		/*
+		 * A JSON-encoded string (e.g. ?ids=[1,2,3]) should be decoded before
+		 * sanitization. This takes priority over the comma-separated-value
+		 * fallback in rest_sanitize_array() / wp_parse_list(), which cannot
+		 * preserve value types.
+		 */
+		if ( is_string( $value ) && str_starts_with( ltrim( $value ), '[' ) ) {
+			$decoded = json_decode( $value, true );
+			if ( is_array( $decoded ) && JSON_ERROR_NONE === json_last_error() ) {
+				$value = $decoded;
+			}
+		}
+
 		$value = rest_sanitize_array( $value );
 
 		if ( ! empty( $args['items'] ) ) {
@@ -2850,6 +2886,18 @@ function rest_sanitize_value_from_schema( $value, $args, $param = '' ) {
 	}
 
 	if ( 'object' === $args['type'] ) {
+		/*
+		 * A JSON-encoded string (e.g. from a GET query parameter) should be
+		 * decoded before sanitization, mirroring what parse_json_params() does
+		 * for application/json request bodies.
+		 */
+		if ( is_string( $value ) ) {
+			$decoded = json_decode( $value, true );
+			if ( null !== $decoded && JSON_ERROR_NONE === json_last_error() ) {
+				$value = $decoded;
+			}
+		}
+
 		$value = rest_sanitize_object( $value );
 
 		foreach ( $value as $property => $v ) {

@liaison commented on PR #11371:


6 weeks ago
#24

Updated the PR to incorporate the implementation suggested by @abcd95. This version provides better type safety checks and aligns the logic with parse_json_params(). Also added the inline documentation for better maintainability. Thanks @abcd95 for the guidance!

#25 follow-up: @westonruter
6 weeks ago

Something that just came to mind: the abilities in the Abilities API can be exposed in the REST API. When an ability is read-only, then a GET request is made. Nevertheless, the input_schema may involve an object of properties.

Consider this Abilities API code:

<?php
add_action(
        'wp_abilities_api_init',
        static function () {
                wp_register_ability(
                        'abilities-experiment/get-post',
                        array(
                                'label'               => __( 'Get Post', 'abilities-experiment' ),
                                'description'         => __( 'Gets fields for a post', 'abilities-experiment' ),
                                'category'            => 'site',
                                'input_schema'        => array(
                                        'type'                 => 'object',
                                        'required'             => array( 'post' ),
                                        'properties'           => array(
                                                'post' => array(
                                                        'type' => 'object',
                                                        'required' => array( 'id' ),
                                                        'properties' => array(
                                                                'id' => array(
                                                                        'type'        => 'integer',
                                                                        'description' => __( 'The ID of the post to get.', 'abilities-experiment' ),
                                                                        'minimum'     => 1,
                                                                ),
                                                        )
                                                ),
                                                'fields' => array(
                                                        'type' => 'array',
                                                        'items' => array(
                                                                'type' => 'string',
                                                        ),
                                                )
                                        ),
                                ),
                                'output_schema'       => array(
                                        'type'                 => 'object',
                                ),
                                'execute_callback'    => static function ( array $input ): array|WP_Error {
                                        $post = get_post( $input['post']['id'] );
                                        if ( ! $post ) {
                                                return new WP_Error( 'post_not_found', __( 'Post not found.', 'abilities-experiment' ), array( 'status' => 404 ) );
                                        }
                                        $data = $post->to_array();
                                        if ( $input['fields'] ) {
                                                $data = wp_array_slice_assoc( $data, $input['fields'] );
                                        }
                                        return $data;
                                },
                                'permission_callback' => static function ( array $input ): bool {
                                        return current_user_can( 'edit_posts', $input['post']['id'] );
                                },
                                'meta'                => array(
                                        'annotations'  => array(
                                                'readonly'    => true,
                                                'destructive' => false,
                                                'idempotent'  => false,
                                        ),
                                        'show_in_rest' => true,
                                ),
                        )
                );
        }
);

Using the Abilities API client module as follows:

(await import("@wordpress/abilities")).executeAbility(
  "abilities-experiment/get-post",
  { post: { id: 1 }, fields: ["post_content", "post_author"] },
);

This results in an HTTP request being made to the REST API as follows:

/wp-json/wp-abilities/v1/abilities/abilities-experiment/get-post/run?input%5Bpost%5D%5Bid%5D=1&input%5Bfields%5D%5B0%5D=post_content&input%5Bfields%5D%5B1%5D=post_author&_locale=user

With the brackets decoded:

/wp-json/wp-abilities/v1/abilities/abilities-experiment/get-post/run?input[post][id]=1&input[fields][0]=post_content&input[fields][1]=post_author&_locale=user

So, note that @wordpress/abilities (er, @wordpress/core-abilities) is already handling this correctly, converting a nested object into a list of URL query params.

See logic in createServerCallback():

// For GET and DELETE requests, pass the input as query parameters.
path = addQueryArgs( path, { input } );

This addQueryArgs() function is part of the @wordpress/url package: https://developer.wordpress.org/block-editor/reference-guides/packages/packages-url/#addqueryargs

So you can use that instead of the qs package.

#26 in reply to: ↑ 25 @dsmy
6 weeks ago

@westonruter I tested both encoding paths against two schema shapes on WordPress trunk (unpatched):

Schema A — fully-typed properties (your example shape):
addQueryArgs() bracket notation, booleans preserved. rest_sanitize_value_from_schema() coerces correctly when property types are declared. You're right that this works today.

JSON.stringify gives 400 rest_invalid_type (the bug, unpatched).

Schema B — additionalProperties: true, no declared property types (our use case):
addQueryArgs() bracket notation are accepted, but types collapse: { lock: { move: false, remove: true }, dropCap: false } arrives as { lock: { move: "false", remove: "true" }, dropCap: "false" }. No schema type hints no coercion.

JSON.stringify gives 400 rest_invalid_type (same bug).

So addQueryArgs() solves the problem for endpoints where every nested property is typed in the schema. For additionalProperties schemas and for block attribute filters where you can't pre-declare every possible key bracket notation is lossy and JSON.stringify is the only lossless encoding path.

This confirms the patch is still needed. It closes the gap for both schema shapes, consistent with how parse_json_params() already handles POST bodies.

Replying to westonruter:

Something that just came to mind: the abilities in the Abilities API can be exposed in the REST API. When an ability is read-only, then a GET request is made. Nevertheless, the input_schema may involve an object of properties.

Consider this Abilities API code:

<?php
add_action(
        'wp_abilities_api_init',
        static function () {
                wp_register_ability(
                        'abilities-experiment/get-post',
                        array(
                                'label'               => __( 'Get Post', 'abilities-experiment' ),
                                'description'         => __( 'Gets fields for a post', 'abilities-experiment' ),
                                'category'            => 'site',
                                'input_schema'        => array(
                                        'type'                 => 'object',
                                        'required'             => array( 'post' ),
                                        'properties'           => array(
                                                'post' => array(
                                                        'type' => 'object',
                                                        'required' => array( 'id' ),
                                                        'properties' => array(
                                                                'id' => array(
                                                                        'type'        => 'integer',
                                                                        'description' => __( 'The ID of the post to get.', 'abilities-experiment' ),
                                                                        'minimum'     => 1,
                                                                ),
                                                        )
                                                ),
                                                'fields' => array(
                                                        'type' => 'array',
                                                        'items' => array(
                                                                'type' => 'string',
                                                        ),
                                                )
                                        ),
                                ),
                                'output_schema'       => array(
                                        'type'                 => 'object',
                                ),
                                'execute_callback'    => static function ( array $input ): array|WP_Error {
                                        $post = get_post( $input['post']['id'] );
                                        if ( ! $post ) {
                                                return new WP_Error( 'post_not_found', __( 'Post not found.', 'abilities-experiment' ), array( 'status' => 404 ) );
                                        }
                                        $data = $post->to_array();
                                        if ( $input['fields'] ) {
                                                $data = wp_array_slice_assoc( $data, $input['fields'] );
                                        }
                                        return $data;
                                },
                                'permission_callback' => static function ( array $input ): bool {
                                        return current_user_can( 'edit_posts', $input['post']['id'] );
                                },
                                'meta'                => array(
                                        'annotations'  => array(
                                                'readonly'    => true,
                                                'destructive' => false,
                                                'idempotent'  => false,
                                        ),
                                        'show_in_rest' => true,
                                ),
                        )
                );
        }
);

Using the Abilities API client module as follows:

(await import("@wordpress/abilities")).executeAbility(
  "abilities-experiment/get-post",
  { post: { id: 1 }, fields: ["post_content", "post_author"] },
);

This results in an HTTP request being made to the REST API as follows:

/wp-json/wp-abilities/v1/abilities/abilities-experiment/get-post/run?input%5Bpost%5D%5Bid%5D=1&input%5Bfields%5D%5B0%5D=post_content&input%5Bfields%5D%5B1%5D=post_author&_locale=user

With the brackets decoded:

/wp-json/wp-abilities/v1/abilities/abilities-experiment/get-post/run?input[post][id]=1&input[fields][0]=post_content&input[fields][1]=post_author&_locale=user

So, note that @wordpress/abilities (er, @wordpress/core-abilities) is already handling this correctly, converting a nested object into a list of URL query params.

See logic in createServerCallback():

// For GET and DELETE requests, pass the input as query parameters.
path = addQueryArgs( path, { input } );

This addQueryArgs() function is part of the @wordpress/url package: https://developer.wordpress.org/block-editor/reference-guides/packages/packages-url/#addqueryargs

So you can use that instead of the qs package.

#27 @liaison
5 weeks ago

@westonruter, @dsmy Thanks for sharing the context about the Abilities API.

The Abilities API and @wordpress/abilities provide a structured interface for the WordPress ecosystem (like Gutenberg). By using the package, developers benefit from API encapsulation and schema-driven validation, as well as:

Security: Execution via this package respects WordPress's built-in permission callbacks.

State Management: Deep integration with Data Store and Caching for the Gutenberg Editor.

Context Awareness: Improved handling for Multisite environments and metadata annotations.

Implementing json_decode directly in rest-api.php provides a more "universal" approach. This keeps the REST API decoupled from any specific client-side library, which is essential for:

Diverse Clients: AI agents (via MCP), standalone scripts, or headless SPAs that may not load the @wordpress/abilities package but still need to pass complex data via GET requests.

Infrastructure Robustness: It ensures JSON-encoded strings are correctly parsed into types that match the input_schema, preventing validation errors during the raw REST path.

@liaison commented on PR #11371:


5 weeks ago
#28

I've added a comprehensive set of unit tests in tests/phpunit/tests/rest-api.php to ensure the fix for #64926 is robust and handles various edge cases. These tests specifically target how rest_validate_value_from_schema and rest_sanitize_value_from_schema handle JSON-encoded strings, which is common in GET requests for complex types.

The new test cases cover:

JSON Array Decoding: Validates that a JSON string like "[1, 2, 3]" is correctly recognized and validated against an array schema.

JSON Object Decoding: Ensures encoded objects like {"id": 123} are properly parsed before validation.

Type Casting during Sanitization: Confirms that nested values (e.g., a string "true" inside a JSON object) are correctly cast to their schema-defined types (e.g., boolean) after decoding.

Graceful Fallback: I've added a specific test (test_validate_invalid_json_falls_back) to ensure that if the input is not valid JSON (e.g., a simple comma-separated string), the logic safely falls back to the original validation flow without throwing errors. This prevents any breaking changes for existing implementations.

These tests pass locally and confirm that the REST API can now flexibly handle both raw structures and JSON-serialized strings in request parameters.

#29 @liaison
5 weeks ago

  • Keywords has-unit-tests added
Note: See TracTickets for help on using tickets.