Skip to content

Parameter coercion does not recurse into object properties or array items, making nested non-string types unsatisfiable #60

Description

@shadowhand

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions