diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index b837ce753..a307018a6 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->convertDotNamedParamsToQueryParams($allParams) ); return; @@ -73,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->convertDotNamedParamsToComplexStructures($nonBodyParams)); + $operation->addParameters( + $this->convertDotNamedParamsToQueryParams($nonBodyParams) + ); if (! $bodyParams) { return; @@ -169,6 +172,83 @@ protected function convertDotNamedParamsToComplexStructures($params) return (new DeepParametersMerger(collect($params)))->handle(); } + /** + * @param Parameter[] $params + * @return Parameter[] + */ + protected function convertDotNamedParamsToQueryParams(array $params): array + { + /** @var Collection $paramsByName */ + $paramsByName = collect($params)->keyBy->name; + + [$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) => $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) + ->explode('.') + ->map(fn ($str, $i) => $i === 0 ? $str : ($str === '*' ? '[]' : "[$str]")) + ->join(''); + + if ($parameter->schema->type instanceof ArrayType) { + $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) + ->addProperties($parameter->schema->type); + } + + return $parameter; + }) + ->filter() + ->values() + ->merge($deepParameters) + ->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..510d8161e 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', + ]); + } +}