Make WordPress Core

Opened 2 years ago

Last modified 22 months ago

#56483 new defect (bug)

Weird oneOf behaviour upon validation of post meta value upon GET retrieval via REST API

Reported by: joeyojoeyo12's profile joeyojoeyo12 Owned by:
Milestone: Awaiting Review Priority: normal
Severity: major Version: 6.0.1
Component: REST API Keywords: reporter-feedback
Focuses: rest-api Cc:

Description

I've been checking for a significant amount of time with some other developers, and we can't find reasonable explanations for the behavior described here: https://wordpress.stackexchange.com/questions/409131/oneof-json-schema-validation-not-properly-working-for-custom-post-meta-value

Change History (6)

#1 @joeyojoeyo12
2 years ago

Another example, eventually illustrating some deeper rooted problems:

<?php
add_action(
        'rest_api_init',
        function () {
                
                register_post_meta(
                        post_type: 'my_custom_post',
                        meta_key:  'purchase_details',
                        args:      [
                                           'type'         => 'object',
                                           'single'       => true,
                                           'show_in_rest' => [
                                                   'schema' => [
                                                           'oneOf' => [
                                                                   [
                                                                           'title'                => 'first_possibility',
                                                                           'type'                 => 'object',
                                                                           'properties'           => [
                                                                                   'first_name' => [
                                                                                           'type'     => 'string',
                                                                                           'required' => true
                                                                                   ],
                                                                                   'age'        => [
                                                                                           'type'     => 'integer',
                                                                                           'required' => true
                                                                                   ],
                                                                                   'gender'     => [
                                                                                           'type'     => 'string',
                                                                                           'enum'     => [
                                                                                                   'm',
                                                                                                   'w',
                                                                                                   'f'
                                                                                           ],
                                                                                           'required' => true
                                                                                   ],
                                                                                   'purchase_comment'      => [
                                                                                           'type'     => 'string',
                                                                                           'required' => true
                                                                                   ],
                                                                                   'products'  => [
                                                                                           'type'        => 'array',
                                                                                           'uniqueItems' => true,
                                                                                           'items'       => [
                                                                                                   'type'    => 'string',
                                                                                                   'pattern' => '[A-z]'
                                                                                           ],
                                                                                           'minItems'    => 1,
                                                                                           'required'    => true
                                                                                   ],
                                                                                   'online_id'     => [
                                                                                           'type'     => 'string',
                                                                                           'pattern'  => '[0-9]_[A-z]{10}',
                                                                                           'required' => true
                                                                                   ],
                                                                                   'shop_location'  => [
                                                                                           'type'                 => 'object',
                                                                                           'patternProperties'    => [
                                                                                                   '[A-z]' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type'    => 'string',
                                                                                                                   'pattern' => '[A-z]'
                                                                                                           ],
                                                                                                           'minItems'    => 0
                                                                                                   ]
                                                                                           ],
                                                                                           'minProperties'        => 0,
                                                                                           'additionalProperties' => false,
                                                                                           'required'             => true
                                                                                   ],
                                                                                   'feedbacks'  => [
                                                                                           'type'                 => [
                                                                                                   'object',
                                                                                                   'null'
                                                                                           ],
                                                                                           'properties'           => [
                                                                                                   'intros' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'required'    => true
                                                                                                   ],
                                                                                                   'mains' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'required'    => true
                                                                                                   ],
                                                                                                   'submains' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'required'    => true
                                                                                                   ],
                                                                                                   'footers' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'required'    => true
                                                                                                   ],
                                                                                                   'signatures' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'required'    => true
                                                                                                   ],
                                                                                                   'addresses' => [
                                                                                                           'type'     => 'array',
                                                                                                           'items'    => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'required' => true
                                                                                                   ]
                                                                                           ],
                                                                                           'additionalProperties' => false,
                                                                                           'required'             => true
                                                                                   ]
                                                                           ],
                                                                           'additionalProperties' => false
                                                                   ],
                                                                   [
                                                                           'title'                => 'second_possibility',
                                                                           'type'                 => 'object',
                                                                           'properties'           => [
                                                                                   'first_name'    => [
                                                                                           'type'     => 'string',
                                                                                           'required' => true
                                                                                   ],
                                                                                   'order_name'     => [
                                                                                           'type'     => 'string',
                                                                                           'required' => true
                                                                                   ],
                                                                                   'product_name'     => [
                                                                                           'type'        => 'array',
                                                                                           'uniqueItems' => true,
                                                                                           'items'       => [
                                                                                                   'type'    => 'string',
                                                                                                   'pattern' => '[A-z]{5}'
                                                                                           ],
                                                                                           'minItems'    => 1,
                                                                                           'required'    => true
                                                                                   ],
                                                                                   'product_category'      => [
                                                                                           'type'                 => 'object',
                                                                                           'patternProperties'    => [
                                                                                                   '[A-z]{5}' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'minItems'    => 0
                                                                                                   ]
                                                                                           ],
                                                                                           'minProperties'        => 1,
                                                                                           'additionalProperties' => false,
                                                                                           'required'             => true
                                                                                   ],
                                                                                   'online_name'        => [
                                                                                           'type'     => 'string',
                                                                                           'pattern'  => '[A-z]{10}',
                                                                                           'required' => true
                                                                                   ],
                                                                                   'shop_location'  => [
                                                                                           'type'                 => 'object',
                                                                                           'patternProperties'    => [
                                                                                                   '[A-z]' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type'    => 'string',
                                                                                                                   'pattern' => '[A-z]'
                                                                                                           ],
                                                                                                           'minItems'    => 0
                                                                                                   ]
                                                                                           ],
                                                                                           'minProperties'        => 0,
                                                                                           'additionalProperties' => false,
                                                                                           'required'             => true
                                                                                   ],
                                                                                   'feedbacks'  => [
                                                                                           'type'                 => [
                                                                                                   'object',
                                                                                                   'null'
                                                                                           ],
                                                                                           'properties'           => [
                                                                                                   'intros' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'required'    => true
                                                                                                   ],
                                                                                                   'mains' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'required'    => true
                                                                                                   ],
                                                                                                   'submains' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'required'    => true
                                                                                                   ],
                                                                                                   'footers' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'required'    => true
                                                                                                   ],
                                                                                                   'signatures' => [
                                                                                                           'type'        => 'array',
                                                                                                           'uniqueItems' => true,
                                                                                                           'items'       => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'required'    => true
                                                                                                   ],
                                                                                                   'addresses' => [
                                                                                                           'type'     => 'array',
                                                                                                           'items'    => [
                                                                                                                   'type' => 'string'
                                                                                                           ],
                                                                                                           'required' => true
                                                                                                   ]
                                                                                           ],
                                                                                           'additionalProperties' => false,
                                                                                           'required'             => true
                                                                                   ],
                                                                                   'profile_doc' => [
                                                                                           'type'     => 'string',
                                                                                           'pattern'  => '^[0-9]+\.(jpg|jpeg|png)$',
                                                                                           'required' => true
                                                                                   ]
                                                                           ],
                                                                           'additionalProperties' => false
                                                                   ]
                                                           ]
                                                   ]
                                           ]
                                   ]
                );
  
                register_post_meta(
                        post_type: 'my_custom_post',
                        meta_key:  'product_category_id',
                        args:      [
                                           'type'          => 'integer',
                                           'single'        => true,
                                           'auth_callback' => function (
                                                   $allowed,
                                                   $meta_key,
                                                   $object_id,
                                                   $user_id,
                                                   $cap,
                                                   $caps
                                           ) {
                                                   return current_user_can( 'edit_dashboard' );
                                           },
                                           'show_in_rest'  => [
                                                   'schema' => [
                                                           'type'    => 'integer',
                                                           'minimum' => 1
                                                   ]
                                           ]
                                   ]
                );
  
                register_post_meta(
                        post_type: 'my_custom_post',
                        meta_key:  'product_shop_id',
                        args:      [
                                           'type'          => 'integer',
                                           'single'        => true,
                                           'auth_callback' => function (
                                                   $allowed,
                                                   $meta_key,
                                                   $object_id,
                                                   $user_id,
                                                   $cap,
                                                   $caps
                                           ) {
                                                   return current_user_can( 'edit_dashboard' );
                                           },
                                           'show_in_rest'  => [
                                                   'schema' => [
                                                           'type'    => 'integer',
                                                           'minimum' => 0
                                                   ]
                                           ]
                                   ]
                );
                
        }
);

With this setting, try to add a new

purchase_details

meta value via

add_post_meta

, and then to try to retrieve it via

REST

without its value being

null

.

SIMPLER EXAMPLE

Here you go with another, much simpler example, this time with a REST route. Let's say you have an endpoint to which you want to submit fruit data. Every distinct fruit (banana, apple, pear) has its own set of attributes:

<?php
$banana_attributes = [
    "bananas_color" => [
        'type' => 'string',
        'pattern' => '^yellow$',
        'required' => true
    ],
    "bananas_age" => [
        'type' => 'integer',
        'enum' => [2,3,4,5],
        'required' => true
    ]
];

$apple_attributes = [
    "apples_color" => [
        'type' => 'string',
        'pattern' => '^red$',
        'required' => true
    ],
    "apples_radius" => [
        'type' => 'integer',
        'enum' => [2,3,4,5],
        'required' => true
    ]
];

$pear_attributes = [
    "pears_color" => [
        'type' => 'string',
        'pattern' => '^green$',
        'required' => true
    ],
    "pears_height" => [
        'type' => 'integer',
        'enum' => [2,3,4,5],
        'required' => true
    ]
];

You want to implement JSON Schema logic using oneOf that validates an input payload to contain either only banana attributes, banana and apple attributes or banana and apple and pear attributes. A payload like this would for example pass:

{
  "data": {
    "bananas_color": "yellow",
    "bananas_age": 4,
    "apples_color": "red",
    "apples_radius": 3,
    "pears_color": "green",
    "pears_height": 5 
  }
}

So I would do something like:

<?php
register_rest_route(
    'mynamespace/v1',
    '/fruits',
    [
        [
            'methods'             => 'POST',
            'permission_callback' => '__return_true',
            'callback'            => [
                FruitsController::class,
                'process'
            ],
            'args'                => [
                'data' => [
                    'type'     => 'object',
                    'additionalProperties' => false,
                    'required' => true,
                    'oneOf'    => [
                        [
                            'title'                => 'only_banana',
                            'type'                 => 'object',
                            'additionalProperties' => false,
                            'properties'           => $banana_attributes
                        ],
                        [
                            'title'                => 'banana_and_apple',
                            'type'                 => 'object',
                            'additionalProperties' => false,
                            'properties'           => array_merge(
                                $banana_attributes,
                                $apple_attributes
                            )
                        ],
                        [
                            'title'                => 'banana_and_apple_and_pear',
                            'type'                 => 'object',
                            'additionalProperties' => false,
                            'properties'           => array_merge(
                                $banana_attributes,
                                $apple_attributes,
                                $pear_attributes
                            )
                        ]
                    ]
                ]
            ]
        ]
    ]
);

Now with this, I have two problems:

A) As @TimothyBlynJacobs said; the above code will block incoming requests with a 400. You actually have to set additionalProperties on the level of the oneOf key to true, or simply omit the additionalProperties key there (which I believe results in the same, as the default value of that one is true). The consequence of this is that I cannot block incoming requests that have more data in the payload than needed, correct?

B) If I'm understanding @TimothyBlynJacobs correctly, he mentioned that setting type => object on the same level as oneOf is not necessary. But you will notice in this example, that if you omit the type key at the oneOf level, the validation will no longer work. So I figured you have to specify it at both levels; can you confirm that?

Then, I also have a question regarding how WordPress executes validation and sanitization based on JSON Schema:

C) When I started with WP REST, I've always specified:

'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'rest_sanitize_request_arg'

For every argument, to ensure that all arguments are both validated and sanitized according to what I specified in the JSON Schema. I now noticed that WP seems to do at least the validation by default; even if I omit the above-mentioned. Does WP now actually automatically trigger the default validation rest_validate_request_arg and sanitization rest_sanitize_request_arg functions based on what you've provided for your JSON schema? Or do I stell have to provide it explicitly to every argument?

D) In my example above, to simplify the JSON Schema related to the usecase, I've grouped the submitted payload into an inner object key data. This means that I now have to adapt the way I submit payloads to endpoints using oneOf logic; by wrapping the payloads in an inner {"data":{}} object. Is that the way to go; or can you in WP JSON Schema maybe directly tell the args that the payload is a simply object; basically under no key?

Last edited 2 years ago by joeyojoeyo12 (previous) (diff)

#2 @anna.bansaghi
2 years ago

Hi, I was able to reproduce the response having null with your workflow, but do not know if the cause is the same as in your case. I have misspelled a value which was defined with pattern in the input meta. Would you mind sharing your input meta here?

So in my case I had inserted an invalid meta with add_post_meta(), and wanted the fetch it with REST API. But REST API validates the value against the schema, and because it was invalid, so the response was null.

One might argue that a detailed error response would be more useful, however a better argue would be to use REST API for all operations (create, read, update, delete), so all database operations will be validated against the schema.

So my next try was using REST API instead of add_post_meta(), and the response immediately was an error message with detailed information about the misspelled value. It was not possible to create an invalid meta at the first place!

As I said before, it is possible that your issue is caused by something else! It would be nice :

  • to see your input meta here, so we can rule out the invalid meta issue, and
  • if you could try to use REST API for creating the meta, and see what would be the response to that.
Last edited 2 years ago by anna.bansaghi (previous) (diff)

#3 @anna.bansaghi
2 years ago

  • Keywords reporter-feedback added; needs-patch removed

#4 @TimothyBlynJacobs
2 years ago

I'm sorry you're experiencing issues @joeyojoeyo12. Could you try and simplify your replication example to the bare minimum required to trigger the error? It is a bit difficult to follow such a large example.

To clarify a couple of things. Yes, when using oneOf, the REST API ensures that exactly one schema matches the input value. If more than one schema matches successfully, then the validation will fail. If this isn't the behavior you are looking for, try using the anyOf keyword. This indicates that you want to match any of the provided schemas.

Can you clarify where you saw oneOf overwritten to anyOf? As far as I known, we never assign a value of anyOf to a schema.

Omitting type when setting your schema in show_in_rest won't actually remove the type definition because WP_REST_Meta_Fields::get_registered_fields defaults the type keyword to the top-level type you set in register_meta.

While not required, specifying a type in addition to a oneOf or anyOf keyword is supported. However, that type must then be valid for all schemas used in oneOf or anyOf.

Validation is supposed to continue after the oneOf or anyOf keywords are processed successfully. So if you are registering a meta key where we default additionalProperties to be false, then it would be expected behavior that your schema validation fails. I believe you should be able to work around this by also setting additionalProperties to true at the same level that you set oneOf.

#5 @joeyojoeyo12
2 years ago

Hey there; apologies on my late reply and thanks for your help; I've added a simpler example above that highlights the issues I'm having.

#6 @TimothyBlynJacobs
22 months ago

In the future @joeyojoeyo12, it'd be best to add the details in the follow up comment instead of editing the first one. It makes things easier to track that way.

A) The consequence of this is that I cannot block incoming requests that have more data in the payload than needed, correct?

You can using additionalProperties.

B) So I figured you have to specify it at both levels; can you confirm that?

You can omit it, but you have to then specify your schema callbacks manually, see my next reply.

C) do I still have to provide it explicitly to every argument?

It depends on how you register your route. If you use WP_REST_Controller::get_item_schema combined with WP_REST_Controller::get_endpoint_args_for_item_schema or simply rest_get_endpoint_args_for_schema, the args definition for your route will have the default JSON Schema validation and sanitization callbacks automatically applied.

Otherwise, WP_REST_Request will automatically apply the schema sanitization via rest_parse_request_arg but only if the arg has a specified type.

D) can you in WP JSON Schema maybe directly tell the args that the payload is a simply object; basically under no key?

No. The REST API doesn't support this behavior. A lot of the APIs are formulated around request objects being a dictionary with parameter keys that each have a set of validation and sanitization bits attached to it. This may be supported officially in the future, but it would take some doing.

In the mean time, you could set a validate_callback when you register your REST API route alongside callback and permission_callback. It is passed the entire WP_REST_Request object. Then you can call the sanitize and validate callbacks directly.

Here is a corrected schema.

[
	'data' => [
		'sanitize_callback' => 'rest_sanitize_request_arg',
		'validate_callback' => 'rest_validate_request_arg',
		'required'          => true,
		'oneOf'             => [
			[
				'title'                => 'only_banana',
				'type'                 => 'object',
				'additionalProperties' => false,
				'properties'           => $banana_attributes
			],
			[
				'title'                => 'banana_and_apple',
				'type'                 => 'object',
				'additionalProperties' => false,
				'properties'           => array_merge(
					$banana_attributes,
					$apple_attributes
				)
			],
			[
				'title'                => 'banana_and_apple_and_pear',
				'type'                 => 'object',
				'additionalProperties' => false,
				'properties'           => array_merge(
					$banana_attributes,
					$apple_attributes,
					$pear_attributes
				)
			]
		]
	]
]
Note: See TracTickets for help on using tickets.