API resources: Infer model name from @mixin

This commit is contained in:
shalvah
2023-05-28 15:32:31 +02:00
parent 0fe280ed8f
commit f0ed95653b
10 changed files with 221 additions and 58 deletions

View File

@@ -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"

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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'));

View File

@@ -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) {

View File

@@ -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
{
/**

View File

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

View File

@@ -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()
{

View File

@@ -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()