diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index 6b45756f..d8d9ddb5 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -3,19 +3,22 @@ namespace Dedoc\Scramble\Support\OperationExtensions; use Dedoc\Scramble\Extensions\OperationExtension; +use Dedoc\Scramble\Support\Generator\Combined\AllOf; use Dedoc\Scramble\Support\Generator\Operation; use Dedoc\Scramble\Support\Generator\Parameter; use Dedoc\Scramble\Support\Generator\Reference; use Dedoc\Scramble\Support\Generator\RequestBodyObject; use Dedoc\Scramble\Support\Generator\Schema; +use Dedoc\Scramble\Support\Generator\Types\Type; use Dedoc\Scramble\Support\OperationExtensions\RulesExtractor\FormRequestRulesExtractor; -use Dedoc\Scramble\Support\OperationExtensions\RulesExtractor\RulesToParameters; +use Dedoc\Scramble\Support\OperationExtensions\RulesExtractor\ParametersExtractionResult; +use Dedoc\Scramble\Support\OperationExtensions\RulesExtractor\RequestMethodCallsExtractor; use Dedoc\Scramble\Support\OperationExtensions\RulesExtractor\ValidateCallExtractor; use Dedoc\Scramble\Support\RouteInfo; use Illuminate\Routing\Route; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Str; -use PhpParser\Node\Stmt\ClassMethod; use Throwable; class RequestBodyExtension extends OperationExtension @@ -32,9 +35,10 @@ public function handle(Operation $operation, RouteInfo $routeInfo) */ $routeInfo->getMethodType(); - [$bodyParams, $schemaName, $schemaDescription] = [[], null, null]; + $rulesResults = collect(); + try { - [$bodyParams, $schemaName, $schemaDescription] = $this->extractParamsFromRequestValidationRules($routeInfo->route, $routeInfo->methodNode()); + $rulesResults = collect($this->extractRouteRequestValidationRules($routeInfo, $routeInfo->methodNode())); } catch (Throwable $exception) { if (app()->environment('testing')) { throw $exception; @@ -46,21 +50,11 @@ public function handle(Operation $operation, RouteInfo $routeInfo) ->summary(Str::of($routeInfo->phpDoc()->getAttribute('summary'))->rtrim('.')) ->description($description); - $bodyParamsNames = array_map(fn ($p) => $p->name, $bodyParams); + $allParams = $rulesResults->flatMap->parameters->unique('name')->values()->all(); - $allParams = [ - ...$bodyParams, - ...array_filter( - array_values($routeInfo->requestParametersFromCalls->data), - fn ($p) => ! in_array($p->name, $bodyParamsNames), - ), - ]; [$queryParams, $bodyParams] = collect($allParams) - ->partition(function (Parameter $parameter) { - return $parameter->getAttribute('isInQuery'); - }); - $queryParams = $queryParams->toArray(); - $bodyParams = $bodyParams->toArray(); + ->partition(fn (Parameter $p) => $p->getAttribute('isInQuery')) + ->map->toArray(); $mediaType = $this->getMediaType($operation, $routeInfo, $allParams); @@ -75,41 +69,66 @@ public function handle(Operation $operation, RouteInfo $routeInfo) return; } - $this->addRequestBody( - $operation, - $mediaType, - $bodyParams, - $schemaName, - $schemaDescription, - ); - } + [$schemaResults, $schemalessResults] = $rulesResults->partition('schemaName'); + $schemalessResults = collect([$this->mergeSchemalessRulesResults($schemalessResults->values())]); - protected function addRequestBody(Operation $operation, string $mediaType, array $bodyParams, ?string $schemaName, ?string $schemaDescription) - { - if (empty($bodyParams)) { + $schemas = $schemaResults->merge($schemalessResults) + ->filter(fn (ParametersExtractionResult $r) => count($r->parameters) || $r->schemaName) + ->map(function (ParametersExtractionResult $r) use ($queryParams) { + $qpNames = collect($queryParams)->keyBy('name'); + + $r->parameters = collect($r->parameters)->filter(fn ($p) => ! $qpNames->has($p->name))->values()->all(); + + return $r; + }) + ->values() + ->map($this->makeSchemaFromResults(...)); + + if ($schemas->isEmpty()) { return; } - $requestBodySchema = Schema::createFromParameters($bodyParams); + $schema = $this->makeComposedRequestBodySchema($schemas); + if (! $schema instanceof Reference) { + $schema = Schema::fromType($schema); + } + + $operation->addRequestBodyObject( + RequestBodyObject::make()->setContent($mediaType, $schema), + ); + } - if (! $schemaName) { - $operation->addRequestBodyObject(RequestBodyObject::make()->setContent($mediaType, $requestBodySchema)); + protected function makeSchemaFromResults(ParametersExtractionResult $result): Type + { + $requestBodySchema = Schema::createFromParameters($result->parameters); - return; + if (! $result->schemaName) { + return $requestBodySchema->type; } $components = $this->openApiTransformer->getComponents(); - if (! $components->hasSchema($schemaName)) { - $requestBodySchema->type->setDescription($schemaDescription ?: ''); + if (! $components->hasSchema($result->schemaName)) { + $requestBodySchema->type->setDescription($result->description ?: ''); - $components->addSchema($schemaName, $requestBodySchema); + $components->addSchema($result->schemaName, $requestBodySchema); } - $operation->addRequestBodyObject( - RequestBodyObject::make()->setContent( - $mediaType, - new Reference('schemas', $schemaName, $components), - ) + return new Reference('schemas', $result->schemaName, $components); + } + + protected function makeComposedRequestBodySchema(Collection $schemas) + { + if ($schemas->count() === 1) { + return $schemas->first(); + } + + return (new AllOf)->setItems($schemas->all()); + } + + protected function mergeSchemalessRulesResults(Collection $schemalessResults): ParametersExtractionResult + { + return new ParametersExtractionResult( + parameters: $schemalessResults->values()->flatMap->parameters->unique('name')->values()->all(), ); } @@ -141,37 +160,50 @@ protected function hasBinary($bodyParams): bool }); } - protected function extractParamsFromRequestValidationRules(Route $route, ?ClassMethod $methodNode) + protected function extractRouteRequestValidationRules(RouteInfo $routeInfo, $methodNode) { - [$rules, $nodesResults] = $this->extractRouteRequestValidationRules($route, $methodNode); - - return [ - (new RulesToParameters($rules, $nodesResults, $this->openApiTransformer))->handle(), - $nodesResults[0]->schemaName ?? null, - $nodesResults[0]->description ?? null, + /* + * These are the extractors that are getting types from the validation rules, so it is + * certain that a property must have the extracted type. + */ + $typeDefiningHandlers = [ + new FormRequestRulesExtractor($methodNode, $this->openApiTransformer), + new ValidateCallExtractor($methodNode, $this->openApiTransformer), ]; + + $validationRulesExtractedResults = collect($typeDefiningHandlers) + ->filter(fn ($h) => $h->shouldHandle()) + ->map(fn ($h) => $h->extract($routeInfo)) + ->values() + ->toArray(); + + /* + * This is the extractor that cannot re-define the incoming type but can add new properties. + * Also, it is useful for additional details. + */ + $detailsExtractor = new RequestMethodCallsExtractor; + + $methodCallsExtractedResults = $detailsExtractor->extract($routeInfo); + + return $this->mergeExtractedProperties($validationRulesExtractedResults, $methodCallsExtractedResults); } - protected function extractRouteRequestValidationRules(Route $route, $methodNode) + /** + * @param ParametersExtractionResult[] $rulesExtractedResults + */ + protected function mergeExtractedProperties(array $rulesExtractedResults, ParametersExtractionResult $methodCallsExtractedResult) { - $rules = []; - $nodesResults = []; - - // Custom form request's class `validate` method - if (($formRequestRulesExtractor = new FormRequestRulesExtractor($methodNode))->shouldHandle()) { - if (count($formRequestRules = $formRequestRulesExtractor->extract($route))) { - $rules = array_merge($rules, $formRequestRules); - $nodesResults[] = $formRequestRulesExtractor->node(); - } - } + $rulesParameters = collect($rulesExtractedResults)->flatMap->parameters->keyBy('name'); - if (($validateCallExtractor = new ValidateCallExtractor($methodNode))->shouldHandle()) { - if ($validateCallRules = $validateCallExtractor->extract()) { - $rules = array_merge($rules, $validateCallRules); - $nodesResults[] = $validateCallExtractor->node(); - } - } + $methodCallsExtractedResult->parameters = collect($methodCallsExtractedResult->parameters) + ->filter(fn (Parameter $p) => ! $rulesParameters->has($p->name)) + ->values() + ->all(); + + /* + * Possible improvements here: using defaults when merging results, etc. + */ - return [$rules, array_filter($nodesResults)]; + return [...$rulesExtractedResults, $methodCallsExtractedResult]; } } diff --git a/src/Support/OperationExtensions/RulesExtractor/FormRequestRulesExtractor.php b/src/Support/OperationExtensions/RulesExtractor/FormRequestRulesExtractor.php index 08d0ede7..f5690ce0 100644 --- a/src/Support/OperationExtensions/RulesExtractor/FormRequestRulesExtractor.php +++ b/src/Support/OperationExtensions/RulesExtractor/FormRequestRulesExtractor.php @@ -3,6 +3,8 @@ namespace Dedoc\Scramble\Support\OperationExtensions\RulesExtractor; use Dedoc\Scramble\Infer; +use Dedoc\Scramble\Support\Generator\TypeTransformer; +use Dedoc\Scramble\Support\RouteInfo; use Dedoc\Scramble\Support\SchemaClassDocReflector; use Illuminate\Http\Request; use Illuminate\Routing\Route; @@ -14,16 +16,13 @@ use ReflectionClass; use Spatie\LaravelData\Contracts\BaseData; -class FormRequestRulesExtractor +class FormRequestRulesExtractor implements RulesExtractor { - private ?FunctionLike $handler; + use GeneratesParametersFromRules; - public function __construct(?FunctionLike $handler) - { - $this->handler = $handler; - } + public function __construct(private ?FunctionLike $handler, private TypeTransformer $typeTransformer) {} - public function shouldHandle() + public function shouldHandle(): bool { if (! $this->handler) { return false; @@ -42,7 +41,7 @@ public function shouldHandle() return true; } - public function node() + public function extract(RouteInfo $routeInfo): ParametersExtractionResult { $requestClassName = $this->getFormRequestClassName(); @@ -54,19 +53,23 @@ public function node() ? null : $phpDocReflector->getSchemaName($requestClassName); - return new ValidationNodesResult( - (new NodeFinder)->find( - Arr::wrap($classReflector->getMethod('rules')->getAstNode()->stmts), - fn (Node $node) => $node instanceof Node\Expr\ArrayItem - && $node->key instanceof Node\Scalar\String_ - && $node->getAttribute('parsedPhpDoc'), + return new ParametersExtractionResult( + parameters: $this->makeParameters( + node: (new NodeFinder)->find( + Arr::wrap($classReflector->getMethod('rules')->getAstNode()->stmts), + fn (Node $node) => $node instanceof Node\Expr\ArrayItem + && $node->key instanceof Node\Scalar\String_ + && $node->getAttribute('parsedPhpDoc'), + ), + rules: $this->rules($routeInfo->route), + typeTransformer: $this->typeTransformer, ), schemaName: $schemaName, description: $phpDocReflector->getDescription(), ); } - public function extract(Route $route) + protected function rules(Route $route) { $requestClassName = $this->getFormRequestClassName(); diff --git a/src/Support/OperationExtensions/RulesExtractor/GeneratesParametersFromRules.php b/src/Support/OperationExtensions/RulesExtractor/GeneratesParametersFromRules.php new file mode 100644 index 00000000..85a27bd0 --- /dev/null +++ b/src/Support/OperationExtensions/RulesExtractor/GeneratesParametersFromRules.php @@ -0,0 +1,13 @@ +handle(); + } +} diff --git a/src/Support/OperationExtensions/RulesExtractor/ValidationNodesResult.php b/src/Support/OperationExtensions/RulesExtractor/ParametersExtractionResult.php similarity index 70% rename from src/Support/OperationExtensions/RulesExtractor/ValidationNodesResult.php rename to src/Support/OperationExtensions/RulesExtractor/ParametersExtractionResult.php index 563812b8..e284f890 100644 --- a/src/Support/OperationExtensions/RulesExtractor/ValidationNodesResult.php +++ b/src/Support/OperationExtensions/RulesExtractor/ParametersExtractionResult.php @@ -2,10 +2,13 @@ namespace Dedoc\Scramble\Support\OperationExtensions\RulesExtractor; -class ValidationNodesResult +/** + * @internal + */ +class ParametersExtractionResult { public function __construct( - public $node, + public array $parameters, public ?string $schemaName = null, public ?string $description = null, ) {} diff --git a/src/Support/OperationExtensions/RulesExtractor/RequestMethodCallsExtractor.php b/src/Support/OperationExtensions/RulesExtractor/RequestMethodCallsExtractor.php new file mode 100644 index 00000000..8f00ae88 --- /dev/null +++ b/src/Support/OperationExtensions/RulesExtractor/RequestMethodCallsExtractor.php @@ -0,0 +1,20 @@ +requestParametersFromCalls->data), + ); + } +} diff --git a/src/Support/OperationExtensions/RulesExtractor/RulesExtractor.php b/src/Support/OperationExtensions/RulesExtractor/RulesExtractor.php new file mode 100644 index 00000000..bb9c344e --- /dev/null +++ b/src/Support/OperationExtensions/RulesExtractor/RulesExtractor.php @@ -0,0 +1,12 @@ + */ private array $nodeDocs; @@ -31,9 +27,8 @@ class RulesToParameters public function __construct(array $rules, array $validationNodesResults, TypeTransformer $openApiTransformer) { $this->rules = $rules; - $this->validationNodesResults = $validationNodesResults; $this->openApiTransformer = $openApiTransformer; - $this->nodeDocs = $this->extractNodeDocs(); + $this->nodeDocs = $this->extractNodeDocs($validationNodesResults); } public function handle() @@ -215,23 +210,12 @@ private function getOrCreateDeepTypeContainer(Type &$base, array $path) } } - private function extractNodeDocs() + private function extractNodeDocs($validationNodesResults) { - return collect($this->validationNodesResults) - ->mapWithKeys(function (ValidationNodesResult $result) { - $arrayNodes = (new NodeFinder)->find( - Arr::wrap($result->node), - fn (Node $node) => $node instanceof Node\Expr\ArrayItem - && $node->key instanceof Node\Scalar\String_ - && $node->getAttribute('parsedPhpDoc') - ); - - return collect($arrayNodes) - ->mapWithKeys(fn (Node\Expr\ArrayItem $item) => [ - $item->key->value => $item->getAttribute('parsedPhpDoc'), - ]) - ->toArray(); - }) + return collect($validationNodesResults) + ->mapWithKeys(fn (Node\Expr\ArrayItem $item) => [ + $item->key->value => $item->getAttribute('parsedPhpDoc'), + ]) ->toArray(); } diff --git a/src/Support/OperationExtensions/RulesExtractor/ValidateCallExtractor.php b/src/Support/OperationExtensions/RulesExtractor/ValidateCallExtractor.php index 955f12c7..48aa5259 100644 --- a/src/Support/OperationExtensions/RulesExtractor/ValidateCallExtractor.php +++ b/src/Support/OperationExtensions/RulesExtractor/ValidateCallExtractor.php @@ -2,6 +2,8 @@ namespace Dedoc\Scramble\Support\OperationExtensions\RulesExtractor; +use Dedoc\Scramble\Support\Generator\TypeTransformer; +use Dedoc\Scramble\Support\RouteInfo; use Dedoc\Scramble\Support\SchemaClassDocReflector; use Illuminate\Http\Request; use PhpParser\Node; @@ -9,21 +11,18 @@ use PhpParser\PrettyPrinter\Standard; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; -class ValidateCallExtractor +class ValidateCallExtractor implements RulesExtractor { - private ?Node\FunctionLike $handle; + use GeneratesParametersFromRules; - public function __construct(?Node\FunctionLike $handle) - { - $this->handle = $handle; - } + public function __construct(private ?Node\FunctionLike $handle, private TypeTransformer $typeTransformer) {} - public function shouldHandle() + public function shouldHandle(): bool { return (bool) $this->handle; } - public function node(): ?ValidationNodesResult + public function extract(RouteInfo $routeInfo): ParametersExtractionResult { $methodNode = $this->handle; @@ -68,61 +67,67 @@ public function node(): ?ValidationNodesResult } if (! $validationRules) { - return null; + return new ParametersExtractionResult(parameters: []); } + $validationRulesNode = $validationRules instanceof Node\Arg ? $validationRules->value : $validationRules; + $phpDocReflector = new SchemaClassDocReflector($callToValidate->getAttribute('parsedPhpDoc', new PhpDocNode([]))); - return new ValidationNodesResult( - $validationRules instanceof Node\Arg ? $validationRules->value : $validationRules, + return new ParametersExtractionResult( + parameters: $this->makeParameters( + node: (new NodeFinder)->find( + $validationRulesNode instanceof Node\Expr\Array_ ? $validationRulesNode->items : [], + fn (Node $node) => $node instanceof Node\Expr\ArrayItem + && $node->key instanceof Node\Scalar\String_ + && $node->getAttribute('parsedPhpDoc'), + ), + rules: $this->rules($validationRulesNode), + typeTransformer: $this->typeTransformer, + ), schemaName: $phpDocReflector->getSchemaName(), description: $phpDocReflector->getDescription(), ); } - public function extract() + public function rules($validationRules): array { - $methodNode = $this->handle; - $validationRules = $this->node()->node ?? null; - - if ($validationRules) { - $printer = new Standard; - $validationRulesCode = $printer->prettyPrint([$validationRules]); - - $injectableParams = collect($methodNode->getParams()) - ->filter(fn (Node\Param $param) => isset($param->type->name)) - ->filter(fn (Node\Param $param) => ! class_exists($className = (string) $param->type) || ! is_a($className, Request::class, true)) - ->filter(fn (Node\Param $param) => isset($param->var->name) && is_string($param->var->name)) - ->mapWithKeys(function (Node\Param $param) { - try { - $type = (string) $param->type; - $primitives = [ - 'int' => 1, - 'bool' => true, - 'string' => '', - 'float' => 1, - ]; - $value = $primitives[$type] ?? app($type); - - return [ - $param->var->name => $value, - ]; - } catch (\Throwable $e) { - return []; - } - }) - ->all(); - - try { - extract($injectableParams); - - $rules = eval("\$request = request(); return $validationRulesCode;"); - } catch (\Throwable $exception) { - throw $exception; - } + if (! $validationRules) { + return []; } - return $rules ?? null; + $methodNode = $this->handle; + + $printer = new Standard; + $validationRulesCode = $printer->prettyPrint([$validationRules]); + + $injectableParams = collect($methodNode->getParams()) + ->filter(fn (Node\Param $param) => isset($param->type->name)) + ->filter(fn (Node\Param $param) => ! class_exists($className = (string) $param->type) || ! is_a($className, Request::class, true)) + ->filter(fn (Node\Param $param) => isset($param->var->name) && is_string($param->var->name)) + ->mapWithKeys(function (Node\Param $param) { + try { + $type = (string) $param->type; + $primitives = [ + 'int' => 1, + 'bool' => true, + 'string' => '', + 'float' => 1, + ]; + $value = $primitives[$type] ?? app($type); + + return [ + $param->var->name => $value, + ]; + } catch (\Throwable $e) { + return []; + } + }) + ->all(); + + extract($injectableParams); + + return eval("\$request = request(); return $validationRulesCode;"); } private function getPossibleParamType(Node\Stmt\ClassMethod $methodNode, Node\Expr\Variable $node): ?string diff --git a/tests/.pest/snapshots/Support/OperationExtensions/RequestBodyExtensionTest/it_allows_to_use_validation_on_form_request.snap b/tests/.pest/snapshots/Support/OperationExtensions/RequestBodyExtensionTest/it_allows_to_use_validation_on_form_request.snap new file mode 100644 index 00000000..000cd52f --- /dev/null +++ b/tests/.pest/snapshots/Support/OperationExtensions/RequestBodyExtensionTest/it_allows_to_use_validation_on_form_request.snap @@ -0,0 +1,133 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Laravel", + "version": "0.0.1" + }, + "servers": [ + { + "url": "http:\/\/localhost\/api" + } + ], + "paths": { + "\/a": { + "post": { + "operationId": "allowsBothFormRequestAndInlineValidationRules.a", + "tags": [ + "AllowsBothFormRequestAndInlineValidationRules" + ], + "requestBody": { + "content": { + "application\/json": { + "schema": { + "allOf": [ + { + "$ref": "#\/components\/schemas\/FormRequest_WithData" + }, + { + "type": "object", + "properties": { + "bar": { + "type": "string" + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "" + }, + "422": { + "$ref": "#\/components\/responses\/ValidationException" + } + } + } + }, + "\/b": { + "post": { + "operationId": "allowsBothFormRequestAndInlineValidationRules.b", + "tags": [ + "AllowsBothFormRequestAndInlineValidationRules" + ], + "requestBody": { + "content": { + "application\/json": { + "schema": { + "allOf": [ + { + "$ref": "#\/components\/schemas\/FormRequest_WithData" + }, + { + "type": "object", + "properties": { + "baz": { + "type": "number" + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "" + }, + "422": { + "$ref": "#\/components\/responses\/ValidationException" + } + } + } + } + }, + "components": { + "schemas": { + "FormRequest_WithData": { + "type": "object", + "properties": { + "foo": { + "type": "string" + } + }, + "title": "FormRequest_WithData" + } + }, + "responses": { + "ValidationException": { + "description": "Validation error", + "content": { + "application\/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Errors overview." + }, + "errors": { + "type": "object", + "description": "A detailed description of each field that failed validation.", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "message", + "errors" + ] + } + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index 0cf16e79..07b02eca 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -1,7 +1,9 @@ map->uri->toArray(); + + Scramble::routes(fn (Route $r) => in_array($r->uri, $routes)); + + $document = app()->make(\Dedoc\Scramble\Generator::class)(); + + expect($document)->toMatchSnapshot(); +}); +class FormRequest_WithData extends FormRequest +{ + public function rules() + { + return ['foo' => 'string']; + } +} +class AllowsBothFormRequestAndInlineValidationRules +{ + public function a(FormRequest_WithData $request) + { + $request->validate(['bar' => 'string']); + } + + public function b(FormRequest_WithData $request) + { + $request->validate(['baz' => 'numeric']); + } +} + it('allows to add description for validation calls in schemas', function () { $document = generateForRoute(function () { return RouteFacade::post('test', Validation_DescriptionSchemaNamesTest_Controller::class);