Skip to content

fix: Recurse into object properties and array items when coercing parameters (#60) - #63

Open
shadowhand wants to merge 1 commit into
duyler:mainfrom
shadowhand:fix/60-nested-parameter-coercion
Open

fix: Recurse into object properties and array items when coercing parameters (#60)#63
shadowhand wants to merge 1 commit into
duyler:mainfrom
shadowhand:fix/60-nested-parameter-coercion

Conversation

@shadowhand

Copy link
Copy Markdown

Fixes #60.

Problem

TypeCoercer::coerceToType() has arms for integer, number, boolean and string; object and array fall through to normalizeValue(), which returns arrays untouched. AbstractParameterValidator::validate() then hands the un-coerced container to the schema validator, which correctly rejects "3" against type: integer.

That is not merely strict. Query, path, header and cookie values arrive as strings, so coercion is the only mechanism by which a nested integer / number / boolean could ever validate — against page: {type: object, properties: {limit: {type: integer}}} there is no value a client could send that would pass. A JSON:API style API hits this immediately on page[limit], page[offset] and boolean filter[...] members.

This is distinct from #58: ?ids=1,2,3 already deserializes to a three element array and still fails.

Fix

Type dispatch in TypeCoercer is now Schema-oriented rather than type-string-oriented, so the new object and array arms can recurse over properties and items. Nested null is passed through instead of being normalized to "", so a nullable leaf reaches the schema validator intact. The top-level null contract — return null when the schema admits it, throw TypeMismatchError otherwise — is unchanged.

Per the issue's suggestion, the traversal is not duplicated. RequestBodyCoercer already walked properties / items for request bodies, so that walk moves to AbstractCoercer::coerceDeclaredProperties() and ::coerceDeclaredItems(), parameterized by the recursion callback. Each coercer keeps its own leaf semantics (TypeCoercer normalizes objects and unknown types; RequestBodyCoercer threads nullableAsType), and RequestBodyCoercer's behavior is unchanged — its duplicated walkers are simply gone. Typing the callback let a now-stale MixedAssignment entry drop out of psalm-baseline.xml.

Result

The issue's repro script, verbatim:

PASS  top-level integer            /things?limit=10
PASS  object property, integer     /things?page[limit]=3
PASS  object property, boolean     /things?filter[enabled]=true
PASS  array items, integer         /things?ids=1,2,3

Tests

Written first and watched fail with the reported errors (Property "limit" validation failed, Property "enabled" validation failed, Item at index 0 … Expected type "integer", but got "string" at /0).

  • tests/Unit/Regression/R4/NestedParameterCoercionRegressionTest.php — the issue's four cases through full request validation.
  • tests/Unit/Validator/Request/TypeCoercerNestedTypesTest.php — the unit surface: object properties → integer / number / boolean, array items, array-of-objects, object-holding-array, undeclared members preserved, strict vs non-strict nested failure, nested null under nullable, union types at both the container and leaf level, and coercion left disabled.

coerce_declared_property_that_follows_an_absent_one exists because Infection escaped a continuebreak mutant on the absent-property branch: ordering only matters when a declared property that is missing precedes one that is present. Adding it took the changed files to 95% covered MSI with no escaped mutants in the new code; the four that remain sit in TypeCoercer's pre-existing top-level null block and union loop, which I left alone rather than widen the diff.

Checks

make tests (7150 tests, 14741 assertions, green), make psalm (no errors), make cs-fix and make rector (both clean) per CONTRIBUTING.md.

One deviation worth flagging: the two new AbstractCoercer helpers carry @param callable(mixed, Schema): (...) docblocks — needed for Psalm to infer through the callback — but no inline // comments, per 4e5baf8. Happy to add a // §N exemption: marker or reshape the helpers if you would rather the shared traversal live somewhere other than AbstractCoercer.

…ameters (duyler#60)

TypeCoercer::coerceToType() had arms for integer, number, boolean and
string; object and array fell through to normalizeValue(), which returns
arrays untouched. AbstractParameterValidator then handed the un-coerced
container to the schema validator, which correctly rejected "3" against
type: integer.

The effect was not merely strict: query, path, header and cookie values
arrive as strings, so coercion is the only mechanism by which a nested
integer, number or boolean could ever validate. Against
`page: {type: object, properties: {limit: {type: integer}}}` there was
no value a client could send that would pass, which a JSON:API style API
hits immediately on page[limit], page[offset] and boolean filter members.

Type dispatch is now Schema-oriented rather than type-string-oriented, so
the object and array arms can recurse over properties and items. Nested
null is passed through instead of being normalized to "", letting a
nullable leaf reach the schema validator intact; the top-level null
contract -- return null when the schema admits it, throw TypeMismatchError
otherwise -- is unchanged.

RequestBodyCoercer already did this traversal for request bodies, so the
walk itself moves to AbstractCoercer::coerceDeclaredProperties() and
::coerceDeclaredItems(), parameterized by the recursion callback. Each
coercer keeps its own leaf semantics -- TypeCoercer normalizes objects and
unknown types, RequestBodyCoercer threads nullableAsType -- and
RequestBodyCoercer's behavior is unchanged. Typing the callback let a
stale MixedAssignment entry drop out of psalm-baseline.xml.

Tests were written first and watched fail with the reported errors:
Property "limit" validation failed, Property "enabled" validation failed,
and Item at index 0 ... Expected type "integer", but got "string" at /0.
NestedParameterCoercionRegressionTest drives the issue's four cases
through full request validation; TypeCoercerNestedTypesTest covers the
unit surface, including strict vs non-strict nested failure, nested null,
union types at both levels, and coercion left disabled.

coerce_declared_property_that_follows_an_absent_one exists because
Infection escaped a continue-to-break mutant on the absent-property
branch: ordering only matters when a declared property that is missing
precedes one that is present.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant