Browse Source

API resources: Infer model name from @mixin

shalvah 2 years ago
parent
commit
f0ed95653b

+ 1 - 1
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"

+ 19 - 9
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);
+    }
 }

+ 43 - 21
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;
+    }
 }

+ 4 - 11
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;
     }
 }

+ 48 - 13
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(<<<WARN
+                Couldn't detect an Eloquent API resource model from your `@apiResource`.
+                Either specify a model using the `@apiResourceModel` annotation, or add an `@mixin` annotation in your resource's docblock.
+                WARN
+            );
         }
 
         return [$modelClass, $states, $relations, $pagination];
@@ -121,6 +140,22 @@ class UseApiResourceTags extends Strategy
         return $tag ? a::parseIntoFields($tag->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'));

+ 12 - 1
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(<<<WARN
+                Couldn't detect an Eloquent API resource model from your ResponseFromApiResource.
+                Either specify a model using the `model:` parameter, or add an `@mixin` annotation in your resource's docblock.
+                WARN
+            );
+            $modelInstantiator = null;
+        } else {
+            $modelInstantiator = fn() => $this->instantiateExampleModel($modelToBeTransformed, $attributeInstance->factoryStates, $attributeInstance->with);
+        }
 
         $pagination = [];
         if ($attributeInstance->paginate) {

+ 3 - 0
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
 {
     /**

+ 1 - 1
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'
         );
     }

+ 31 - 1
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()
     {

+ 59 - 0
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()