diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index 6bfcbe44..424c6803 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -4,6 +4,7 @@ use Dedoc\Scramble\Extensions\OperationExtension; use Dedoc\Scramble\Support\Generator\Operation; +use Dedoc\Scramble\Support\Generator\Parameter; use Dedoc\Scramble\Support\Generator\RequestBodyObject; use Dedoc\Scramble\Support\Generator\Schema; use Dedoc\Scramble\Support\Generator\Types\ObjectType; @@ -12,6 +13,7 @@ use Dedoc\Scramble\Support\OperationExtensions\RulesExtractor\ValidateCallExtractor; use Dedoc\Scramble\Support\RouteInfo; use Illuminate\Routing\Route; +use Illuminate\Support\Arr; use Illuminate\Support\Str; use PhpParser\Node\Stmt\ClassMethod; use Throwable; @@ -20,25 +22,27 @@ class RequestBodyExtension extends OperationExtension { public function handle(Operation $operation, RouteInfo $routeInfo) { - $method = $operation->method; - $description = Str::of($routeInfo->phpDoc()->getAttribute('description')); try { - if (count($bodyParams = $this->extractParamsFromRequestValidationRules($routeInfo->route, $routeInfo->methodNode()))) { - if ($method !== 'get') { + $bodyParams = $this->extractParamsFromRequestValidationRules($routeInfo->route, $routeInfo->methodNode()); + + $mediaType = $this->getMediaType($operation, $routeInfo, $bodyParams); + + if (count($bodyParams)) { + if ($operation->method !== 'get') { $operation->addRequestBodyObject( - RequestBodyObject::make()->setContent('application/json', Schema::createFromParameters($bodyParams)) + RequestBodyObject::make()->setContent($mediaType, Schema::createFromParameters($bodyParams)) ); } else { $operation->addParameters($bodyParams); } - } elseif ($method !== 'get') { + } elseif ($operation->method !== 'get') { $operation ->addRequestBodyObject( RequestBodyObject::make() ->setContent( - 'application/json', + $mediaType, Schema::fromType(new ObjectType) ) ); @@ -55,14 +59,43 @@ public function handle(Operation $operation, RouteInfo $routeInfo) ->description($description); } - private function extractParamsFromRequestValidationRules(Route $route, ?ClassMethod $methodNode) + protected function getMediaType(Operation $operation, RouteInfo $routeInfo, array $bodyParams): string + { + if ( + ($mediaTags = $routeInfo->phpDoc()->getTagsByName('@requestMediaType')) + && ($mediaType = trim(Arr::first($mediaTags)?->value?->value)) + ) { + return $mediaType; + } + + $jsonMediaType = 'application/json'; + + if ($operation->method === 'get') { + return $jsonMediaType; + } + + return $this->hasBinary($bodyParams) ? 'multipart/form-data' : $jsonMediaType; + } + + protected function hasBinary($bodyParams): bool + { + return collect($bodyParams)->contains(function (Parameter $parameter) { + if (property_exists($parameter?->schema?->type, 'format')) { + return $parameter->schema->type->format === 'binary'; + } + + return false; + }); + } + + protected function extractParamsFromRequestValidationRules(Route $route, ?ClassMethod $methodNode) { [$rules, $nodesResults] = $this->extractRouteRequestValidationRules($route, $methodNode); return (new RulesToParameters($rules, $nodesResults, $this->openApiTransformer))->handle(); } - private function extractRouteRequestValidationRules(Route $route, $methodNode) + protected function extractRouteRequestValidationRules(Route $route, $methodNode) { $rules = []; $nodesResults = []; diff --git a/src/Support/OperationExtensions/RulesExtractor/RulesMapper.php b/src/Support/OperationExtensions/RulesExtractor/RulesMapper.php index d749e324..f318ff6a 100644 --- a/src/Support/OperationExtensions/RulesExtractor/RulesMapper.php +++ b/src/Support/OperationExtensions/RulesExtractor/RulesMapper.php @@ -157,4 +157,18 @@ public function enum(Type $_, Enum $rule) new ObjectType($enumName) ); } + + public function image(Type $type) + { + return $this->file($type); + } + + public function file(Type $type) + { + if ($type instanceof UnknownType) { + $type = $this->string($type); + } + + return $type->format('binary'); + } } diff --git a/src/Support/OperationExtensions/RulesExtractor/RulesToParameter.php b/src/Support/OperationExtensions/RulesExtractor/RulesToParameter.php index 238b24fa..156e244b 100644 --- a/src/Support/OperationExtensions/RulesExtractor/RulesToParameter.php +++ b/src/Support/OperationExtensions/RulesExtractor/RulesToParameter.php @@ -23,7 +23,7 @@ class RulesToParameter private ?PhpDocNode $docNode; const RULES_PRIORITY = [ - 'bool', 'boolean', 'numeric', 'int', 'integer', 'string', 'array', 'exists', + 'bool', 'boolean', 'numeric', 'int', 'integer', 'file', 'image', 'string', 'array', 'exists', ]; public function __construct(string $name, $rules, ?PhpDocNode $docNode, TypeTransformer $openApiTransformer) diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php new file mode 100644 index 00000000..6917d2e0 --- /dev/null +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -0,0 +1,57 @@ +toHaveKey('application/json') + ->toHaveLength(1); +}); +class RequestBodyExtensionTest__uses_application_json_as_default +{ + public function index(Illuminate\Http\Request $request) + { + $request->validate(['foo' => 'string']); + } +} + +it('allows manually defining a request media type', function () { + $openApiDocument = generateForRoute(function () { + return RouteFacade::post('api/test', [RequestBodyExtensionTest__allows_manual_request_media_type::class, 'index']); + }); + + expect($openApiDocument['paths']['/test']['post']['requestBody']['content']) + ->toHaveKey('application/xml') + ->toHaveLength(1); +}); +class RequestBodyExtensionTest__allows_manual_request_media_type +{ + /** + * @requestMediaType application/xml + */ + public function index(Illuminate\Http\Request $request) + { + $request->validate(['foo' => 'string']); + } +} + +it('automatically infers multipart/form-data as request media type when some of body params is binary', function () { + $openApiDocument = generateForRoute(function () { + return RouteFacade::post('api/test', [RequestBodyExtensionTest__automaticall_infers_form_data::class, 'index']); + }); + + expect($openApiDocument['paths']['/test']['post']['requestBody']['content']) + ->toHaveKey('multipart/form-data') + ->toHaveLength(1); +}); +class RequestBodyExtensionTest__automaticall_infers_form_data +{ + public function index(Illuminate\Http\Request $request) + { + $request->validate(['foo' => 'file']); + } +} diff --git a/tests/ValidationRulesDocumentingTest.php b/tests/ValidationRulesDocumentingTest.php index fc28656a..c4faf24a 100644 --- a/tests/ValidationRulesDocumentingTest.php +++ b/tests/ValidationRulesDocumentingTest.php @@ -134,6 +134,28 @@ ->and($type->format)->toBe('email'); }); +it('supports image rule', function () { + $rules = [ + 'image' => 'required|image', + ]; + + $type = app()->make(RulesToParameters::class, ['rules' => $rules])->handle()[0]->schema->type; + + expect($type)->toBeInstanceOf(StringType::class) + ->and($type->format)->toBe('binary'); +}); + +it('supports file rule', function () { + $rules = [ + 'file' => 'required|file', + ]; + + $type = app()->make(RulesToParameters::class, ['rules' => $rules])->handle()[0]->schema->type; + + expect($type)->toBeInstanceOf(StringType::class) + ->and($type->format)->toBe('binary'); +}); + it('extracts rules from request->validate call', function () { RouteFacade::get('api/test', [ValidationRulesDocumenting_Test::class, 'index']);