diff --git a/CHANGELOG.md b/CHANGELOG.md index b546e730..9c5d2fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- An array-typed `form` parameter no longer rejects a single value. + `ParameterDeserializer::deserializeForm()` decided whether a + non-exploded value was an array by looking for a comma instead of at + the declared schema type, so `?include=author,comments` deserialized + to a list while `?include=author` stayed a string and failed the + `type: array` check with `TypeMismatchError`. Form now routes on the + schema type through `splitBySeparator()` — as `simple`, `matrix`, + `label` and `cookie` already did — yielding a one item list for a + lone value and an empty list for `?include=`. The comma heuristic + remains for parameters that declare no array type, and `explode` + handling is unchanged. (#58) + ## [0.7.0] Preparation for the 1.0.0 stable release. This section tracks work that diff --git a/src/Validator/Request/ParameterDeserializer.php b/src/Validator/Request/ParameterDeserializer.php index 4268acea..a66fed8f 100644 --- a/src/Validator/Request/ParameterDeserializer.php +++ b/src/Validator/Request/ParameterDeserializer.php @@ -32,7 +32,7 @@ public function deserialize(mixed $value, Parameter $param): array|int|string|fl if (is_array($normalized)) { return 'form' === $style - ? $this->deserializeForm($normalized, $param->explode) + ? $this->deserializeForm($normalized, $param) : $normalized; } @@ -41,7 +41,7 @@ public function deserialize(mixed $value, Parameter $param): array|int|string|fl 'matrix' => $this->deserializeMatrix($normalized, $param), 'label' => $this->deserializeLabel($normalized, $param), 'simple' => $this->deserializeSimple($normalized, $param), - 'form' => $this->deserializeForm($normalized, $param->explode), + 'form' => $this->deserializeForm($normalized, $param), 'pipeDelimited' => $this->deserializePipeDelimited($normalized), 'spaceDelimited' => $this->deserializeSpaceDelimited($normalized), 'cookie' => $this->deserializeCookie($normalized, $param), @@ -103,10 +103,10 @@ private function deserializeSimple(string $value, Parameter $param): array|strin return $this->splitBySeparator($value, ','); } - private function deserializeForm(array|string $value, bool $explode): array|int|string + private function deserializeForm(array|string $value, Parameter $param): array|int|string { if (is_array($value)) { - if ($explode) { + if ($param->explode) { return $value; } @@ -114,7 +114,15 @@ private function deserializeForm(array|string $value, bool $explode): array|int| return implode(',', $value); } - if (false === $explode && str_contains($value, ',')) { + if ($param->explode) { + return $value; + } + + if ($this->isArrayType($param)) { + return $this->splitBySeparator($value, ','); + } + + if (str_contains($value, ',')) { $this->assertWithinItemLimit($value, ','); return explode(',', $value); diff --git a/tests/Functional/Request/HeaderValidationTest.php b/tests/Functional/Request/HeaderValidationTest.php index 62796c54..85bc0470 100644 --- a/tests/Functional/Request/HeaderValidationTest.php +++ b/tests/Functional/Request/HeaderValidationTest.php @@ -343,8 +343,12 @@ public function header_array_type_with_comma_separated_string_passes(): void $this->assertSame('/test', $operation->path); } + /** + * A lone value is a one item list, not a scalar, so the schema it fails + * is `minItems` rather than `type`. + */ #[Test] - public function header_array_type_with_single_value_throws_type_mismatch(): void + public function header_array_type_with_single_value_throws_min_items(): void { $yaml = <<<'YAML' openapi: 3.1.0 @@ -377,7 +381,7 @@ public function header_array_type_with_single_value_throws_type_mismatch(): void $request = $this->psrFactory->createServerRequest('GET', '/test') ->withHeader('X-Tags', 'solo'); - $this->expectException(TypeMismatchError::class); + $this->expectException(MinItemsError::class); $validator->validateRequest($request); } diff --git a/tests/Functional/Request/QueryParameterEdgeCasesTest.php b/tests/Functional/Request/QueryParameterEdgeCasesTest.php index e5f8bc27..45d68b0b 100644 --- a/tests/Functional/Request/QueryParameterEdgeCasesTest.php +++ b/tests/Functional/Request/QueryParameterEdgeCasesTest.php @@ -1455,6 +1455,58 @@ public function qp_07_query_parser_bracket_key_produces_nested_array(): void $this->assertSame(['arr' => ['a']], $result); } + /** + * A non-exploded `form` array parameter -- the JSON:API `include` shape -- + * accepts any number of items, including one and none. + */ + #[Test] + #[DataProvider('provideFormArrayItemCounts')] + public function form_array_accepts_any_item_count(string $uri): void + { + $yaml = <<fromYamlString($yaml) + ->build(); + + $operation = $validator->validateRequest( + $this->psrFactory->createServerRequest('GET', $uri), + ); + + $this->assertSame('/articles', $operation->path); + } + + /** + * @return array + */ + public static function provideFormArrayItemCounts(): array + { + return [ + 'two items' => ['/articles?include=author,comments'], + 'one item' => ['/articles?include=author'], + 'no items' => ['/articles?include='], + ]; + } + /** * @return array */ diff --git a/tests/Integration/Validator/Request/ParameterDeserializerTest.php b/tests/Integration/Validator/Request/ParameterDeserializerTest.php index 9110d271..18e1b99e 100644 --- a/tests/Integration/Validator/Request/ParameterDeserializerTest.php +++ b/tests/Integration/Validator/Request/ParameterDeserializerTest.php @@ -275,6 +275,70 @@ public function deserialize_form_string_without_comma_no_explode(): void $this->assertSame('value', $result); } + #[Test] + public function deserialize_form_array_single_value_returns_single_element_array(): void + { + $param = new Parameter( + name: 'tags', + in: 'query', + style: 'form', + explode: false, + schema: new Schema(type: 'array'), + ); + + $result = $this->deserializer->deserialize('solo', $param); + + $this->assertSame(['solo'], $result); + } + + #[Test] + public function deserialize_form_array_with_comma_separated_values_returns_array(): void + { + $param = new Parameter( + name: 'tags', + in: 'query', + style: 'form', + explode: false, + schema: new Schema(type: 'array'), + ); + + $result = $this->deserializer->deserialize('blue,black,brown', $param); + + $this->assertSame(['blue', 'black', 'brown'], $result); + } + + #[Test] + public function deserialize_form_array_empty_value_returns_empty_array(): void + { + $param = new Parameter( + name: 'tags', + in: 'query', + style: 'form', + explode: false, + schema: new Schema(type: 'array'), + ); + + $result = $this->deserializer->deserialize('', $param); + + $this->assertSame([], $result); + } + + #[Test] + public function deserialize_form_array_with_nullable_type_union_returns_array(): void + { + $param = new Parameter( + name: 'tags', + in: 'query', + style: 'form', + explode: false, + schema: new Schema(type: ['array', 'null']), + ); + + $result = $this->deserializer->deserialize('solo', $param); + + $this->assertSame(['solo'], $result); + } + #[Test] public function deserialize_unknown_style_returns_value(): void {