Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions src/Validator/Request/ParameterDeserializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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),
Expand Down Expand Up @@ -103,18 +103,26 @@ 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;
}

/** @var array<int, scalar> $value */
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);
Expand Down
8 changes: 6 additions & 2 deletions tests/Functional/Request/HeaderValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down
52 changes: 52 additions & 0 deletions tests/Functional/Request/QueryParameterEdgeCasesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <<<YAML
openapi: 3.0.0
info:
title: Include API
version: 1.0.0
paths:
/articles:
get:
parameters:
- name: include
in: query
style: form
explode: false
schema:
type: array
items:
type: string
responses:
'200':
description: OK
YAML;
$validator = OpenApiValidatorBuilder::create()
->fromYamlString($yaml)
->build();

$operation = $validator->validateRequest(
$this->psrFactory->createServerRequest('GET', $uri),
);

$this->assertSame('/articles', $operation->path);
}

/**
* @return array<non-empty-string, array{non-empty-string}>
*/
public static function provideFormArrayItemCounts(): array
{
return [
'two items' => ['/articles?include=author,comments'],
'one item' => ['/articles?include=author'],
'no items' => ['/articles?include='],
];
}

/**
* @return array<non-empty-string, array{non-empty-string, non-empty-string}>
*/
Expand Down
64 changes: 64 additions & 0 deletions tests/Integration/Validator/Request/ParameterDeserializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down