mirror of
https://github.com/ambieco/scribe.git
synced 2026-01-12 22:43:50 +08:00
API resources: Infer model name from @mixin
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user