Skip to content

Commit

Permalink
API resources: Infer model name from @mixin
Browse files Browse the repository at this point in the history
  • Loading branch information
shalvah committed May 28, 2023
1 parent 0fe280e commit f0ed956
Show file tree
Hide file tree
Showing 10 changed files with 221 additions and 58 deletions.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 19 additions & 9 deletions src/Attributes/ResponseFromApiResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
64 changes: 43 additions & 21 deletions src/Extracting/Shared/ApiResourceResponseTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,54 +9,56 @@
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) {
Expand Down Expand Up @@ -94,4 +96,24 @@ public static function getApiResourceOrCollectionInstance(

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

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;

Expand Down Expand Up @@ -66,18 +68,9 @@ public function getFromTags(array $tagsOnMethod, array $tagsOnClass = []): array
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;
}
}
61 changes: 48 additions & 13 deletions src/Extracting/Strategies/Responses/UseApiResourceTags.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
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 ).
Expand Down Expand Up @@ -46,8 +48,8 @@ public function __invoke(ExtractedEndpointData $endpointData, array $routeRules
*/
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);
Expand Down Expand Up @@ -75,34 +77,51 @@ private function getStatusCodeAndApiResourceClass(Tag $tag): array
$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];
Expand All @@ -121,6 +140,22 @@ private function getAdditionalData(array $tags): array
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'));
Expand Down
13 changes: 12 additions & 1 deletion src/Extracting/Strategies/Responses/UseResponseAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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;

/**
Expand Down Expand Up @@ -50,7 +51,17 @@ protected function extractFromAttributes(

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) {
Expand Down
3 changes: 3 additions & 0 deletions tests/Fixtures/TestUserApiResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

use Illuminate\Http\Resources\Json\JsonResource;

/**
* @mixin \Knuckles\Scribe\Tests\Fixtures\TestUser
*/
class TestUserApiResource extends JsonResource
{
/**
Expand Down
2 changes: 1 addition & 1 deletion tests/GenerateDocumentation/BehavioursTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ public function can_generate_with_apiresource_tag_but_without_apiresourcemodel_t
{
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'
);
}
Expand Down
32 changes: 31 additions & 1 deletion tests/Strategies/Responses/UseApiResourceTagsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ public function properly_binds_route_and_request_when_fetching_apiresource_respo
}

/** @test */
public function can_parse_apiresource_tags_with_model_factory_states()
public function can_parse_apiresourcemodel_tags_with_factory_states()
{
$config = new DocumentationConfig([]);

Expand Down Expand Up @@ -233,6 +233,36 @@ public function can_parse_apiresource_tags_with_model_factory_states()
], $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' => '[email protected]',
'state1' => true,
'random-state' => true,
],
]),
],
], $results);
}

/** @test */
public function loads_specified_relations_for_model()
{
Expand Down
Loading

0 comments on commit f0ed956

Please sign in to comment.