From 892a6909e75d41660434cbd2308e32b04a1ba1ac Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Fri, 4 Jul 2025 17:09:21 +0300 Subject: [PATCH 1/3] wip deep parameters in query string --- .../RequestBodyExtension.php | 45 +++++++- .../RequestBodyExtensionTest.php | 103 ++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index b837ce753..5600ad8f7 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -11,6 +11,7 @@ use Dedoc\Scramble\Support\Generator\Reference; use Dedoc\Scramble\Support\Generator\RequestBodyObject; use Dedoc\Scramble\Support\Generator\Schema; +use Dedoc\Scramble\Support\Generator\Types\ArrayType; use Dedoc\Scramble\Support\Generator\Types\ObjectType; use Dedoc\Scramble\Support\Generator\Types\Type; use Dedoc\Scramble\Support\Generator\TypeTransformer; @@ -63,7 +64,7 @@ public function handle(Operation $operation, RouteInfo $routeInfo) if (in_array($operation->method, static::HTTP_METHODS_WITHOUT_REQUEST_BODY)) { $operation->addParameters( - $this->convertDotNamedParamsToComplexStructures($allParams) + $this->convertDotNamedParamsToFlatParams($allParams) ); return; @@ -73,7 +74,7 @@ public function handle(Operation $operation, RouteInfo $routeInfo) ->partition(fn (Parameter $p) => $p->in !== 'body' || $p->getAttribute('isInQuery') || $p->getAttribute('nonBody')) ->map->toArray(); - $operation->addParameters($this->convertDotNamedParamsToComplexStructures($nonBodyParams)); + $operation->addParameters($this->convertDotNamedParamsToFlatParams($nonBodyParams)); if (! $bodyParams) { return; @@ -169,6 +170,46 @@ protected function convertDotNamedParamsToComplexStructures($params) return (new DeepParametersMerger(collect($params)))->handle(); } + /** + * @param Parameter[] $params + * @return array + */ + protected function convertDotNamedParamsToFlatParams($params): array + { + /** @var Collection $paramsByKeys */ + $paramsByKeys = collect($params)->keyBy->name; + + return collect($params) + /* + * Rejecting array "container" parameters for cases when there are properties specified. For example: + * ['filter' => 'array', 'filter.accountable' => 'integer'] + * In this ruleset `filter` should not be documented at all as the accountable is enough. + */ + ->reject(fn (Parameter $p) => $paramsByKeys->keys()->some(fn (string $key) => Str::startsWith($key, $p->name.'.'))) + ->map(function (Parameter $originalParameter) { + $parameter = clone $originalParameter; + + $parameter->name = Str::of($parameter->name) + ->replace('.*.', '.0.') + ->explode('.') + ->map(fn ($str, $i) => $i === 0 ? $str : ($str === '*' ? '[]' : "[$str]")) + ->join(''); + + if ($parameter->schema->type instanceof ArrayType) { + $parameter->name .= '[]'; + } + + if (Str::endsWith($parameter->name, '[]') && !$parameter->schema->type instanceof ArrayType) { + $parameter->schema->type = (new ArrayType()) + ->setItems($parameter->schema->type) + ->addProperties($parameter->schema->type); + } + + return $parameter; + }) + ->all(); + } + protected function getMediaType(Operation $operation, RouteInfo $routeInfo, array $bodyParams): string { if ( diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index efb3c2e76..2f2bb4c8b 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -584,3 +584,106 @@ public function __invoke(Request $request) $request->validate(['foo' => 'integer']); } } + +it('documents deep query parameters according to how they can be read by laravel api', function () { + $document = generateForRoute(fn () => RouteFacade::get('test', RequestBodyExtensionTest_DeepQueryParametersController::class)); + + expect($parameters = $document['paths']['/test']['get']['parameters']) + ->toHaveCount(1) + ->and($parameters[0]) + ->toBe([ + 'name' => 'filter[accountable]', + 'in' => 'query', + 'schema' => [ + 'type' => 'integer', + ], + ]); +}); +class RequestBodyExtensionTest_DeepQueryParametersController +{ + public function __invoke(Request $request) + { + $request->validate([ + 'filter.accountable' => 'integer' + ]); + } +} + +it('documents deep query parameters with container according to how they can be read by laravel api', function () { + $document = generateForRoute(fn () => RouteFacade::get('test', RequestBodyExtensionTest_DeepQueryParametersWithContainerController::class)); + + expect($parameters = $document['paths']['/test']['get']['parameters']) + ->toHaveCount(1) + ->and($parameters[0]) + ->toBe([ + 'name' => 'filter[accountable]', + 'in' => 'query', + 'schema' => [ + 'type' => 'integer', + ], + ]); +}); +class RequestBodyExtensionTest_DeepQueryParametersWithContainerController +{ + public function __invoke(Request $request) + { + $request->validate([ + 'filter' => 'array', + 'filter.accountable' => 'integer', + ]); + } +} + +it('documents array query parameters as arrays of some type', function () { + $document = generateForRoute(fn () => RouteFacade::get('test', RequestBodyExtensionTest_ArrayQueryParametersController::class)); + + expect($parameters = $document['paths']['/test']['get']['parameters']) + ->toHaveCount(1) + ->and($parameters[0]) + ->toBe([ + 'name' => 'tags[]', + 'in' => 'query', + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ] + ], + ]); +}); +class RequestBodyExtensionTest_ArrayQueryParametersController +{ + public function __invoke(Request $request) + { + $request->validate([ + 'tags' => 'array', + ]); + } +} + +it('documents array query parameters as arrays of specific type', function () { + $document = generateForRoute(fn () => RouteFacade::get('test', RequestBodyExtensionTest_ArraySpecificQueryParametersController::class)); + + expect($parameters = $document['paths']['/test']['get']['parameters']) + ->toHaveCount(1) + ->and($parameters[0]) + ->toBe([ + 'name' => 'tags[]', + 'in' => 'query', + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'integer', + ] + ], + ]); +}); +class RequestBodyExtensionTest_ArraySpecificQueryParametersController +{ + public function __invoke(Request $request) + { + $request->validate([ + 'tags.*' => 'integer', + ]); + } +} From 08f7323c54ba40b01a3e0fb5480eadb3a6547ed7 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sat, 5 Jul 2025 12:17:13 +0300 Subject: [PATCH 2/3] flatten query parameters improvements --- .../RequestBodyExtension.php | 59 +++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index 5600ad8f7..0089942e2 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -64,7 +64,7 @@ public function handle(Operation $operation, RouteInfo $routeInfo) if (in_array($operation->method, static::HTTP_METHODS_WITHOUT_REQUEST_BODY)) { $operation->addParameters( - $this->convertDotNamedParamsToFlatParams($allParams) + $this->convertDotNamedParamsToQueryParams($allParams) ); return; @@ -74,7 +74,9 @@ public function handle(Operation $operation, RouteInfo $routeInfo) ->partition(fn (Parameter $p) => $p->in !== 'body' || $p->getAttribute('isInQuery') || $p->getAttribute('nonBody')) ->map->toArray(); - $operation->addParameters($this->convertDotNamedParamsToFlatParams($nonBodyParams)); + $operation->addParameters( + $this->convertDotNamedParamsToQueryParams($nonBodyParams) + ); if (! $bodyParams) { return; @@ -172,25 +174,51 @@ protected function convertDotNamedParamsToComplexStructures($params) /** * @param Parameter[] $params - * @return array + * @return Parameter[] */ - protected function convertDotNamedParamsToFlatParams($params): array + protected function convertDotNamedParamsToQueryParams(array $params): array { - /** @var Collection $paramsByKeys */ - $paramsByKeys = collect($params)->keyBy->name; + /** @var Collection $paramsByName */ + $paramsByName = collect($params)->keyBy->name; - return collect($params) + [$convertableParameters, $deepParameters] = collect($params) /* * Rejecting array "container" parameters for cases when there are properties specified. For example: * ['filter' => 'array', 'filter.accountable' => 'integer'] * In this ruleset `filter` should not be documented at all as the accountable is enough. */ - ->reject(fn (Parameter $p) => $paramsByKeys->keys()->some(fn (string $key) => Str::startsWith($key, $p->name.'.'))) - ->map(function (Parameter $originalParameter) { + ->reject(fn (Parameter $p) => $paramsByName->keys()->some(fn (string $key) => Str::startsWith($key, $p->name.'.'))) + ->partition(function (Parameter $p) { + if ($p->getAttribute('isFlat')) { + return true; + } + + $isScalar = ! in_array($p->schema->type->type ?? null, ['array', 'object', null], strict: true); + + $isArrayOfScalar = ($p->schema->type ?? null) instanceof ArrayType + && ! in_array($p->schema->type->items->type ?? null, ['array', 'object', null], strict: true); + + if (! Str::contains($p->name, '*')) { // no nested arrays + return $isScalar || $isArrayOfScalar; + } + + if (Str::endsWith($p->name, '*') && (Str::substrCount($p->name, '*') === 1)) { + return $isScalar; + } + + return false; + }); + + $deepParameters = array_map( + fn (Parameter $p) => tap($p, fn (Parameter $p) => $p->setExtensionProperty('deepObject-style', 'qs')), + $this->convertDotNamedParamsToComplexStructures($deepParameters->all()), + ); + + return collect($convertableParameters) + ->map(function (Parameter $originalParameter) use ($paramsByName) { $parameter = clone $originalParameter; $parameter->name = Str::of($parameter->name) - ->replace('.*.', '.0.') ->explode('.') ->map(fn ($str, $i) => $i === 0 ? $str : ($str === '*' ? '[]' : "[$str]")) ->join(''); @@ -199,6 +227,14 @@ protected function convertDotNamedParamsToFlatParams($params): array $parameter->name .= '[]'; } + if ( + $parameter->name !== $originalParameter->name + && ($sameNameParam = $paramsByName->get($parameter->name)) + && $sameNameParam !== $originalParameter + ) { + return null; + } + if (Str::endsWith($parameter->name, '[]') && !$parameter->schema->type instanceof ArrayType) { $parameter->schema->type = (new ArrayType()) ->setItems($parameter->schema->type) @@ -207,6 +243,9 @@ protected function convertDotNamedParamsToFlatParams($params): array return $parameter; }) + ->filter() + ->values() + ->merge($deepParameters) ->all(); } From 25c45d949d9b25c82c76d43bd2535f9cb409bdba Mon Sep 17 00:00:00 2001 From: romalytvynenko Date: Sat, 5 Jul 2025 09:18:19 +0000 Subject: [PATCH 3/3] Fix styling --- src/Support/OperationExtensions/RequestBodyExtension.php | 6 +++--- .../OperationExtensions/RequestBodyExtensionTest.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index 0089942e2..a307018a6 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -173,7 +173,7 @@ protected function convertDotNamedParamsToComplexStructures($params) } /** - * @param Parameter[] $params + * @param Parameter[] $params * @return Parameter[] */ protected function convertDotNamedParamsToQueryParams(array $params): array @@ -235,8 +235,8 @@ protected function convertDotNamedParamsToQueryParams(array $params): array return null; } - if (Str::endsWith($parameter->name, '[]') && !$parameter->schema->type instanceof ArrayType) { - $parameter->schema->type = (new ArrayType()) + if (Str::endsWith($parameter->name, '[]') && ! $parameter->schema->type instanceof ArrayType) { + $parameter->schema->type = (new ArrayType) ->setItems($parameter->schema->type) ->addProperties($parameter->schema->type); } diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index 2f2bb4c8b..510d8161e 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -604,7 +604,7 @@ class RequestBodyExtensionTest_DeepQueryParametersController public function __invoke(Request $request) { $request->validate([ - 'filter.accountable' => 'integer' + 'filter.accountable' => 'integer', ]); } } @@ -647,7 +647,7 @@ public function __invoke(Request $request) 'type' => 'array', 'items' => [ 'type' => 'string', - ] + ], ], ]); }); @@ -674,7 +674,7 @@ public function __invoke(Request $request) 'type' => 'array', 'items' => [ 'type' => 'integer', - ] + ], ], ]); });