diff --git a/src/ScrambleServiceProvider.php b/src/ScrambleServiceProvider.php index 3e9d2470..32c523b4 100644 --- a/src/ScrambleServiceProvider.php +++ b/src/ScrambleServiceProvider.php @@ -29,6 +29,7 @@ use Dedoc\Scramble\Support\InferExtensions\ModelExtension; use Dedoc\Scramble\Support\InferExtensions\PossibleExceptionInfer; use Dedoc\Scramble\Support\InferExtensions\ResourceCollectionTypeInfer; +use Dedoc\Scramble\Support\InferExtensions\ResourceResponseMethodReturnTypeExtension; use Dedoc\Scramble\Support\InferExtensions\ResponseFactoryTypeInfer; use Dedoc\Scramble\Support\InferExtensions\ResponseMethodReturnTypeExtension; use Dedoc\Scramble\Support\InferExtensions\TypeTraceInfer; @@ -49,6 +50,7 @@ use Dedoc\Scramble\Support\TypeToSchemaExtensions\LengthAwarePaginatorTypeToSchema; use Dedoc\Scramble\Support\TypeToSchemaExtensions\ModelToSchema; use Dedoc\Scramble\Support\TypeToSchemaExtensions\PaginatorTypeToSchema; +use Dedoc\Scramble\Support\TypeToSchemaExtensions\ResourceResponseTypeToSchema; use Dedoc\Scramble\Support\TypeToSchemaExtensions\ResponseTypeToSchema; use Dedoc\Scramble\Support\TypeToSchemaExtensions\VoidTypeToSchema; use PhpParser\ParserFactory; @@ -96,6 +98,7 @@ public function configurePackage(Package $package): void $inferExtensionsClasses = array_merge([ ResponseMethodReturnTypeExtension::class, JsonResourceExtension::class, + ResourceResponseMethodReturnTypeExtension::class, JsonResponseMethodReturnTypeExtension::class, ModelExtension::class, ], $inferExtensionsClasses); @@ -187,6 +190,7 @@ public function configurePackage(Package $package): void PaginatorTypeToSchema::class, LengthAwarePaginatorTypeToSchema::class, ResponseTypeToSchema::class, + ResourceResponseTypeToSchema::class, VoidTypeToSchema::class, ], $typesToSchemaExtensions), array_merge([ diff --git a/src/Support/InferExtensions/JsonResourceExtension.php b/src/Support/InferExtensions/JsonResourceExtension.php index e75a3008..a672ea20 100644 --- a/src/Support/InferExtensions/JsonResourceExtension.php +++ b/src/Support/InferExtensions/JsonResourceExtension.php @@ -20,7 +20,6 @@ use Dedoc\Scramble\Support\Type\IntegerType; use Dedoc\Scramble\Support\Type\KeyedArrayType; use Dedoc\Scramble\Support\Type\Literal\LiteralBooleanType; -use Dedoc\Scramble\Support\Type\Literal\LiteralIntegerType; use Dedoc\Scramble\Support\Type\Literal\LiteralStringType; use Dedoc\Scramble\Support\Type\NullType; use Dedoc\Scramble\Support\Type\ObjectType; @@ -29,8 +28,10 @@ use Dedoc\Scramble\Support\Type\StringType; use Dedoc\Scramble\Support\Type\Type; use Dedoc\Scramble\Support\Type\Union; +use Dedoc\Scramble\Support\Type\UnknownType; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Http\Resources\Json\ResourceResponse; use Illuminate\Http\Resources\MergeValue; use Illuminate\Http\Resources\MissingValue; @@ -52,7 +53,11 @@ public function getMethodReturnType(MethodCallEvent $event): ?Type ? $this->getModelMethodReturn($event->getInstance()->name, 'toArray', $event->arguments, $event->scope) : null, - 'response', 'toResponse' => new Generic(JsonResponse::class, [$event->getInstance(), new LiteralIntegerType(200), new ArrayType]), + 'response', 'toResponse' => new Generic(JsonResponse::class, [ + new Generic(ResourceResponse::class, [$event->getInstance()]), + new UnknownType, + new ArrayType, + ]), 'whenLoaded' => count($event->arguments) === 1 ? Union::wrap([ diff --git a/src/Support/InferExtensions/ResourceResponseMethodReturnTypeExtension.php b/src/Support/InferExtensions/ResourceResponseMethodReturnTypeExtension.php new file mode 100644 index 00000000..9f9b663e --- /dev/null +++ b/src/Support/InferExtensions/ResourceResponseMethodReturnTypeExtension.php @@ -0,0 +1,40 @@ +isInstanceOf(ResourceResponse::class); + } + + public function getMethodReturnType(MethodCallEvent $event): ?Type + { + if ($event->name !== 'toResponse') { + return null; + } + + $resourceType = $event->getInstance()->templateTypes[0] ?? null; + if (! $resourceType) { + return new Generic(JsonResponse::class, [new UnknownType, new UnknownType, new KeyedArrayType]); + } + + return new Generic(JsonResponse::class, [ + new Generic(ResourceResponse::class, [$resourceType]), + new UnknownType, + new ArrayType, + ]); + } +} diff --git a/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php b/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php index 708ee4ef..e0e24bd5 100644 --- a/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php +++ b/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php @@ -3,29 +3,22 @@ namespace Dedoc\Scramble\Support\TypeToSchemaExtensions; use Dedoc\Scramble\Extensions\TypeToSchemaExtension; -use Dedoc\Scramble\Infer\Analyzer\MethodQuery; use Dedoc\Scramble\Infer\Scope\GlobalScope; use Dedoc\Scramble\Infer\Services\ReferenceTypeResolver; -use Dedoc\Scramble\Support\Generator\Combined\AllOf; use Dedoc\Scramble\Support\Generator\Reference; -use Dedoc\Scramble\Support\Generator\Schema; -use Dedoc\Scramble\Support\Generator\Types\ObjectType as OpenApiObjectType; use Dedoc\Scramble\Support\Generator\Types\UnknownType; use Dedoc\Scramble\Support\InferExtensions\ResourceCollectionTypeInfer; use Dedoc\Scramble\Support\Type\ArrayType; use Dedoc\Scramble\Support\Type\Generic; use Dedoc\Scramble\Support\Type\KeyedArrayType; -use Dedoc\Scramble\Support\Type\Literal\LiteralIntegerType; use Dedoc\Scramble\Support\Type\ObjectType; -use Dedoc\Scramble\Support\Type\Reference\AbstractReferenceType; use Dedoc\Scramble\Support\Type\Reference\MethodCallReferenceType; use Dedoc\Scramble\Support\Type\Type; use Dedoc\Scramble\Support\Type\TypeHelper; -use Dedoc\Scramble\Support\Type\TypeWalker; -use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\ResourceCollection; +use Illuminate\Http\Resources\Json\ResourceResponse; class JsonResourceTypeToSchema extends TypeToSchemaExtension { @@ -82,103 +75,10 @@ public function toSchema(Type $type) */ public function toResponse(Type $type) { - $definition = $this->infer->analyzeClass($type->name); - - $additional = $type->templateTypes[1 /* TAdditional */] ?? new UnknownType; - - $openApiType = $this->openApiTransformer->transform($type); - - if (($withArray = $definition->getMethodCallType('with')) instanceof KeyedArrayType) { - $withArray->items = $this->flattenMergeValues($withArray->items); - } - if ($additional instanceof KeyedArrayType) { - $additional->items = $this->flattenMergeValues($additional->items); - } - - $shouldWrap = ($wrapKey = $type->name::$wrap ?? null) !== null - || $withArray instanceof KeyedArrayType - || $additional instanceof KeyedArrayType; - $wrapKey = $wrapKey ?: 'data'; - - if ($shouldWrap) { - $openApiType = $this->mergeResourceTypeAndAdditionals( - $wrapKey, - $openApiType, - $this->normalizeKeyedArrayType($withArray), - $this->normalizeKeyedArrayType($additional), - ); - } - - $response = $this->openApiTransformer->toResponse($this->makeBaseResponse($type)); - - return $response - ->description('`'.$this->components->uniqueSchemaName($type->name).'`') - ->setContent( - 'application/json', - Schema::fromType($openApiType), - ); - } - - private function makeBaseResponse(Type $type) - { - $definition = $this->infer->analyzeClass($type->name); - - $responseType = new Generic(JsonResponse::class, [new \Dedoc\Scramble\Support\Type\UnknownType, new LiteralIntegerType(200), new KeyedArrayType]); - - $methodQuery = MethodQuery::make($this->infer) - ->withArgumentType([null, 1], $responseType) - ->from($definition, 'withResponse'); - - $effectTypes = $methodQuery->getTypes(fn ($t) => (bool) (new TypeWalker)->first($t, fn ($t) => $t === $responseType)); + $resourceResponseType = new Generic(ResourceResponse::class, [$type]); - $effectTypes - ->filter(fn ($t) => $t instanceof AbstractReferenceType) - ->each(function (AbstractReferenceType $t) use ($methodQuery) { - ReferenceTypeResolver::getInstance()->resolve($methodQuery->getScope(), $t); - }); - - return $responseType; - } - - private function mergeResourceTypeAndAdditionals(string $wrapKey, $openApiType, ?KeyedArrayType $withArray, ?KeyedArrayType $additional) - { - $resolvedOpenApiType = $openApiType instanceof Reference ? $openApiType->resolve() : $openApiType; - $resolvedOpenApiType = $resolvedOpenApiType instanceof Schema ? $resolvedOpenApiType->type : $resolvedOpenApiType; - - // If resolved type already contains wrapKey, we don't need to wrap it again. But we still need to merge additionals. - if ($resolvedOpenApiType instanceof OpenApiObjectType && $resolvedOpenApiType->hasProperty($wrapKey)) { - $items = array_values(array_filter([ - $openApiType, - $this->transformNullableType($withArray), - $this->transformNullableType($additional), - ])); - - return count($items) > 1 ? (new AllOf)->setItems($items) : $items[0]; - } - - $openApiType = (new OpenApiObjectType) - ->addProperty($wrapKey, $openApiType) - ->setRequired([$wrapKey]); - - if ($withArray) { - $this->mergeOpenApiObjects($openApiType, $this->openApiTransformer->transform($withArray)); - } - - if ($additional) { - $this->mergeOpenApiObjects($openApiType, $this->openApiTransformer->transform($additional)); - } - - return $openApiType; - } - - private function normalizeKeyedArrayType($type): ?KeyedArrayType - { - return $type instanceof KeyedArrayType ? $type : null; - } - - private function transformNullableType(?KeyedArrayType $type) - { - return $type ? $this->openApiTransformer->transform($type) : null; + return (new ResourceResponseTypeToSchema($this->infer, $this->openApiTransformer, $this->components)) + ->toResponse($resourceResponseType); } public function reference(ObjectType $type) diff --git a/src/Support/TypeToSchemaExtensions/ResourceResponseTypeToSchema.php b/src/Support/TypeToSchemaExtensions/ResourceResponseTypeToSchema.php new file mode 100644 index 00000000..84be8e0f --- /dev/null +++ b/src/Support/TypeToSchemaExtensions/ResourceResponseTypeToSchema.php @@ -0,0 +1,136 @@ +isInstanceOf(ResourceResponse::class) + && count($type->templateTypes) >= 1; + } + + public function toResponse(Type $type) + { + $resourceType = $type->templateTypes[0]; + $openApiType = $this->openApiTransformer->transform($resourceType); + + $definition = $this->infer->analyzeClass($resourceType->name); + + $withArray = $definition->getMethodCallType('with'); + $additional = $resourceType instanceof Generic ? ($resourceType->templateTypes[1] ?? null) : null; + + if ($withArray instanceof KeyedArrayType) { + $withArray->items = $this->flattenMergeValues($withArray->items); + } + if ($additional instanceof KeyedArrayType) { + $additional->items = $this->flattenMergeValues($additional->items); + } + + $shouldWrap = ($wrapKey = $resourceType->name::$wrap ?? null) !== null + || $withArray instanceof KeyedArrayType + || $additional instanceof KeyedArrayType; + $wrapKey = $wrapKey ?: 'data'; + + if ($shouldWrap) { + $openApiType = $this->mergeResourceTypeAndAdditionals( + $wrapKey, + $openApiType, + $this->normalizeKeyedArrayType($withArray), + $this->normalizeKeyedArrayType($additional), + ); + } + + $response = $this->openApiTransformer->toResponse($this->makeBaseResponse($resourceType)); + + return $response + ->description('`'.$this->components->uniqueSchemaName($resourceType->name).'`') + ->setContent( + 'application/json', + Schema::fromType($openApiType), + ); + } + + private function makeBaseResponse(Type $type) + { + $definition = $this->infer->analyzeClass($type->name); + + $responseType = new Generic(JsonResponse::class, [new UnknownType, new LiteralIntegerType(200), new KeyedArrayType]); + + $methodQuery = MethodQuery::make($this->infer) + ->withArgumentType([null, 1], $responseType) + ->from($definition, 'withResponse'); + + $effectTypes = $methodQuery->getTypes(fn ($t) => (bool) (new TypeWalker)->first($t, fn ($t) => $t === $responseType)); + + $effectTypes + ->filter(fn ($t) => $t instanceof AbstractReferenceType) + ->each(function (AbstractReferenceType $t) use ($methodQuery) { + ReferenceTypeResolver::getInstance()->resolve($methodQuery->getScope(), $t); + }); + + return $responseType; + } + + private function mergeResourceTypeAndAdditionals(string $wrapKey, $openApiType, ?KeyedArrayType $withArray, ?KeyedArrayType $additional) + { + $resolvedOpenApiType = $openApiType instanceof Reference ? $openApiType->resolve() : $openApiType; + $resolvedOpenApiType = $resolvedOpenApiType instanceof Schema ? $resolvedOpenApiType->type : $resolvedOpenApiType; + + // If resolved type already contains wrapKey, we don't need to wrap it again. But we still need to merge additionals. + if ($resolvedOpenApiType instanceof OpenApiObjectType && $resolvedOpenApiType->hasProperty($wrapKey)) { + $items = array_values(array_filter([ + $openApiType, + $this->transformNullableType($withArray), + $this->transformNullableType($additional), + ])); + + return count($items) > 1 ? (new AllOf)->setItems($items) : $items[0]; + } + + $openApiType = (new OpenApiObjectType) + ->addProperty($wrapKey, $openApiType) + ->setRequired([$wrapKey]); + + if ($withArray) { + $this->mergeOpenApiObjects($openApiType, $this->openApiTransformer->transform($withArray)); + } + + if ($additional) { + $this->mergeOpenApiObjects($openApiType, $this->openApiTransformer->transform($additional)); + } + + return $openApiType; + } + + private function normalizeKeyedArrayType($type): ?KeyedArrayType + { + return $type instanceof KeyedArrayType ? $type : null; + } + + private function transformNullableType(?KeyedArrayType $type) + { + return $type ? $this->openApiTransformer->transform($type) : null; + } +} diff --git a/src/Support/TypeToSchemaExtensions/ResponseTypeToSchema.php b/src/Support/TypeToSchemaExtensions/ResponseTypeToSchema.php index ab260b8f..22ef0a5e 100644 --- a/src/Support/TypeToSchemaExtensions/ResponseTypeToSchema.php +++ b/src/Support/TypeToSchemaExtensions/ResponseTypeToSchema.php @@ -7,7 +7,10 @@ use Dedoc\Scramble\Support\Generator\Schema; use Dedoc\Scramble\Support\Type\Generic; use Dedoc\Scramble\Support\Type\Literal\LiteralIntegerType; +use Dedoc\Scramble\Support\Type\ObjectType; use Dedoc\Scramble\Support\Type\Type; +use Dedoc\Scramble\Support\Type\UnknownType; +use Illuminate\Contracts\Support\Responsable; use Illuminate\Http\JsonResponse; class ResponseTypeToSchema extends TypeToSchemaExtension @@ -18,7 +21,8 @@ public function shouldHandle(Type $type) && ( $type->isInstanceOf(\Illuminate\Http\Response::class) || $type->isInstanceOf(JsonResponse::class) - ); + ) + && count($type->templateTypes) >= 2; } /** @@ -26,6 +30,10 @@ public function shouldHandle(Type $type) */ public function toResponse(Type $type) { + if ($this->isResponsable($type) && $responsableResponse = $this->handleResponsableResponse($type)) { + return $responsableResponse; + } + if (! $type->templateTypes[1] instanceof LiteralIntegerType) { return null; } @@ -44,4 +52,31 @@ public function toResponse(Type $type) return $response; } + + private function isResponsable(Generic $type): bool + { + $data = $type->templateTypes[0]; + + return $data instanceof ObjectType && $data->isInstanceOf(Responsable::class); + } + + private function handleResponsableResponse(Generic $jsonResponseType): ?Response + { + $data = $jsonResponseType->templateTypes[0]; + $statusCode = $jsonResponseType->templateTypes[1]; + + $response = $this->openApiTransformer->toResponse($data); + + $responseStatusCode = $statusCode instanceof UnknownType + ? $response->code + : ($statusCode->value ?? null); + + if (! $responseStatusCode) { + return null; + } + + $response->code = $responseStatusCode; + + return $response; + } } diff --git a/tests/ResponseDocumentingTest.php b/tests/ResponseDocumentingTest.php index 0114e331..b7596fb7 100644 --- a/tests/ResponseDocumentingTest.php +++ b/tests/ResponseDocumentingTest.php @@ -132,7 +132,15 @@ public function toArray(\Illuminate\Http\Request $request) $openApiDocument = generateForRoute(fn () => \Illuminate\Support\Facades\Route::get('api/test', [Foo_TestSix::class, 'single'])); expect($openApiDocument['paths']['/test']['get']['responses'][201]['content']['application/json']['schema']) - ->toBe(['$ref' => '#/components/schemas/Foo_TestFiveResource']); + ->toBe([ + 'type' => 'object', + 'properties' => [ + 'data' => [ + '$ref' => '#/components/schemas/Foo_TestFiveResource', + ], + ], + 'required' => ['data'], + ]); }); test('automated response status code inference when using collection ->response->setStatusCode method', function () { @@ -140,8 +148,16 @@ public function toArray(\Illuminate\Http\Request $request) expect($openApiDocument['paths']['/test']['get']['responses'][201]['content']['application/json']['schema']) ->toBe([ - 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Foo_TestFiveResource'], + 'type' => 'object', + 'properties' => [ + 'data' => [ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Foo_TestFiveResource'], + ], + ], + 'required' => [ + 0 => 'data', + ], ]); }); class Foo_TestSix diff --git a/tests/Support/TypeToSchemaExtensions/JsonResourceTypeToSchemaTest.php b/tests/Support/TypeToSchemaExtensions/JsonResourceTypeToSchemaTest.php index 95dcf58b..c2739caa 100644 --- a/tests/Support/TypeToSchemaExtensions/JsonResourceTypeToSchemaTest.php +++ b/tests/Support/TypeToSchemaExtensions/JsonResourceTypeToSchemaTest.php @@ -8,6 +8,7 @@ use Dedoc\Scramble\Support\Type\UnknownType; use Dedoc\Scramble\Support\TypeToSchemaExtensions\JsonResourceTypeToSchema; use Dedoc\Scramble\Support\TypeToSchemaExtensions\ResponseTypeToSchema; +use Illuminate\Support\Facades\Route; it('supports call to method', function () { $type = new Generic(JsonResourceTypeToSchemaTest_WithInteger::class, [new UnknownType]); @@ -137,6 +138,32 @@ public function withResponse(\Illuminate\Http\Request $request, \Illuminate\Http } } +it('properly handles custom status code', function () { + $openApiDocument = generateForRoute(function () { + return Route::get('api/test', JsonResourceTypeToSchemaTest_StatusCodeController::class); + }); + + $responses = $openApiDocument['paths']['/test']['get']['responses']; + + expect($responses) + ->toHaveKey('201') + ->not->toHaveKey('429') + ->and($responses['201']['content']['application/json']['schema']) + ->toHaveKey('properties') + ->and($responses['201']['content']['application/json']['schema']['properties']['data']['$ref'] ?? null) + ->toBe('#/components/schemas/JsonResourceTypeToSchemaTest_WithResponseSample'); +}); + +class JsonResourceTypeToSchemaTest_StatusCodeController +{ + public function __invoke() + { + return (new JsonResourceTypeToSchemaTest_WithResponseSample) + ->response() + ->setStatusCode(201); + } +} + /** * @property JsonResourceTypeToSchemaTest_User $resource */ @@ -209,3 +236,35 @@ public function toArray(\Illuminate\Http\Request $request) ]; } } + +it('handles additional data with custom status code', function () { + $openApiDocument = generateForRoute(function () { + return Route::get('api/test', [JsonResourceTypeToSchemaTest_AdditionalController::class, 'index']); + }); + + $responses = $openApiDocument['paths']['/test']['get']['responses']; + + expect($responses) + ->toHaveKey('202') + ->not->toHaveKey('200') + ->and($responses['202']['content']['application/json']['schema']['properties']) + ->toHaveKeys(['data', 'meta']) + ->and($responses['202']['content']['application/json']['schema']['properties']['data']['$ref'] ?? null) + ->toBe('#/components/schemas/JsonResourceTypeToSchemaTest_Sample') + ->and($responses['202']['content']['application/json']['schema']['properties']['meta']) + ->toBe([ + 'type' => 'object', + 'properties' => ['foo' => ['type' => 'string', 'example' => 'bar']], + 'required' => ['foo'], + ]); +}); +class JsonResourceTypeToSchemaTest_AdditionalController +{ + public function index() + { + return (new JsonResourceTypeToSchemaTest_Sample) + ->additional(['meta' => ['foo' => 'bar']]) + ->response() + ->setStatusCode(202); + } +}