diff --git a/composer.json b/composer.json index 9ea9ead..7f2fd78 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "require": { "php" : "^7.4|^8.0|^8.1", "illuminate/support": "^7.0|^8.0|^9.0", - "illuminate/http": "^7.0|^8.0|^9.0" + "illuminate/http": "^7.0|^8.0|^9.0", + "ext-json": "*" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/publishable/config/api.php b/publishable/config/api.php index 296c4f8..a876beb 100644 --- a/publishable/config/api.php +++ b/publishable/config/api.php @@ -61,4 +61,15 @@ // 'route_prefix' => 'app' + /* + |-------------------------------------------------------------------------- + | Routing manifest file + |-------------------------------------------------------------------------- + | + | Here is the path for the routing manifest that will cache the resolution + | strategy for each Resource in every version. + */ + + 'routing_cache_path' => app()->bootstrapPath('cache/api-resources-cache.php'), + ]; diff --git a/src/APIResourceManager.php b/src/APIResourceManager.php index 8ed06cc..ac53e49 100644 --- a/src/APIResourceManager.php +++ b/src/APIResourceManager.php @@ -3,10 +3,11 @@ namespace Juampi92\APIResources; use Exception; -use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Support\Str; use Juampi92\APIResources\Exceptions\ResourceNotFoundException; +use Juampi92\APIResources\Resolvers\ResolverFactory; +use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Http\Resources\Json\ResourceCollection; use Juampi92\APIResources\Support\Version; class APIResourceManager @@ -158,50 +159,9 @@ public function getBasePath(): string return $this->path; } - /** - * Returns the classname of the versioned resource, - * or it's latest version if it doesn't exist. - * - * Throws an exception if it cannot find it. - * - * @param string $classname - * - * @return class-string - * @throws ResourceNotFoundException - */ - public function resolveClassname(string $classname) - { - $versions = $this->getVersionsBetween($this->current, $this->latest); - - foreach ($versions as $version) { - $path = $this->parseClassname($classname, $version); - - // Check if the resource was found - if (class_exists($path)) { - return $path; - } - } - - throw new Exceptions\ResourceNotFoundException($classname, $this->latest); - } - - /** - * Returns the classname with the version considering. - * - * @return class-string - */ - protected function parseClassname(string $classname, string $version): string + public function getResourcesPath(): ?string { - if (! empty($this->resources)) { - $path = $this->resources . "\\v{$version}\\" . Str::after($classname, $this->resources . "\\"); - } else { - $path = "v{$version}\\" . $classname; - } - - $path = "\\{$this->path}\\{$path}"; - - /** @phpstan-ignore-next-line */ - return $path; + return $this->resources; } /** @@ -216,9 +176,15 @@ protected function parseClassname(string $classname, string $version): string */ public function resolve(string $classname): APIResource { - return new APIResource( - $this->resolveClassname($classname) - ); + return new APIResource(ResolverFactory::make($classname)->run()); + } + + /** + * @throws ResourceNotFoundException + */ + public function resolveClassname(string $classname): string + { + return ResolverFactory::make($classname)->run(); } /** @@ -247,6 +213,14 @@ public function collection($classname, ...$args) return $resource->collection(...$args); } + /** + * @return array + */ + public function getVersionsBetweenCurrentAndLatest(): array + { + return $this->getVersionsBetween($this->current, $this->latest); + } + /** * @return array */ diff --git a/src/Facades/APIResource.php b/src/Facades/APIResource.php index 7740cdd..8810706 100644 --- a/src/Facades/APIResource.php +++ b/src/Facades/APIResource.php @@ -7,6 +7,7 @@ /** * @method static \Juampi92\APIResources\APIResourceManager setVersion(string $version, string $apiName = null) * @method static string getVersion() + * @method static string getLatestVersion() * @method static bool isLatest(string $c = null) * @method static string resolveClassname(string $classname, bool $forceLatest = null) Returns formatted classname using current version * @method static \Juampi92\APIResources\APIResource resolve(string $classname) @@ -14,6 +15,11 @@ * @method static \Illuminate\Http\Resources\Json\Resource collection(string $classname, ...$args) Resolves the classname and instantiates the resource as a collection * @method static string getRoute(string $name, array $parameters, bool $absolute) * @method static string getRouteName(string $name) + * @method static string getBasePath() + * @method static ?string getResourcesPath() + * @method static array getVersionsBetweenCurrentAndLatest() + * + * @see \Juampi92\APIResources\APIResourceManager */ class APIResource extends Facade { diff --git a/src/Resolvers/CacheResolver.php b/src/Resolvers/CacheResolver.php new file mode 100644 index 0000000..8e61c74 --- /dev/null +++ b/src/Resolvers/CacheResolver.php @@ -0,0 +1,35 @@ + $path */ + $path = $routingManifest[$this->classname][$version] ?? null; + + if (! $path) { + continue; + } + + if (class_exists($path)) { + return $path; + } + } + + throw new ResourceNotFoundException($this->classname, APIResource::getLatestVersion()); + } +} diff --git a/src/Resolvers/PathResolver.php b/src/Resolvers/PathResolver.php new file mode 100644 index 0000000..9d84f5b --- /dev/null +++ b/src/Resolvers/PathResolver.php @@ -0,0 +1,56 @@ +getPath($version); + + // Check if the resource was found + if (class_exists($path)) { + return $path; + } + } + + throw new ResourceNotFoundException($this->classname, APIResource::getLatestVersion()); + } + + private function guessResourcePath(string $version): string + { + $resourcesPath = APIResource::getResourcesPath(); + + if (empty($resourcesPath)) { + return "{$version}\\{$this->classname}"; + } + + return sprintf( + "%s\\%s\\%s", + $resourcesPath, + $version, + Str::after($this->classname, $resourcesPath . "\\") + ); + } + + /** + * @return class-string + */ + protected function getPath($version): string + { + $basePath = APIResource::getBasePath(); + + $resourcePath = $this->guessResourcePath($version); + + return "\\{$basePath}\\{$resourcePath}"; + } +} diff --git a/src/Resolvers/Resolver.php b/src/Resolvers/Resolver.php new file mode 100644 index 0000000..18022f7 --- /dev/null +++ b/src/Resolvers/Resolver.php @@ -0,0 +1,23 @@ +classname = $classname; + } + + /** + * @return class-string + * @throws ResourceNotFoundException + */ + abstract public function run(): string; +} diff --git a/src/Resolvers/ResolverFactory.php b/src/Resolvers/ResolverFactory.php new file mode 100644 index 0000000..b033c8c --- /dev/null +++ b/src/Resolvers/ResolverFactory.php @@ -0,0 +1,19 @@ + 3]); - $resourceManager = new APIResourceManager(); - $resourceManager->setVersion('2'); + APIResourceFacade::setVersion('2'); - $this->assertEquals(2, $resourceManager->getVersion()); - $this->assertEquals(3, $resourceManager->getLatestVersion()); + $this->assertEquals(2, APIResourceFacade::getVersion()); + $this->assertEquals(3, APIResourceFacade::getLatestVersion()); - $resource = $resourceManager->resolve('App\Post'); - $this->assertEquals('\\'.\Juampi92\APIResources\Tests\Fixtures\Resources\App\v3\Post::class, $resource->getClass()); + $resource = APIResourceFacade::resolve('App\Post'); + $this->assertEquals('\\'. PostV3AppResource::class, $resource->getClass()); } public function test_fails_if_no_fallback() @@ -83,11 +85,10 @@ public function test_fails_if_no_fallback() // Set latest as 2 config(['api.version' => 2]); - $resourceManager = new APIResourceManager(); - $resourceManager->setVersion(2); + APIResourceFacade::setVersion(2); - $resourceManager->resolve('App\Comment'); + APIResourceFacade::resolve('App\Comment'); } public function test_nested_resources_simple() @@ -140,12 +141,11 @@ public function test_nested_resources_with_fallback() public function test_without_resource_folder() { config(['api.resources' => '']); - $resourceManager = new APIResourceManager(); $user = new Fixtures\Models\User(); - $resourceManager->setVersion('1'); - $resource = $resourceManager->resolve('User')->make($user); + APIResourceFacade::setVersion('1'); + $resource = APIResourceFacade::resolve('User')->make($user); $this->assertInstanceOf(Fixtures\Resources\v1\User::class, $resource); @@ -155,4 +155,57 @@ public function test_without_resource_folder() 'v' => 1, ]); } + + public function test_it_prioritize_resolving_from_route_manifest(): void + { + // Arrange + $manifest = [ + 'App\User' => [ + 'v1' => UserV2Cached::class, + 'v2' => UserV2Cached::class, + 'v3' => UserV3Cached::class, + ], + ]; + + $content = sprintf( + "assertInstanceOf( + UserV2Cached::class, + APIResourceFacade::resolve('App\User')->make($user) + ); + + // Act & Assert + + APIResourceFacade::setVersion('2'); + + $this->assertInstanceOf( + UserV2Cached::class, + APIResourceFacade::resolve('App\User')->make($user) + ); + + // Act & Assert + + APIResourceFacade::setVersion('3'); + + $this->assertInstanceOf( + UserV3Cached::class, + APIResourceFacade::resolve('App\User')->make($user) + ); + + // Cleanup + + Storage::delete(config('api.routing_cache_path')); + } } diff --git a/tests/APIResourcesMultipleTest.php b/tests/APIResourcesMultipleTest.php index f0b95f8..1307b32 100644 --- a/tests/APIResourcesMultipleTest.php +++ b/tests/APIResourcesMultipleTest.php @@ -2,7 +2,7 @@ namespace Juampi92\APIResources\Tests; -use Juampi92\APIResources\APIResourceManager; +use Juampi92\APIResources\Facades\APIResource; class APIResourcesMultipleTest extends TestCase { @@ -27,12 +27,11 @@ public function test_nested_resources_with_fallback() ], 'api.default' => 'app', ]); - $resourceManager = new APIResourceManager(); $user = new Fixtures\Models\User(); - $resourceManager->setVersion('1', 'app'); - $resource = $resourceManager->resolve('App\User')->make($user); + APIResource::setVersion('1', 'app'); + $resource = APIResource::resolve('App\User')->make($user); $this->assertInstanceOf(Fixtures\Resources\App\v2\User::class, $resource); @@ -40,10 +39,8 @@ public function test_nested_resources_with_fallback() * Now change to the desktop API */ - $resourceManager = new APIResourceManager(); - - $resourceManager->setVersion('2', 'desktop'); - $resource = $resourceManager->resolve('Api\User')->make($user); + APIResource::setVersion('2', 'desktop'); + $resource = APIResource::resolve('Api\User')->make($user); $this->assertInstanceOf(Fixtures\Resources\Api\v2\User::class, $resource); } diff --git a/tests/Fixtures/Models/Post.php b/tests/Fixtures/Models/Post.php index 760754f..dddc3b2 100644 --- a/tests/Fixtures/Models/Post.php +++ b/tests/Fixtures/Models/Post.php @@ -2,7 +2,7 @@ namespace Juampi92\APIResources\Tests\Fixtures\Models; -class User +class Post { // Simulate Eloquent's dynamic attributes public $id = 2; diff --git a/tests/Fixtures/Resources/App/v1/File.php b/tests/Fixtures/Resources/App/v1/File.php new file mode 100644 index 0000000..0d6037e --- /dev/null +++ b/tests/Fixtures/Resources/App/v1/File.php @@ -0,0 +1,17 @@ + $this->id, + 'path' => $this->name, + 'v' => 1, + ]; + } +} diff --git a/tests/Fixtures/Resources/App/v2/File.php b/tests/Fixtures/Resources/App/v2/File.php new file mode 100644 index 0000000..feae3ea --- /dev/null +++ b/tests/Fixtures/Resources/App/v2/File.php @@ -0,0 +1,17 @@ + $this->id, + 'path' => $this->name, + 'v' => 2, + ]; + } +} diff --git a/tests/Fixtures/Resources/App/v3/File.php b/tests/Fixtures/Resources/App/v3/File.php new file mode 100644 index 0000000..a51ea2c --- /dev/null +++ b/tests/Fixtures/Resources/App/v3/File.php @@ -0,0 +1,17 @@ + $this->id, + 'path' => $this->name, + 'v' => 3, + ]; + } +} diff --git a/tests/Fixtures/Resources/Cached/v2/User.php b/tests/Fixtures/Resources/Cached/v2/User.php new file mode 100644 index 0000000..a2f7caf --- /dev/null +++ b/tests/Fixtures/Resources/Cached/v2/User.php @@ -0,0 +1,20 @@ + $this->id, + 'name' => $this->name, + 'rank' => api_resource('App\Rank')->make($this->rank()), + 'v' => 2, + ]; + } +} diff --git a/tests/Fixtures/Resources/Cached/v3/User.php b/tests/Fixtures/Resources/Cached/v3/User.php new file mode 100644 index 0000000..0a6e11f --- /dev/null +++ b/tests/Fixtures/Resources/Cached/v3/User.php @@ -0,0 +1,18 @@ + $this->id, + 'name' => $this->name, + 'rank' => api_resource('App\Rank')->make($this->rank()), + 'v' => 3, + ]; + } +} diff --git a/tests/Fixtures/config/multi.php b/tests/Fixtures/config/multi.php index d613b28..5b973fb 100644 --- a/tests/Fixtures/config/multi.php +++ b/tests/Fixtures/config/multi.php @@ -75,4 +75,14 @@ 'collection' => 'Collections', ], + /* + |-------------------------------------------------------------------------- + | Routing manifest file + |-------------------------------------------------------------------------- + | + | Here is the path for the routing manifest that will cache the resolution + | strategy for each Resource in every version. + */ + + 'routing_cache_path' => app()->bootstrapPath('cache/api-resources-cache.php'), ]; diff --git a/tests/Fixtures/config/simple.php b/tests/Fixtures/config/simple.php index 73e35a2..88d7867 100644 --- a/tests/Fixtures/config/simple.php +++ b/tests/Fixtures/config/simple.php @@ -49,4 +49,14 @@ 'resources' => 'App', + /* + |-------------------------------------------------------------------------- + | Routing manifest file + |-------------------------------------------------------------------------- + | + | Here is the path for the routing manifest that will cache the resolution + | strategy for each Resource in every version. + */ + + 'routing_cache_path' => app()->bootstrapPath('cache/api-resources-cache.php'), ]; diff --git a/tests/Resolvers/ResolutionStrategyTest.php b/tests/Resolvers/ResolutionStrategyTest.php new file mode 100644 index 0000000..d08fce5 --- /dev/null +++ b/tests/Resolvers/ResolutionStrategyTest.php @@ -0,0 +1,125 @@ + require __DIR__.'/../Fixtures/config/simple.php']); + } + + public function test_it_can_resolve_api_changes(): void + { + APIResource::setVersion('1'); + + $this->assertEquals( + '\\' . \Juampi92\APIResources\Tests\Fixtures\Resources\App\v1\File::class, + ResolverFactory::make('App\File')->run(), + ); + + APIResource::setVersion('2'); + + $this->assertEquals( + '\\' . \Juampi92\APIResources\Tests\Fixtures\Resources\App\v2\File::class, + ResolverFactory::make('App\File')->run(), + ); + + APIResource::setVersion('3'); + + $this->assertEquals( + '\\' . \Juampi92\APIResources\Tests\Fixtures\Resources\App\v3\File::class, + ResolverFactory::make('App\File')->run(), + ); + } + + /** + * @dataProvider resourcePathDataProvider + */ + public function test_it_can_resolve_resource_path_changes(string $path, string $version, string $classname, string $expected): void + { + config(['api.resources_path' => $path]); + config(['api.resources' => '']); + + APIResource::setVersion($version); + + $this->assertEquals( + $expected, + ResolverFactory::make($classname)->run(), + ); + } + + public function resourcePathDataProvider(): array + { + return [ + [ + 'Juampi92\\APIResources\\Tests\\Fixtures\Resources\Cached', + '2', + 'User', + '\\' . \Juampi92\APIResources\Tests\Fixtures\Resources\Cached\v2\User::class, + ], + + [ + 'Juampi92\\APIResources\\Tests\\Fixtures\Resources\Api', + '1', + 'User', + '\\' . \Juampi92\APIResources\Tests\Fixtures\Resources\Api\v1\User::class, + ], + ]; + } + + /** + * @dataProvider resourcePrefixDataProvider + */ + public function test_it_can_resolve_resources_prefix_changes(?string $prefix, string $version, string $classname, string $expected): void + { + config(['api.resources' => $prefix]); + + APIResource::setVersion($version); + + $this->assertEquals( + $expected, + ResolverFactory::make($classname)->run(), + ); + } + + public function resourcePrefixDataProvider(): array + { + return [ + [ + 'Cached', + '2', + 'User', + '\\' . \Juampi92\APIResources\Tests\Fixtures\Resources\Cached\v2\User::class, + ], + + [ + 'Api', + '1', + 'User', + '\\' . \Juampi92\APIResources\Tests\Fixtures\Resources\Api\v1\User::class, + ], + + [ + '', + '1', + 'User', + '\\' . \Juampi92\APIResources\Tests\Fixtures\Resources\v1\User::class, + ], + + [ + null, + '1', + 'User', + '\\' . \Juampi92\APIResources\Tests\Fixtures\Resources\v1\User::class, + ], + ]; + } +} diff --git a/tests/ResourcePathResolveTest.php b/tests/ResourcePathResolveTest.php deleted file mode 100644 index e9e9aca..0000000 --- a/tests/ResourcePathResolveTest.php +++ /dev/null @@ -1,78 +0,0 @@ - require __DIR__ . '/../publishable/config/api.php']); - - $this->apiResourceManager = new APIResourceManager(); - } - - public function test_it_can_resolve_api_changes() - { - $this->apiResourceManager->setVersion('1'); - $classname = $this->callMethod($this->apiResourceManager, 'parseClassname', ['App\User', '1']); - $this->assertEquals('\\App\\Http\\Resources\\App\\v1\\User', $classname); - - $this->apiResourceManager->setVersion('2'); - $classname = $this->callMethod($this->apiResourceManager, 'parseClassname', ['App\Users', '2']); - $this->assertEquals('\\App\\Http\\Resources\\App\\v2\\Users', $classname); - - $this->apiResourceManager->setVersion('3'); - $classname = $this->callMethod($this->apiResourceManager, 'parseClassname', ['App\User\Single', '3']); - $this->assertEquals('\\App\\Http\\Resources\\App\\v3\\User\\Single', $classname); - } - - public function test_it_can_resolve_resource_path_changes() - { - config(['api.resources_path' => 'App\Resources']); - $this->apiResourceManager->setVersion('3'); - $classname = $this->callMethod($this->apiResourceManager, 'parseClassname', ['App\User', '3']); - $this->assertEquals('\\App\\Resources\\App\\v3\\User', $classname); - - config(['api.resources_path' => 'App\Resources2']); - $this->apiResourceManager->setVersion('4'); - $classname = $this->callMethod($this->apiResourceManager, 'parseClassname', ['App\User', '4']); - $this->assertEquals('\\App\\Resources2\\App\\v4\\User', $classname); - } - - public function test_it_can_resolve_resources_prefix_changes() - { - config(['api.resources' => 'Api']); - $this->apiResourceManager->setVersion('1'); - $classname = $this->callMethod($this->apiResourceManager, 'parseClassname', ['Api\User', '1']); - $this->assertEquals('\\App\\Http\\Resources\\Api\\v1\\User', $classname); - - config(['api.resources' => 'Api\App']); - $this->apiResourceManager->setVersion('1'); - $classname = $this->callMethod($this->apiResourceManager, 'parseClassname', ['Api\App\User', '1']); - $this->assertEquals('\\App\\Http\\Resources\\Api\\App\\v1\\User', $classname); - } - - public function test_it_can_resolve_resources_prefix_empty() - { - config(['api.resources' => '']); - $this->apiResourceManager->setVersion('1'); - $classname = $this->callMethod($this->apiResourceManager, 'parseClassname', ['User', '1']); - $this->assertEquals('\\App\\Http\\Resources\\v1\\User', $classname); - - config(['api.resources' => null]); - $this->apiResourceManager->setVersion('1'); - $classname = $this->callMethod($this->apiResourceManager, 'parseClassname', ['User', '1']); - $this->assertEquals('\\App\\Http\\Resources\\v1\\User', $classname); - } -}