PHP version
8.5
duyler/openapi version
0.7.0
OpenAPI spec version
3.0
Description
TypeCoercer::coerce() coerces a parameter using only the top-level schema type. coerceToType() has arms for integer, number, boolean and string, and everything else falls through to normalizeValue(), which returns arrays untouched:
return match ($type) {
'integer' => $strict ? $this->coerceToIntegerStrict($value) : $this->coerceToInteger($value),
'number' => $strict ? $this->coerceToNumberStrict($value) : $this->coerceToNumber($value),
'boolean' => $strict ? $this->coerceToBooleanStrict($value) : $this->coerceToBoolean($value),
'string' => $this->coerceToString($value),
default => $this->normalizeValue($value), // <-- 'object' and 'array' land here
};
So for a parameter declared type: object (or type: array), the nested values stay strings. AbstractParameterValidator::validate() then hands the un-coerced value straight to the schema validator:
$value = $this->deserializer->deserialize($value, $param);
$value = $this->coercer->coerce($value, $param, $this->coercion, $this->config->strictCoercion);
if (null !== $param->schema) {
$this->schemaValidator->validate($value, $param->schema, $context);
}
…which correctly rejects "3" against type: integer.
The effect is that any non-string type nested inside a parameter schema is unsatisfiable, not merely strict. Query, path, header and cookie values are always strings on the wire, so coercion is the only mechanism by which a nested integer/number/boolean could ever validate. With coercion enabled, ?page[limit]=3 against page: {type: object, properties: {limit: {type: integer}}} can never pass, and there is no value a client could send that would.
This looks like an oversight rather than a design decision, because the same package already does the recursion for request bodies. RequestBodyCoercer::coerceToType() has the two arms TypeCoercer is missing:
'object' => $this->coerceToObject($value, $schema, $strict, $nullableAsType),
'array' => $this->coerceToArray($value, $schema, $strict, $nullableAsType),
and coerceToObject() / coerceToArray() walk properties and items, recursing through coerceInternal(). Parameters simply don't get that treatment.
This is distinct from #58. That issue is about deserializeForm() deciding array-ness by sniffing for a comma — i.e. the shape the value deserializes into. This issue is about coercing the values inside a container that has already deserialized correctly. In the repro below, ?ids=1,2,3 does deserialize to a three element array and then still fails, so fixing #58 would not fix this.
A JSON:API style API feels this immediately, since page[limit], page[offset] and boolean filter[...] members are the conventional spelling for pagination and filtering.
Steps to reproduce
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder;
use Nyholm\Psr7\ServerRequest;
$yaml = <<<'YAML'
openapi: 3.0.0
info:
title: Coercion API
version: 1.0.0
paths:
/things:
get:
parameters:
- name: limit
in: query
schema:
type: integer
- name: page
in: query
style: deepObject
schema:
type: object
properties:
limit:
type: integer
- name: filter
in: query
style: deepObject
schema:
type: object
properties:
enabled:
type: boolean
- name: ids
in: query
style: form
explode: false
schema:
type: array
items:
type: integer
responses:
'200':
description: ok
YAML;
$validator = OpenApiValidatorBuilder::create()
->fromYamlString($yaml)
->enableCoercion()
->build();
$cases = [
'top-level integer ' => '/things?limit=10',
'object property, integer ' => '/things?page[limit]=3',
'object property, boolean ' => '/things?filter[enabled]=true',
'array items, integer ' => '/things?ids=1,2,3',
];
foreach ($cases as $label => $uri) {
$validator->reset();
parse_str((string) parse_url($uri, PHP_URL_QUERY), $query);
$request = new ServerRequest('GET', 'http://localhost' . $uri);
$request = $request->withQueryParams($query);
try {
$validator->validateRequest($request);
printf("PASS %s %s\n", $label, $uri);
} catch (Throwable $e) {
printf("FAIL %s %s\n %s\n", $label, $uri, $e->getMessage());
}
}
Actual result
The top-level integer coerces and passes. Every nested type fails, whichever container it sits in:
PASS top-level integer /things?limit=10
FAIL object property, integer /things?page[limit]=3
Property "limit" validation failed
FAIL object property, boolean /things?filter[enabled]=true
Property "enabled" validation failed
FAIL array items, integer /things?ids=1,2,3
Item at index 0 validation failed: Expected type "integer", but got "string" at /0
Expected: all four pass. page[limit]=3 should coerce to ['limit' => 3], filter[enabled]=true to ['enabled' => true], and ids=1,2,3 to [1, 2, 3], each then validating against its declared schema.
The natural fix is for TypeCoercer to gain 'object' and 'array' arms that recurse over properties and items — ideally by delegating to the same Schema-recursive coercion RequestBodyCoercer already implements, rather than duplicating the traversal. Note the signature difference: TypeCoercer is Parameter-oriented while the recursion needs to be Schema-oriented, so the shared piece is RequestBodyCoercer::coerceInternal()'s shape.
PHP version
8.5
duyler/openapi version
0.7.0
OpenAPI spec version
3.0
Description
TypeCoercer::coerce()coerces a parameter using only the top-level schema type.coerceToType()has arms forinteger,number,booleanandstring, and everything else falls through tonormalizeValue(), which returns arrays untouched:So for a parameter declared
type: object(ortype: array), the nested values stay strings.AbstractParameterValidator::validate()then hands the un-coerced value straight to the schema validator:…which correctly rejects
"3"againsttype: integer.The effect is that any non-string type nested inside a parameter schema is unsatisfiable, not merely strict. Query, path, header and cookie values are always strings on the wire, so coercion is the only mechanism by which a nested
integer/number/booleancould ever validate. With coercion enabled,?page[limit]=3againstpage: {type: object, properties: {limit: {type: integer}}}can never pass, and there is no value a client could send that would.This looks like an oversight rather than a design decision, because the same package already does the recursion for request bodies.
RequestBodyCoercer::coerceToType()has the two armsTypeCoerceris missing:and
coerceToObject()/coerceToArray()walkpropertiesanditems, recursing throughcoerceInternal(). Parameters simply don't get that treatment.This is distinct from #58. That issue is about
deserializeForm()deciding array-ness by sniffing for a comma — i.e. the shape the value deserializes into. This issue is about coercing the values inside a container that has already deserialized correctly. In the repro below,?ids=1,2,3does deserialize to a three element array and then still fails, so fixing #58 would not fix this.A JSON:API style API feels this immediately, since
page[limit],page[offset]and booleanfilter[...]members are the conventional spelling for pagination and filtering.Steps to reproduce
Actual result
The top-level
integercoerces and passes. Every nested type fails, whichever container it sits in:Expected: all four pass.
page[limit]=3should coerce to['limit' => 3],filter[enabled]=trueto['enabled' => true], andids=1,2,3to[1, 2, 3], each then validating against its declared schema.The natural fix is for
TypeCoercerto gain'object'and'array'arms that recurse overpropertiesanditems— ideally by delegating to the sameSchema-recursive coercionRequestBodyCoerceralready implements, rather than duplicating the traversal. Note the signature difference:TypeCoercerisParameter-oriented while the recursion needs to beSchema-oriented, so the shared piece isRequestBodyCoercer::coerceInternal()'s shape.