diff --git a/composer.json b/composer.json index 938ede3..c36863a 100644 --- a/composer.json +++ b/composer.json @@ -62,7 +62,7 @@ }, "scripts": { "lint": "phpstan analyse -c ./phpstan.neon src camel --memory-limit 1G", - "test": "pest --stop-on-failure --exclude-group dingo --coverage --colors", + "test": "pest --stop-on-failure --exclude-group dingo --colors", "test-ci": "pest --exclude-group dingo --coverage --min=80", "test-parallel": "paratest -p16 --stop-on-failure --exclude-group dingo", "test-parallel-ci": "paratest -p16 --exclude-group dingo" diff --git a/src/Attributes/ResponseFromApiResource.php b/src/Attributes/ResponseFromApiResource.php index f24608c..65275ac 100644 --- a/src/Attributes/ResponseFromApiResource.php +++ b/src/Attributes/ResponseFromApiResource.php @@ -3,25 +3,35 @@ namespace Knuckles\Scribe\Attributes; use Attribute; +use Knuckles\Scribe\Extracting\Shared\ApiResourceResponseTools; #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class ResponseFromApiResource { public function __construct( - public string $name, - public ?string $model = null, - public int $status = 200, + public string $name, + public ?string $model = null, + public int $status = 200, public ?string $description = '', /* Mark if this should be used as a collection. Only needed if not using a ResourceCollection. */ - public bool $collection = false, - public array $factoryStates = [], - public array $with = [], + public bool $collection = false, + public array $factoryStates = [], + public array $with = [], - public ?int $paginate = null, - public ?int $simplePaginate = null, - public array $additional = [], + public ?int $paginate = null, + public ?int $simplePaginate = null, + public array $additional = [], ) { } + + public function modelToBeTransformed(): ?string + { + if (!empty($this->model)) { + return $this->model; + } + + return ApiResourceResponseTools::tryToInferApiResourceModel($this->name); + } } diff --git a/src/Extracting/Shared/ApiResourceResponseTools.php b/src/Extracting/Shared/ApiResourceResponseTools.php index ae025af..b93cc60 100644 --- a/src/Extracting/Shared/ApiResourceResponseTools.php +++ b/src/Extracting/Shared/ApiResourceResponseTools.php @@ -9,54 +9,56 @@ use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\Paginator; +use Illuminate\Support\Arr; use Knuckles\Camel\Extraction\ExtractedEndpointData; use Knuckles\Scribe\Tools\ConsoleOutputUtils as c; use Knuckles\Scribe\Tools\ErrorHandlingUtils as e; use Knuckles\Scribe\Tools\Utils; +use Mpociot\Reflection\DocBlock; +use Mpociot\Reflection\DocBlock\Tag; +use ReflectionClass; class ApiResourceResponseTools { public static function fetch( - string $apiResourceClass, bool $isCollection, $modelInstantiator, + string $apiResourceClass, bool $isCollection, ?callable $modelInstantiator, ExtractedEndpointData $endpointData, array $pagination, array $additionalData ) { - try { - $resource = ApiResourceResponseTools::getApiResourceOrCollectionInstance( - $apiResourceClass, $isCollection, $modelInstantiator, $pagination, $additionalData - ); - $response = ApiResourceResponseTools::getApiResourceResponse($resource, $endpointData); - return $response->getContent(); - } catch (Exception $e) { - c::warn('Exception thrown when fetching Eloquent API resource response for ' . $endpointData->name()); - e::dumpExceptionIfVerbose($e); - - return null; - } + $resource = static::getApiResourceOrCollectionInstance( + $apiResourceClass, $isCollection, $modelInstantiator, $pagination, $additionalData + ); + $response = static::callApiResourceAndGetResponse($resource, $endpointData); + return $response->getContent(); } - public static function getApiResourceResponse(JsonResource $resource, ExtractedEndpointData $endpointData): JsonResponse + public static function callApiResourceAndGetResponse(JsonResource $resource, ExtractedEndpointData $endpointData): JsonResponse { $uri = Utils::getUrlWithBoundParameters($endpointData->route->uri(), $endpointData->cleanUrlParameters); $method = $endpointData->route->methods()[0]; $request = Request::create($uri, $method); $request->headers->add(['Accept' => 'application/json']); + // Set the route properly, so it works for users who have code that checks for the route. + $request->setRouteResolver(fn() => $endpointData->route); + + $previousBoundRequest = app('request'); app()->bind('request', fn() => $request); - // Set the route properly, so it works for users who have code that checks for the route. - return $resource->toResponse( - $request->setRouteResolver(fn() => $endpointData->route) - ); + $response = $resource->toResponse($request); + + app()->bind('request', fn() => $previousBoundRequest); + + return $response; } public static function getApiResourceOrCollectionInstance( - string $apiResourceClass, bool $isCollection, $modelInstantiator, - array $paginationStrategy = [], array $additionalData = [] + string $apiResourceClass, bool $isCollection, ?callable $modelInstantiator, + array $paginationStrategy = [], array $additionalData = [] ): JsonResource { // If the API Resource uses an empty $resource (e.g. an empty array), the $modelInstantiator will be null // See https://github.com/knuckleswtf/scribe/issues/652 - $modelInstance = $modelInstantiator() ?? []; + $modelInstance = is_callable($modelInstantiator) ? $modelInstantiator() : []; try { $resource = new $apiResourceClass($modelInstance); } catch (Exception) { @@ -94,4 +96,24 @@ class ApiResourceResponseTools return $resource->additional($additionalData); } + + /** + * Check if the ApiResource class has an `@mixin` docblock, and fetch the model from there. + */ + public static function tryToInferApiResourceModel(string $apiResourceClass): string|null + { + $class = new ReflectionClass($apiResourceClass); + $docBlock = new DocBlock($class->getDocComment() ?: ''); + /** @var Tag|null $mixinTag */ + $mixinTag = Arr::first(Utils::filterDocBlockTags($docBlock->getTags(), 'mixin')); + if (empty($mixinTag) || empty($modelClass = trim($mixinTag->getContent()))) { + return null; + } + + if (class_exists($modelClass)) { + return $modelClass; + } + + return null; + } } diff --git a/src/Extracting/Strategies/ResponseFields/GetFromResponseFieldTag.php b/src/Extracting/Strategies/ResponseFields/GetFromResponseFieldTag.php index e9c528b..d955132 100644 --- a/src/Extracting/Strategies/ResponseFields/GetFromResponseFieldTag.php +++ b/src/Extracting/Strategies/ResponseFields/GetFromResponseFieldTag.php @@ -4,6 +4,8 @@ namespace Knuckles\Scribe\Extracting\Strategies\ResponseFields; use Knuckles\Scribe\Extracting\Shared\ResponseFieldTools; use Knuckles\Scribe\Extracting\Strategies\GetFieldsFromTagStrategy; +use Knuckles\Scribe\Extracting\Strategies\Responses\UseApiResourceTags; +use Knuckles\Scribe\Tools\AnnotationParser as a; use Mpociot\Reflection\DocBlock; use Knuckles\Scribe\Tools\Utils as u; @@ -66,18 +68,9 @@ class GetFromResponseFieldTag extends GetFieldsFromTagStrategy return parent::getFromTags(array_merge($tagsOnMethod, $tagsOnApiResource ?? []), $tagsOnClass); } - /** - * An API resource tag may contain a status code before the class name, - * so this method parses out the class name. - */ public function getClassNameFromApiResourceTag(string $apiResourceTag): string { - if (!str_contains($apiResourceTag, ' ')) { - return $apiResourceTag; - } - - $exploded = explode(' ', $apiResourceTag); - - return $exploded[count($exploded) - 1]; + ['content' => $className] = a::parseIntoContentAndFields($apiResourceTag, UseApiResourceTags::apiResourceAllowedFields()); + return $className; } } diff --git a/src/Extracting/Strategies/Responses/UseApiResourceTags.php b/src/Extracting/Strategies/Responses/UseApiResourceTags.php index 64ab407..19cb533 100644 --- a/src/Extracting/Strategies/Responses/UseApiResourceTags.php +++ b/src/Extracting/Strategies/Responses/UseApiResourceTags.php @@ -13,7 +13,9 @@ use Knuckles\Scribe\Extracting\Strategies\Strategy; use Knuckles\Scribe\Tools\AnnotationParser as a; use Knuckles\Scribe\Tools\ConsoleOutputUtils as c; use Knuckles\Scribe\Tools\Utils; +use Mpociot\Reflection\DocBlock; use Mpociot\Reflection\DocBlock\Tag; +use ReflectionClass; /** * Parse an Eloquent API resource response from the docblock ( @apiResource || @apiResourcecollection ). @@ -46,8 +48,8 @@ class UseApiResourceTags extends Strategy */ public function getApiResourceResponseFromTags(Tag $apiResourceTag, array $allTags, ExtractedEndpointData $endpointData): ?array { - [$statusCode, $description, $apiResourceClass, $isCollection] = $this->getStatusCodeAndApiResourceClass($apiResourceTag); - [$modelClass, $factoryStates, $relations, $pagination] = $this->getClassToBeTransformedAndAttributes($allTags); + [$statusCode, $description, $apiResourceClass, $isCollection, $extra] = $this->getStatusCodeAndApiResourceClass($apiResourceTag); + [$modelClass, $factoryStates, $relations, $pagination] = $this->getClassToBeTransformedAndAttributes($allTags, $apiResourceClass, $extra); $additionalData = $this->getAdditionalData($allTags); $modelInstantiator = fn() => $this->instantiateExampleModel($modelClass, $factoryStates, $relations); @@ -75,34 +77,51 @@ class UseApiResourceTags extends Strategy $status = $result[1] ?: 0; $content = $result[2]; - ['fields' => $fields, 'content' => $content] = a::parseIntoContentAndFields($content, ['status', 'scenario']); + [ + 'fields' => $fields, + 'content' => $content + ] = a::parseIntoContentAndFields($content, static::apiResourceAllowedFields()); + $status = $fields['status'] ?: $status; $apiResourceClass = $content; $description = $fields['scenario'] ?: ""; $isCollection = strtolower($tag->getName()) == 'apiresourcecollection'; - return [(int)$status, $description, $apiResourceClass, $isCollection]; + return [ + (int)$status, + $description, + $apiResourceClass, + $isCollection, + collect($fields)->only(...static::apiResourceExtraFields())->toArray(), + ]; } - private function getClassToBeTransformedAndAttributes(array $tags): array + protected function getClassToBeTransformedAndAttributes(array $tags, string $apiResourceClass, array $extra): array { $modelTag = Arr::first(Utils::filterDocBlockTags($tags, 'apiresourcemodel')); $modelClass = null; - $states = []; - $relations = []; - $pagination = []; if ($modelTag) { - ['content' => $modelClass, 'fields' => $fields] = a::parseIntoContentAndFields($modelTag->getContent(), ['states', 'with', 'paginate']); - $states = $fields['states'] ? explode(',', $fields['states']) : []; - $relations = $fields['with'] ? explode(',', $fields['with']) : []; - $pagination = $fields['paginate'] ? explode(',', $fields['paginate']) : []; + ['content' => $modelClass, 'fields' => $fields] = a::parseIntoContentAndFields($modelTag->getContent(), static::apiResourceModelAllowedFields()); + } + + $fields = array_merge($extra, $fields ?? []); + $states = $fields['states'] ? explode(',', $fields['states']) : []; + $relations = $fields['with'] ? explode(',', $fields['with']) : []; + $pagination = $fields['paginate'] ? explode(',', $fields['paginate']) : []; + + if (empty($modelClass)) { + $modelClass = ApiResourceResponseTools::tryToInferApiResourceModel($apiResourceClass); } if (empty($modelClass)) { - c::warn("Couldn't detect an Eloquent API resource model from your docblock. Did you remember to specify a model using @apiResourceModel?"); + c::warn(<<getContent()) : []; } + // These fields were originally only set on @apiResourceModel, but now we also support them on @apiResource + public static function apiResourceExtraFields() + { + return ['states', 'with', 'paginate']; + } + + public static function apiResourceAllowedFields() + { + return ['status', 'scenario', ...static::apiResourceExtraFields()]; + } + + public static function apiResourceModelAllowedFields() + { + return ['states', 'with', 'paginate']; + } + public function getApiResourceTag(array $tags): ?Tag { return Arr::first(Utils::filterDocBlockTags($tags, 'apiresource', 'apiresourcecollection')); diff --git a/src/Extracting/Strategies/Responses/UseResponseAttributes.php b/src/Extracting/Strategies/Responses/UseResponseAttributes.php index 2678150..6aa37e2 100644 --- a/src/Extracting/Strategies/Responses/UseResponseAttributes.php +++ b/src/Extracting/Strategies/Responses/UseResponseAttributes.php @@ -13,6 +13,7 @@ use Knuckles\Scribe\Extracting\ParamHelpers; use Knuckles\Scribe\Extracting\Shared\ApiResourceResponseTools; use Knuckles\Scribe\Extracting\Shared\TransformerResponseTools; use Knuckles\Scribe\Extracting\Strategies\PhpAttributeStrategy; +use Knuckles\Scribe\Tools\ConsoleOutputUtils as c; use ReflectionClass; /** @@ -50,7 +51,17 @@ class UseResponseAttributes extends PhpAttributeStrategy protected function getApiResourceResponse(ResponseFromApiResource $attributeInstance) { - $modelInstantiator = fn() => $this->instantiateExampleModel($attributeInstance->model, $attributeInstance->factoryStates, $attributeInstance->with); + $modelToBeTransformed = $attributeInstance->modelToBeTransformed(); + if (empty($modelToBeTransformed)) { + c::warn(<< $this->instantiateExampleModel($modelToBeTransformed, $attributeInstance->factoryStates, $attributeInstance->with); + } $pagination = []; if ($attributeInstance->paginate) { diff --git a/tests/Fixtures/TestUserApiResource.php b/tests/Fixtures/TestUserApiResource.php index 5087365..0fe5d8b 100644 --- a/tests/Fixtures/TestUserApiResource.php +++ b/tests/Fixtures/TestUserApiResource.php @@ -4,6 +4,9 @@ namespace Knuckles\Scribe\Tests\Fixtures; use Illuminate\Http\Resources\Json\JsonResource; +/** + * @mixin \Knuckles\Scribe\Tests\Fixtures\TestUser + */ class TestUserApiResource extends JsonResource { /** diff --git a/tests/GenerateDocumentation/BehavioursTest.php b/tests/GenerateDocumentation/BehavioursTest.php index 88efb0a..b86177f 100644 --- a/tests/GenerateDocumentation/BehavioursTest.php +++ b/tests/GenerateDocumentation/BehavioursTest.php @@ -234,7 +234,7 @@ class BehavioursTest extends BaseLaravelTest { RouteFacade::get('/api/test', [TestController::class, 'withEmptyApiResource']); $this->generateAndExpectConsoleOutput( - "Couldn't detect an Eloquent API resource model from your docblock. Did you remember to specify a model using @apiResourceModel?", + "Couldn't detect an Eloquent API resource model", 'Processed route: [GET] api/test' ); } diff --git a/tests/Strategies/Responses/UseApiResourceTagsTest.php b/tests/Strategies/Responses/UseApiResourceTagsTest.php index 0a35d4e..9ee2b1e 100644 --- a/tests/Strategies/Responses/UseApiResourceTagsTest.php +++ b/tests/Strategies/Responses/UseApiResourceTagsTest.php @@ -204,7 +204,7 @@ class UseApiResourceTagsTest extends BaseLaravelTest } /** @test */ - public function can_parse_apiresource_tags_with_model_factory_states() + public function can_parse_apiresourcemodel_tags_with_factory_states() { $config = new DocumentationConfig([]); @@ -233,6 +233,36 @@ class UseApiResourceTagsTest extends BaseLaravelTest ], $results); } + + /** @test */ + public function can_infer_model_from_mixin_tag_and_parse_apiresource_tags_with_factory_states() + { + $config = new DocumentationConfig([]); + + $route = new Route(['POST'], "/somethingRandom", ['uses' => [TestController::class, 'dummy']]); + + $strategy = new UseApiResourceTags($config); + $tags = [ + new Tag('apiResource', '201 \Knuckles\Scribe\Tests\Fixtures\TestUserApiResource states=state1,random-state'), + ]; + $results = $strategy->getApiResourceResponseFromTags($strategy->getApiResourceTag($tags), $tags, ExtractedEndpointData::fromRoute($route)); + + $this->assertArraySubset([ + [ + 'status' => 201, + 'content' => json_encode([ + 'data' => [ + 'id' => 4, + 'name' => 'Tested Again', + 'email' => 'a@b.com', + 'state1' => true, + 'random-state' => true, + ], + ]), + ], + ], $results); + } + /** @test */ public function loads_specified_relations_for_model() { diff --git a/tests/Strategies/Responses/UseResponseAttributesTest.php b/tests/Strategies/Responses/UseResponseAttributesTest.php index 2734bb4..fa366db 100644 --- a/tests/Strategies/Responses/UseResponseAttributesTest.php +++ b/tests/Strategies/Responses/UseResponseAttributesTest.php @@ -144,6 +144,58 @@ class UseResponseAttributesTest extends BaseLaravelTest ], $results); } + /** @test */ + public function can_parse_apiresource_attributes_with_no_model_specified() + { + $factory = app(\Illuminate\Database\Eloquent\Factory::class); + $factory->afterMaking(TestUser::class, function (TestUser $user, $faker) { + if ($user->id === 4) { + $child = Utils::getModelFactory(TestUser::class)->make(['id' => 5, 'parent_id' => 4]); + $user->setRelation('children', collect([$child])); + } + }); + + $results = $this->fetch($this->endpoint("apiResourceAttributesWithNoModel")); + + $this->assertArraySubset([ + [ + 'status' => 200, + 'content' => json_encode([ + 'data' => [ + [ + 'id' => 4, + 'name' => 'Tested Again', + 'email' => 'a@b.com', + 'children' => [ + [ + 'id' => 5, + 'name' => 'Tested Again', + 'email' => 'a@b.com', + ], + ], + 'state1' => true, + 'random-state' => true, + ], + ], + 'links' => [ + "first" => '/?page=1', + "last" => null, + "prev" => null, + "next" => '/?page=2', + ], + "meta" => [ + "current_page" => 1, + "from" => 1, + "path" => '/', + "per_page" => 1, + "to" => 1, + ], + "a" => "b", + ]), + ], + ], $results); + } + /** @test */ public function can_parse_transformer_attributes() { @@ -215,6 +267,13 @@ class ResponseAttributesTestController } + #[ResponseFromApiResource(TestUserApiResource::class, collection: true, + factoryStates: ["state1", "random-state"], simplePaginate: 1, additional: ["a" => "b"])] + public function apiResourceAttributesWithNoModel() + { + + } + #[ResponseFromTransformer(TestTransformer::class, TestModel::class, collection: true, paginate: [IlluminatePaginatorAdapter::class, 1])] public function transformerAttributes()