From 482f48782546c401a978d2ad2d7ab8068395f11d Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 4 Feb 2021 09:30:49 +0000 Subject: [PATCH 1/6] Initial controller action --- .../Controllers/Api/V1/PostController.php | 25 ++++++++- tests/dummy/routes/api.php | 4 ++ .../dummy/tests/Api/V1/Posts/PublishTest.php | 53 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/dummy/tests/Api/V1/Posts/PublishTest.php diff --git a/tests/dummy/app/Http/Controllers/Api/V1/PostController.php b/tests/dummy/app/Http/Controllers/Api/V1/PostController.php index 2ceae18..c84346f 100644 --- a/tests/dummy/app/Http/Controllers/Api/V1/PostController.php +++ b/tests/dummy/app/Http/Controllers/Api/V1/PostController.php @@ -19,9 +19,14 @@ namespace App\Http\Controllers\Api\V1; +use App\Http\Controllers\Controller; +use App\JsonApi\V1\Posts\PostQuery; +use App\Models\Post; +use LaravelJsonApi\Contracts\Store\Store; +use LaravelJsonApi\Core\Responses\DataResponse; use LaravelJsonApi\Laravel\Http\Controllers\Actions; -class PostController +class PostController extends Controller { use Actions\FetchMany; @@ -35,4 +40,22 @@ class PostController use Actions\AttachRelationship; use Actions\DetachRelationship; + /** + * Publish a post. + * + * @param Store $store + * @param Post $post + * @return DataResponse + */ + public function publish(Store $store, Post $post) + { + $post->update(['published_at' => now()]); + +// $model = $store +// ->queryOne('posts', $post) +// ->using($request) +// ->first(); + + return new DataResponse($post); + } } diff --git a/tests/dummy/routes/api.php b/tests/dummy/routes/api.php index 5d600ff..4a78503 100644 --- a/tests/dummy/routes/api.php +++ b/tests/dummy/routes/api.php @@ -1,5 +1,7 @@ prefix('v1')->namespace('Api\V1')->resources(function ($server) { @@ -9,6 +11,8 @@ $relationships->hasMany('tags'); }); + Route::post('posts/{post}/-actions/publish', [PostController::class, 'publish']); + $server->resource('videos')->relationships(function ($relationships) { $relationships->hasMany('tags'); }); diff --git a/tests/dummy/tests/Api/V1/Posts/PublishTest.php b/tests/dummy/tests/Api/V1/Posts/PublishTest.php new file mode 100644 index 0000000..851b6e1 --- /dev/null +++ b/tests/dummy/tests/Api/V1/Posts/PublishTest.php @@ -0,0 +1,53 @@ +travelTo($date = now()->milliseconds(0)); + + $post = Post::factory()->create(['published_at' => null]); + + $expected = $this->serializer + ->post($post) + ->replace('publishedAt', $date->jsonSerialize()) + ->jsonSerialize(); + + $response = $this + ->withoutExceptionHandling() + ->actingAs($post->author) + ->jsonApi('posts') + ->contentType('application/json') + ->post(url('/api/v1/posts', [$post, '-actions/publish'])); + + $response->assertFetchedOneExact($expected); + + $this->assertDatabaseHas('posts', array_replace( + $post->getAttributes(), + ['published_at' => $date->toDateTimeString()] + )); + } +} From 66e469ee886175900e31e06f528e3e281bc1e7bd Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 4 Feb 2021 11:18:46 +0000 Subject: [PATCH 2/6] Add registration of custom actions --- src/Routing/ActionRegistrar.php | 198 ++++++++++++++++++ src/Routing/PendingResourceRegistration.php | 52 +++++ src/Routing/ResourceRegistrar.php | 124 ++++++++--- .../Controllers/Api/V1/PostController.php | 11 +- .../dummy/app/JsonApi/V1/Posts/PostQuery.php | 17 ++ tests/dummy/routes/api.php | 6 +- .../dummy/tests/Api/V1/Posts/PublishTest.php | 56 ++++- 7 files changed, 416 insertions(+), 48 deletions(-) create mode 100644 src/Routing/ActionRegistrar.php diff --git a/src/Routing/ActionRegistrar.php b/src/Routing/ActionRegistrar.php new file mode 100644 index 0000000..f281317 --- /dev/null +++ b/src/Routing/ActionRegistrar.php @@ -0,0 +1,198 @@ +router = $router; + $this->resource = $resource; + $this->routes = $routes; + $this->resourceType = $resourceType; + $this->options = $options; + $this->controller = $controller; + $this->prefix = $prefix; + } + + /** + * @return $this + */ + public function withId(): self + { + $copy = clone $this; + $copy->id = true; + + return $copy; + } + + /** + * @param string $uri + * @param string|null $method + * @return IlluminateRoute + */ + public function post(string $uri, string $method = null): IlluminateRoute + { + $method = $method ?: $this->guessMethod($uri); + $parameter = $this->getParameter(); + + $route = $this->router->post( + $this->uri($uri, $parameter), + sprintf('%s@%s', $this->controller, $method) + ); + + $this->route($route, $parameter); + + return $route; + } + + /** + * @return string|null + */ + private function getParameter(): ?string + { + if ($this->id) { + return $this->resource->getResourceParameterName( + $this->resourceType, + $this->options + ); + } + + return null; + } + + /** + * Configure the supplied route. + * + * @param IlluminateRoute $route + * @param string|null $parameter + */ + private function route(IlluminateRoute $route, ?string $parameter): void + { + $route->where($this->resource->getWheres( + $this->resourceType, + $parameter, + $this->options + )); + + $route->defaults(Route::RESOURCE_TYPE, $this->resourceType); + + if ($parameter) { + $route->defaults(Route::RESOURCE_ID_NAME, $parameter); + } + + $this->routes->add($route); + } + + /** + * Normalize the URI. + * + * @param string $uri + * @param string|null $parameter + * @return string + */ + private function uri(string $uri, ?string $parameter): string + { + $uri = ltrim($uri, '/'); + + if ($this->prefix) { + $uri = sprintf('%s/%s', $this->prefix, $uri); + } + + if ($this->id) { + return sprintf('{%s}/%s', $parameter, $uri); + } + + return $uri; + } + + /** + * @param string $uri + * @return string + */ + private function guessMethod(string $uri): string + { + return Str::camel($uri); + } +} diff --git a/src/Routing/PendingResourceRegistration.php b/src/Routing/PendingResourceRegistration.php index 92dedcb..cc1763c 100644 --- a/src/Routing/PendingResourceRegistration.php +++ b/src/Routing/PendingResourceRegistration.php @@ -21,6 +21,8 @@ use Closure; use Illuminate\Routing\RouteCollection; +use InvalidArgumentException; +use function is_string; class PendingResourceRegistration { @@ -55,6 +57,16 @@ class PendingResourceRegistration */ private ?Closure $relationships = null; + /** + * @var string|null + */ + private ?string $actionsPrefix = null; + + /** + * @var Closure|null + */ + private ?Closure $actions = null; + /** * @var array|string[] */ @@ -195,6 +207,8 @@ public function withoutMiddleware(string ...$middleware) } /** + * Register resource relationship routes. + * * @param Closure $callback * @return $this */ @@ -205,6 +219,30 @@ public function relationships(Closure $callback): self return $this; } + /** + * Register custom actions for the resource. + * + * @param string|Closure $prefixOrCallback + * @param Closure|null $callback + * @return $this + */ + public function actions($prefixOrCallback, Closure $callback = null): self + { + if ($prefixOrCallback instanceof Closure && null === $callback) { + $this->actionsPrefix = null; + $this->actions = $prefixOrCallback; + return $this; + } + + if (is_string($prefixOrCallback) && !empty($prefixOrCallback) && $callback instanceof Closure) { + $this->actionsPrefix = $prefixOrCallback; + $this->actions = $callback; + return $this; + } + + throw new InvalidArgumentException('Invalid arguments when registering custom resource actions.'); + } + /** * Register the resource routes. * @@ -233,6 +271,20 @@ public function register(): RouteCollection } } + if ($this->actions) { + $actions = $this->registrar->actions( + $this->resourceType, + $this->controller, + $this->options, + $this->actionsPrefix, + $this->actions + ); + + foreach ($actions as $route) { + $routes->add($route); + } + } + return $routes; } diff --git a/src/Routing/ResourceRegistrar.php b/src/Routing/ResourceRegistrar.php index a50ecfa..50d4388 100644 --- a/src/Routing/ResourceRegistrar.php +++ b/src/Routing/ResourceRegistrar.php @@ -109,6 +109,43 @@ public function relationships( return $routes; } + /** + * Register resource custom actions. + * + * @param string $resourceType + * @param string $controller + * @param array $options + * @param string|null $prefix + * @param Closure $callback + * @return RouteCollection + */ + public function actions( + string $resourceType, + string $controller, + array $options, + ?string $prefix, + Closure $callback + ): RouteCollection + { + $attributes = $this->getCustomActions($resourceType, $options); + + $actions = new ActionRegistrar( + $this->router, + $this, + $routes = new RouteCollection(), + $resourceType, + $options, + $controller, + $prefix + ); + + $this->router->group($attributes, function () use ($actions, $callback) { + $callback($actions); + }); + + return $routes; + } + /** * Register resource routes. * @@ -130,6 +167,47 @@ public function register(string $resourceType, string $controller, array $option return $routes; } + /** + * @param string $resourceType + * @param array $options + * @return string + */ + public function getResourceParameterName(string $resourceType, array $options): string + { + if (isset($options['parameter'])) { + return $options['parameter']; + } + + $param = Str::singular($resourceType); + + /** + * Dash-case is not allowed for route parameters. Therefore if the + * resource type contains a dash, we will underscore it. + */ + if (Str::contains($param, '-')) { + $param = Str::underscore($param); + } + + return $param; + } + + /** + * @param string $resourceType + * @param string|null $parameter + * @param array $options + * @return array + */ + public function getWheres(string $resourceType, ?string $parameter, array $options): array + { + $where = $options['wheres'] ?? []; + + if ($parameter && !isset($action['where'][$parameter])) { + $where[$parameter] = $this->getIdPattern($resourceType); + } + + return $where; + } + /** * Add the index method. * @@ -243,30 +321,6 @@ private function getResourceUri(string $resourceType): string ->uriType(); } - /** - * @param string $resourceType - * @param array $options - * @return string - */ - private function getResourceParameterName(string $resourceType, array $options): string - { - if (isset($options['parameter'])) { - return $options['parameter']; - } - - $param = Str::singular($resourceType); - - /** - * Dash-case is not allowed for route parameters. Therefore if the - * resource type contains a dash, we will underscore it. - */ - if (Str::contains($param, '-')) { - $param = Str::underscore($param); - } - - return $param; - } - /** * Get the action array for a resource route. * @@ -332,27 +386,35 @@ private function getRelationshipsAction(string $resourceType, ?string $parameter } /** + * Get the action array for custom the actions group. + * * @param string $resourceType - * @param string|null $parameter * @param array $options * @return array */ - private function getWheres(string $resourceType, ?string $parameter, array $options): array + private function getCustomActions(string $resourceType, array $options) { - $where = $options['wheres'] ?? []; + $action = [ + 'prefix' => $this->getResourceUri($resourceType), + 'as' => "{$resourceType}.", + ]; - if ($parameter && !isset($action['where'][$parameter])) { - $where[$parameter] = $this->getIdPattern($resourceType); + if (isset($options['middleware'])) { + $action['middleware'] = $options['middleware']; } - return $where; + if (isset($options['excluded_middleware'])) { + $action['excluded_middleware'] = $options['excluded_middleware']; + } + + return $action; } /** * @param string $resourceType * @return string */ - private function getIdPattern(string $resourceType): string + public function getIdPattern(string $resourceType): string { return $this->server ->schemas() diff --git a/tests/dummy/app/Http/Controllers/Api/V1/PostController.php b/tests/dummy/app/Http/Controllers/Api/V1/PostController.php index c84346f..1ebe07f 100644 --- a/tests/dummy/app/Http/Controllers/Api/V1/PostController.php +++ b/tests/dummy/app/Http/Controllers/Api/V1/PostController.php @@ -44,17 +44,18 @@ class PostController extends Controller * Publish a post. * * @param Store $store + * @param PostQuery $query * @param Post $post * @return DataResponse */ - public function publish(Store $store, Post $post) + public function publish(Store $store, PostQuery $query, Post $post) { $post->update(['published_at' => now()]); -// $model = $store -// ->queryOne('posts', $post) -// ->using($request) -// ->first(); + $model = $store + ->queryOne('posts', $post) + ->using($query) + ->first(); return new DataResponse($post); } diff --git a/tests/dummy/app/JsonApi/V1/Posts/PostQuery.php b/tests/dummy/app/JsonApi/V1/Posts/PostQuery.php index c9ab7a5..8d2ed70 100644 --- a/tests/dummy/app/JsonApi/V1/Posts/PostQuery.php +++ b/tests/dummy/app/JsonApi/V1/Posts/PostQuery.php @@ -25,6 +25,23 @@ class PostQuery extends ResourceQuery { + /** + * Authorize the request. + * + * @return bool|null + */ + public function authorize(): ?bool + { + if ($this->is('*-actions*')) { + return (bool) optional($this->user())->can( + 'update', + $this->model() + ); + } + + return null; + } + /** * Get the validation rules that apply to the request. * diff --git a/tests/dummy/routes/api.php b/tests/dummy/routes/api.php index 4a78503..343cd07 100644 --- a/tests/dummy/routes/api.php +++ b/tests/dummy/routes/api.php @@ -1,7 +1,5 @@ prefix('v1')->namespace('Api\V1')->resources(function ($server) { @@ -9,10 +7,10 @@ $relationships->hasOne('author')->readOnly(); $relationships->hasMany('comments')->readOnly(); $relationships->hasMany('tags'); + })->actions('-actions', function ($actions) { + $actions->withId()->post('publish'); }); - Route::post('posts/{post}/-actions/publish', [PostController::class, 'publish']); - $server->resource('videos')->relationships(function ($relationships) { $relationships->hasMany('tags'); }); diff --git a/tests/dummy/tests/Api/V1/Posts/PublishTest.php b/tests/dummy/tests/Api/V1/Posts/PublishTest.php index 851b6e1..dc4bc51 100644 --- a/tests/dummy/tests/Api/V1/Posts/PublishTest.php +++ b/tests/dummy/tests/Api/V1/Posts/PublishTest.php @@ -20,34 +20,74 @@ namespace App\Tests\Api\V1\Posts; use App\Models\Post; +use App\Models\User; use App\Tests\Api\V1\TestCase; class PublishTest extends TestCase { + /** + * @var Post + */ + private Post $post; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + $this->post = Post::factory()->create(['published_at' => null]); + } + public function test(): void { $this->travelTo($date = now()->milliseconds(0)); - $post = Post::factory()->create(['published_at' => null]); - $expected = $this->serializer - ->post($post) + ->post($this->post) ->replace('publishedAt', $date->jsonSerialize()) - ->jsonSerialize(); + ->replace('author', ['type' => 'users', 'id' => $this->post->author]); $response = $this ->withoutExceptionHandling() - ->actingAs($post->author) + ->actingAs($this->post->author) ->jsonApi('posts') ->contentType('application/json') - ->post(url('/api/v1/posts', [$post, '-actions/publish'])); + ->includePaths('author') + ->post(url('/api/v1/posts', [$this->post, '-actions/publish'])); - $response->assertFetchedOneExact($expected); + $response->assertFetchedOneExact($expected->jsonSerialize()); + $response->assertIncluded([$expected['author']]); $this->assertDatabaseHas('posts', array_replace( - $post->getAttributes(), + $this->post->getRawOriginal(), ['published_at' => $date->toDateTimeString()] )); } + + public function testUnauthorized(): void + { + $response = $this + ->jsonApi('posts') + ->contentType('application/json') + ->post(url('/api/v1/posts', [$this->post, '-actions/publish'])); + + $response->assertNotFound(); + + $this->assertDatabaseHas('posts', $this->post->getRawOriginal()); + } + + public function testForbidden(): void + { + $response = $this + ->actingAs(User::factory()->create()) + ->jsonApi('posts') + ->contentType('application/json') + ->post(url('/api/v1/posts', [$this->post, '-actions/publish'])); + + $response->assertNotFound(); + + $this->assertDatabaseHas('posts', $this->post->getRawOriginal()); + } } From 354af34688d83f022325d960b80717948b737563 Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 4 Feb 2021 13:37:35 +0000 Subject: [PATCH 3/6] Add testing for registering different method custom actions --- CHANGELOG.md | 2 + src/Routing/ActionRegistrar.php | 85 +++++++- .../Controllers/Api/V1/PostController.php | 21 +- tests/dummy/app/Models/User.php | 8 + tests/dummy/app/Policies/PostPolicy.php | 9 + .../dummy/database/factories/UserFactory.php | 8 + tests/dummy/routes/api.php | 5 +- .../V1/Posts/{ => Actions}/PublishTest.php | 2 +- .../tests/Api/V1/Posts/Actions/PurgeTest.php | 56 ++++++ tests/lib/Integration/Routing/ActionsTest.php | 185 ++++++++++++++++++ tests/lib/Integration/Routing/HasManyTest.php | 26 +++ tests/lib/Integration/Routing/HasOneTest.php | 26 +++ .../lib/Integration/Routing/ResourceTest.php | 30 +++ 13 files changed, 452 insertions(+), 11 deletions(-) rename tests/dummy/tests/Api/V1/Posts/{ => Actions}/PublishTest.php (98%) create mode 100644 tests/dummy/tests/Api/V1/Posts/Actions/PurgeTest.php create mode 100644 tests/lib/Integration/Routing/ActionsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4678750..e6a3e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to this project will be documented in this file. This projec ### Added +- Can now register routes for custom actions on a resource, using the `actions()` helper method when registering + resources. - The `JsonApiController` now has the Laravel `AuthorizesRequests`, `DispatchesJobs` and `ValidatesRequests` traits applied. diff --git a/src/Routing/ActionRegistrar.php b/src/Routing/ActionRegistrar.php index f281317..bef02b8 100644 --- a/src/Routing/ActionRegistrar.php +++ b/src/Routing/ActionRegistrar.php @@ -76,7 +76,7 @@ class ActionRegistrar * @param string $resourceType * @param array $options * @param string $controller - * @param string $prefix + * @param string|null $prefix */ public function __construct( RegistrarContract $router, @@ -85,7 +85,7 @@ public function __construct( string $resourceType, array $options, string $controller, - string $prefix + string $prefix = null ) { $this->router = $router; $this->resource = $resource; @@ -108,18 +108,91 @@ public function withId(): self } /** + * Register a new GET route. + * + * @param string $uri + * @param string|null $method + * @return IlluminateRoute + */ + public function get(string $uri, string $method = null): IlluminateRoute + { + return $this->register('get', $uri, $method); + } + + /** + * Register a new POST route. + * * @param string $uri * @param string|null $method * @return IlluminateRoute */ public function post(string $uri, string $method = null): IlluminateRoute { - $method = $method ?: $this->guessMethod($uri); + return $this->register('post', $uri, $method); + } + + /** + * Register a new PATCH route. + * + * @param string $uri + * @param string|null $method + * @return IlluminateRoute + */ + public function patch(string $uri, string $method = null): IlluminateRoute + { + return $this->register('patch', $uri, $method); + } + + /** + * Register a new PUT route. + * + * @param string $uri + * @param string|null $method + * @return IlluminateRoute + */ + public function put(string $uri, string $method = null): IlluminateRoute + { + return $this->register('put', $uri, $method); + } + + /** + * Register a new DELETE route. + * + * @param string $uri + * @param string|null $method + * @return IlluminateRoute + */ + public function delete(string $uri, string $method = null): IlluminateRoute + { + return $this->register('delete', $uri, $method); + } + + /** + * Register a new OPTIONS route. + * + * @param string $uri + * @param string|null $method + * @return IlluminateRoute + */ + public function options(string $uri, string $method = null): IlluminateRoute + { + return $this->register('options', $uri, $method); + } + + /** + * @param string $method + * @param string $uri + * @param string|null $action + * @return IlluminateRoute + */ + public function register(string $method, string $uri, string $action = null): IlluminateRoute + { + $action = $action ?: $this->guessControllerAction($uri); $parameter = $this->getParameter(); - $route = $this->router->post( + $route = $this->router->{$method}( $this->uri($uri, $parameter), - sprintf('%s@%s', $this->controller, $method) + sprintf('%s@%s', $this->controller, $action) ); $this->route($route, $parameter); @@ -191,7 +264,7 @@ private function uri(string $uri, ?string $parameter): string * @param string $uri * @return string */ - private function guessMethod(string $uri): string + private function guessControllerAction(string $uri): string { return Str::camel($uri); } diff --git a/tests/dummy/app/Http/Controllers/Api/V1/PostController.php b/tests/dummy/app/Http/Controllers/Api/V1/PostController.php index 1ebe07f..d811611 100644 --- a/tests/dummy/app/Http/Controllers/Api/V1/PostController.php +++ b/tests/dummy/app/Http/Controllers/Api/V1/PostController.php @@ -22,6 +22,8 @@ use App\Http\Controllers\Controller; use App\JsonApi\V1\Posts\PostQuery; use App\Models\Post; +use Illuminate\Contracts\Support\Responsable; +use Illuminate\Http\Response; use LaravelJsonApi\Contracts\Store\Store; use LaravelJsonApi\Core\Responses\DataResponse; use LaravelJsonApi\Laravel\Http\Controllers\Actions; @@ -40,15 +42,28 @@ class PostController extends Controller use Actions\AttachRelationship; use Actions\DetachRelationship; + /** + * @return Response + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function purge(): Response + { + $this->authorize('deleteAll', Post::class); + + Post::query()->delete(); + + return response('', 204); + } + /** * Publish a post. * * @param Store $store * @param PostQuery $query * @param Post $post - * @return DataResponse + * @return Responsable */ - public function publish(Store $store, PostQuery $query, Post $post) + public function publish(Store $store, PostQuery $query, Post $post): Responsable { $post->update(['published_at' => now()]); @@ -57,6 +72,6 @@ public function publish(Store $store, PostQuery $query, Post $post) ->using($query) ->first(); - return new DataResponse($post); + return new DataResponse($model); } } diff --git a/tests/dummy/app/Models/User.php b/tests/dummy/app/Models/User.php index 3f20825..200b8ee 100644 --- a/tests/dummy/app/Models/User.php +++ b/tests/dummy/app/Models/User.php @@ -53,4 +53,12 @@ class User extends Authenticatable protected $casts = [ 'email_verified_at' => 'datetime', ]; + + /** + * @return bool + */ + public function isAdmin(): bool + { + return 'support@example.com' === $this->email; + } } diff --git a/tests/dummy/app/Policies/PostPolicy.php b/tests/dummy/app/Policies/PostPolicy.php index 54116ad..683727c 100644 --- a/tests/dummy/app/Policies/PostPolicy.php +++ b/tests/dummy/app/Policies/PostPolicy.php @@ -144,6 +144,15 @@ public function delete(?User $user, Post $post): bool return $this->author($user, $post); } + /** + * @param User|null $user + * @return bool + */ + public function deleteAll(?User $user): bool + { + return $user && $user->isAdmin(); + } + /** * @param User|null $user * @param Post $post diff --git a/tests/dummy/database/factories/UserFactory.php b/tests/dummy/database/factories/UserFactory.php index 33d0124..1de1269 100644 --- a/tests/dummy/database/factories/UserFactory.php +++ b/tests/dummy/database/factories/UserFactory.php @@ -47,4 +47,12 @@ public function definition() 'remember_token' => Str::random(10), ]; } + + /** + * @return UserFactory + */ + public function admin(): self + { + return $this->state(['email' => 'support@example.com']); + } } diff --git a/tests/dummy/routes/api.php b/tests/dummy/routes/api.php index 343cd07..3a7f487 100644 --- a/tests/dummy/routes/api.php +++ b/tests/dummy/routes/api.php @@ -3,14 +3,17 @@ use LaravelJsonApi\Laravel\Facades\JsonApiRoute; JsonApiRoute::server('v1')->prefix('v1')->namespace('Api\V1')->resources(function ($server) { + /** Posts */ $server->resource('posts')->relationships(function ($relationships) { $relationships->hasOne('author')->readOnly(); $relationships->hasMany('comments')->readOnly(); $relationships->hasMany('tags'); })->actions('-actions', function ($actions) { - $actions->withId()->post('publish'); + $actions->delete('purge')->name('purge'); + $actions->withId()->post('publish')->name('publish'); }); + /** Videos */ $server->resource('videos')->relationships(function ($relationships) { $relationships->hasMany('tags'); }); diff --git a/tests/dummy/tests/Api/V1/Posts/PublishTest.php b/tests/dummy/tests/Api/V1/Posts/Actions/PublishTest.php similarity index 98% rename from tests/dummy/tests/Api/V1/Posts/PublishTest.php rename to tests/dummy/tests/Api/V1/Posts/Actions/PublishTest.php index dc4bc51..f18f600 100644 --- a/tests/dummy/tests/Api/V1/Posts/PublishTest.php +++ b/tests/dummy/tests/Api/V1/Posts/Actions/PublishTest.php @@ -17,7 +17,7 @@ declare(strict_types=1); -namespace App\Tests\Api\V1\Posts; +namespace App\Tests\Api\V1\Posts\Actions; use App\Models\Post; use App\Models\User; diff --git a/tests/dummy/tests/Api/V1/Posts/Actions/PurgeTest.php b/tests/dummy/tests/Api/V1/Posts/Actions/PurgeTest.php new file mode 100644 index 0000000..addb6ea --- /dev/null +++ b/tests/dummy/tests/Api/V1/Posts/Actions/PurgeTest.php @@ -0,0 +1,56 @@ +count(3)->create(); + + $response = $this + ->actingAs(User::factory()->admin()->create()) + ->jsonApi('posts') + ->delete('/api/v1/posts/-actions/purge'); + + $response->assertNoContent(); + + $this->assertDatabaseCount('posts', 0); + } + + public function testForbidden(): void + { + Post::factory()->count(3)->create(); + + $response = $this + ->actingAs(User::factory()->create()) + ->jsonApi('posts') + ->delete('/api/v1/posts/-actions/purge'); + + $response->assertForbidden(); + + $this->assertDatabaseCount('posts', 3); + } +} diff --git a/tests/lib/Integration/Routing/ActionsTest.php b/tests/lib/Integration/Routing/ActionsTest.php new file mode 100644 index 0000000..8ec293c --- /dev/null +++ b/tests/lib/Integration/Routing/ActionsTest.php @@ -0,0 +1,185 @@ + ['GET'], + 'POST' => ['POST'], + 'PATCH' => ['PATCH'], + 'PUT' => ['PUT'], + 'DELETE' => ['DELETE'], + 'OPTIONS' => ['OPTIONS'], + ]; + } + + /** + * @param string $method + * @dataProvider methodProvider + */ + public function testBase(string $method): void + { + $func = strtolower($method); + $server = $this->createServer('v1'); + $this->createSchema($server, 'posts', '\d+'); + + $this->defaultApiRoutesWithNamespace(function () use ($func) { + JsonApiRoute::server('v1') + ->prefix('v1') + ->namespace('Api\\V1') + ->resources(function ($server) use ($func) { + $server->resource('posts')->actions(function ($actions) use ($func) { + $actions->{$func}('foo-bar')->name('foobar'); + }); + }); + }); + + $route = $this->assertMatch($method, '/api/v1/posts/foo-bar'); + $this->assertSame("App\Http\Controllers\Api\V1\PostController@fooBar", $route->action['controller']); + $this->assertSame("v1.posts.foobar", $route->getName()); + $this->assertSame(['api', 'jsonapi:v1'], $route->action['middleware']); + $this->assertSame('posts', $route->parameter('resource_type')); + $this->assertNull($route->parameter('resource_id_name')); + $this->assertArrayNotHasKey('post', $route->wheres); + } + + /** + * @param string $method + * @dataProvider methodProvider + */ + public function testBaseWithPrefix(string $method): void + { + $func = strtolower($method); + $server = $this->createServer('v1'); + $this->createSchema($server, 'posts', '\d+'); + + $this->defaultApiRoutesWithNamespace(function () use ($func) { + JsonApiRoute::server('v1') + ->prefix('v1') + ->namespace('Api\\V1') + ->resources(function ($server) use ($func) { + $server->resource('posts')->actions('-actions', function ($actions) use ($func) { + $actions->{$func}('foo-bar')->name('foobar'); + }); + }); + }); + + $route = $this->assertMatch($method, '/api/v1/posts/-actions/foo-bar'); + $this->assertSame("App\Http\Controllers\Api\V1\PostController@fooBar", $route->action['controller']); + $this->assertSame("v1.posts.foobar", $route->getName()); + $this->assertSame(['api', 'jsonapi:v1'], $route->action['middleware']); + $this->assertSame('posts', $route->parameter('resource_type')); + $this->assertNull($route->parameter('resource_id_name')); + $this->assertArrayNotHasKey('post', $route->wheres); + } + + /** + * @param string $method + * @dataProvider methodProvider + */ + public function testId(string $method): void + { + $func = strtolower($method); + $server = $this->createServer('v1'); + $this->createSchema($server, 'posts', '\d+'); + + $this->defaultApiRoutesWithNamespace(function () use ($func) { + JsonApiRoute::server('v1') + ->prefix('v1') + ->namespace('Api\\V1') + ->resources(function ($server) use ($func) { + $server->resource('posts')->actions(function ($actions) use ($func) { + $actions->withId()->{$func}('foo-bar')->name('foobar'); + }); + }); + }); + + $route = $this->assertMatch($method, '/api/v1/posts/123/foo-bar'); + $this->assertSame("App\Http\Controllers\Api\V1\PostController@fooBar", $route->action['controller']); + $this->assertSame("v1.posts.foobar", $route->getName()); + $this->assertSame(['api', 'jsonapi:v1'], $route->action['middleware']); + $this->assertSame('posts', $route->parameter('resource_type')); + $this->assertSame('post', $route->parameter('resource_id_name')); + $this->assertSame('\d+', $route->wheres['post'] ?? null); + } + + /** + * @param string $method + * @dataProvider methodProvider + */ + public function testIdWithPrefix(string $method): void + { + $func = strtolower($method); + $server = $this->createServer('v1'); + $this->createSchema($server, 'posts', '\d+'); + + $this->defaultApiRoutesWithNamespace(function () use ($func) { + JsonApiRoute::server('v1') + ->prefix('v1') + ->namespace('Api\\V1') + ->resources(function ($server) use ($func) { + $server->resource('posts')->actions('-actions', function ($actions) use ($func) { + $actions->withId()->{$func}('foo-bar')->name('foobar'); + }); + }); + }); + + $route = $this->assertMatch($method, '/api/v1/posts/123/-actions/foo-bar'); + $this->assertSame("App\Http\Controllers\Api\V1\PostController@fooBar", $route->action['controller']); + $this->assertSame("v1.posts.foobar", $route->getName()); + $this->assertSame(['api', 'jsonapi:v1'], $route->action['middleware']); + $this->assertSame('posts', $route->parameter('resource_type')); + $this->assertSame('post', $route->parameter('resource_id_name')); + $this->assertSame('\d+', $route->wheres['post'] ?? null); + } + + /** + * @param string $method + * @dataProvider methodProvider + */ + public function testIdConstraintWorks(string $method): void + { + $func = strtolower($method); + $server = $this->createServer('v1'); + $this->createSchema($server, 'posts', '\d+'); + + $this->defaultApiRoutesWithNamespace(function () use ($func) { + JsonApiRoute::server('v1') + ->prefix('v1') + ->namespace('Api\\V1') + ->resources(function ($server) use ($func) { + $server->resource('posts')->actions('-actions', function ($actions) use ($func) { + $actions->withId()->{$func}('foo-bar')->name('foobar'); + }); + }); + }); + + $this->assertNotFound($method, '/api/v1/posts/123abc/-actions/foo-bar'); + } +} diff --git a/tests/lib/Integration/Routing/HasManyTest.php b/tests/lib/Integration/Routing/HasManyTest.php index 5bc2d9b..18ad3a6 100644 --- a/tests/lib/Integration/Routing/HasManyTest.php +++ b/tests/lib/Integration/Routing/HasManyTest.php @@ -454,4 +454,30 @@ public function testOwnActions(string $method, string $uri, string $action, stri $this->assertSame('post', $route->parameter('resource_id_name')); $this->assertSame('tags', $route->parameter('resource_relationship')); } + + /** + * @param string $method + * @param string $uri + * @dataProvider genericProvider + */ + public function testIdConstraintWorks(string $method, string $uri): void + { + $server = $this->createServer('v1'); + $schema = $this->createSchema($server, 'posts', '\d+'); + $this->createRelation($schema, 'tags'); + + $this->defaultApiRoutesWithNamespace(function () { + JsonApiRoute::server('v1') + ->prefix('v1') + ->namespace('Api\\V1') + ->resources(function ($server) { + $server->resource('posts')->relationships(function ($relations) { + $relations->hasMany('tags'); + }); + }); + }); + + $this->assertMatch($method, $uri); + $this->assertNotFound($method, str_replace('123', '123abc', $uri)); + } } diff --git a/tests/lib/Integration/Routing/HasOneTest.php b/tests/lib/Integration/Routing/HasOneTest.php index 3f233ee..fed9172 100644 --- a/tests/lib/Integration/Routing/HasOneTest.php +++ b/tests/lib/Integration/Routing/HasOneTest.php @@ -386,4 +386,30 @@ public function testOwnActions(string $method, string $uri, string $action, stri $this->assertSame('author', $route->parameter('resource_relationship')); } + /** + * @param string $method + * @param string $uri + * @dataProvider genericProvider + */ + public function testIdConstraintWorks(string $method, string $uri): void + { + $server = $this->createServer('v1'); + $schema = $this->createSchema($server, 'posts', '\d+'); + $this->createRelation($schema, 'author'); + + $this->defaultApiRoutesWithNamespace(function () { + JsonApiRoute::server('v1') + ->prefix('v1') + ->namespace('Api\\V1') + ->resources(function ($server) { + $server->resource('posts')->relationships(function ($relations) { + $relations->hasOne('author'); + }); + }); + }); + + $this->assertMatch($method, $uri); + $this->assertNotFound($method, str_replace('123', '123abc', $uri)); + } + } diff --git a/tests/lib/Integration/Routing/ResourceTest.php b/tests/lib/Integration/Routing/ResourceTest.php index f82330d..4a68afa 100644 --- a/tests/lib/Integration/Routing/ResourceTest.php +++ b/tests/lib/Integration/Routing/ResourceTest.php @@ -469,4 +469,34 @@ public function testReadOnly(): void ]); } + /** + * @return array + */ + public function resourceMethodProvider(): array + { + return [ + 'GET' => ['GET'], + 'PATCH' => ['PATCH'], + 'DELETE' => ['DELETE'], + ]; + } + + /** + * @param string $method + * @dataProvider resourceMethodProvider + */ + public function testIdConstraintWorks(string $method): void + { + $server = $this->createServer('v1'); + $this->createSchema($server, 'posts', '\d+'); + + $this->defaultApiRoutesWithNamespace(function () { + JsonApiRoute::server('v1')->prefix('v1')->namespace('Api\\V1')->resources(function ($server) { + $server->resource('posts'); + }); + }); + + $this->assertMatch($method, '/api/v1/posts/123'); + $this->assertNotFound($method, '/api/v1/posts/123abc'); + } } From cbd1a7286bd986ecee2a267804ff222b5522488d Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 4 Feb 2021 14:01:56 +0000 Subject: [PATCH 4/6] Automatically assign names to action routes --- CHANGELOG.md | 4 +- src/Routing/ActionProxy.php | 93 +++++++++++++++++++ src/Routing/ActionRegistrar.php | 30 +++--- tests/lib/Integration/Routing/ActionsTest.php | 74 +++++++++++++-- 4 files changed, 177 insertions(+), 24 deletions(-) create mode 100644 src/Routing/ActionProxy.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a3e6b..73b82ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ All notable changes to this project will be documented in this file. This projec ### Added -- Can now register routes for custom actions on a resource, using the `actions()` helper method when registering - resources. +- [#12](https://github.com/laravel-json-api/laravel/pull/12) Can now register routes for custom actions on a resource, + using the `actions()` helper method when registering resources. See the PR for examples. - The `JsonApiController` now has the Laravel `AuthorizesRequests`, `DispatchesJobs` and `ValidatesRequests` traits applied. diff --git a/src/Routing/ActionProxy.php b/src/Routing/ActionProxy.php new file mode 100644 index 0000000..de53fc6 --- /dev/null +++ b/src/Routing/ActionProxy.php @@ -0,0 +1,93 @@ +route = $route; + $this->controllerMethod = $controllerMethod; + } + + /** + * @param $name + * @param $arguments + */ + public function __call($name, $arguments) + { + $this->forwardCallTo($this->route, $name, $arguments); + } + + /** + * @return void + */ + public function __destruct() + { + if (false === $this->named) { + $this->route->name($this->controllerMethod); + } + } + + /** + * @param string $name + * @return $this + */ + public function name(string $name): self + { + $this->route->name($name); + $this->named = true; + + return $this; + } + +} diff --git a/src/Routing/ActionRegistrar.php b/src/Routing/ActionRegistrar.php index bef02b8..1f7ed2f 100644 --- a/src/Routing/ActionRegistrar.php +++ b/src/Routing/ActionRegistrar.php @@ -112,9 +112,9 @@ public function withId(): self * * @param string $uri * @param string|null $method - * @return IlluminateRoute + * @return ActionProxy */ - public function get(string $uri, string $method = null): IlluminateRoute + public function get(string $uri, string $method = null): ActionProxy { return $this->register('get', $uri, $method); } @@ -124,9 +124,9 @@ public function get(string $uri, string $method = null): IlluminateRoute * * @param string $uri * @param string|null $method - * @return IlluminateRoute + * @return ActionProxy */ - public function post(string $uri, string $method = null): IlluminateRoute + public function post(string $uri, string $method = null): ActionProxy { return $this->register('post', $uri, $method); } @@ -136,9 +136,9 @@ public function post(string $uri, string $method = null): IlluminateRoute * * @param string $uri * @param string|null $method - * @return IlluminateRoute + * @return ActionProxy */ - public function patch(string $uri, string $method = null): IlluminateRoute + public function patch(string $uri, string $method = null): ActionProxy { return $this->register('patch', $uri, $method); } @@ -148,9 +148,9 @@ public function patch(string $uri, string $method = null): IlluminateRoute * * @param string $uri * @param string|null $method - * @return IlluminateRoute + * @return ActionProxy */ - public function put(string $uri, string $method = null): IlluminateRoute + public function put(string $uri, string $method = null): ActionProxy { return $this->register('put', $uri, $method); } @@ -160,9 +160,9 @@ public function put(string $uri, string $method = null): IlluminateRoute * * @param string $uri * @param string|null $method - * @return IlluminateRoute + * @return ActionProxy */ - public function delete(string $uri, string $method = null): IlluminateRoute + public function delete(string $uri, string $method = null): ActionProxy { return $this->register('delete', $uri, $method); } @@ -172,9 +172,9 @@ public function delete(string $uri, string $method = null): IlluminateRoute * * @param string $uri * @param string|null $method - * @return IlluminateRoute + * @return ActionProxy */ - public function options(string $uri, string $method = null): IlluminateRoute + public function options(string $uri, string $method = null): ActionProxy { return $this->register('options', $uri, $method); } @@ -183,9 +183,9 @@ public function options(string $uri, string $method = null): IlluminateRoute * @param string $method * @param string $uri * @param string|null $action - * @return IlluminateRoute + * @return ActionProxy */ - public function register(string $method, string $uri, string $action = null): IlluminateRoute + public function register(string $method, string $uri, string $action = null): ActionProxy { $action = $action ?: $this->guessControllerAction($uri); $parameter = $this->getParameter(); @@ -197,7 +197,7 @@ public function register(string $method, string $uri, string $action = null): Il $this->route($route, $parameter); - return $route; + return new ActionProxy($route, $action); } /** diff --git a/tests/lib/Integration/Routing/ActionsTest.php b/tests/lib/Integration/Routing/ActionsTest.php index 8ec293c..7915cf5 100644 --- a/tests/lib/Integration/Routing/ActionsTest.php +++ b/tests/lib/Integration/Routing/ActionsTest.php @@ -55,14 +55,14 @@ public function testBase(string $method): void ->namespace('Api\\V1') ->resources(function ($server) use ($func) { $server->resource('posts')->actions(function ($actions) use ($func) { - $actions->{$func}('foo-bar')->name('foobar'); + $actions->{$func}('foo-bar'); }); }); }); $route = $this->assertMatch($method, '/api/v1/posts/foo-bar'); $this->assertSame("App\Http\Controllers\Api\V1\PostController@fooBar", $route->action['controller']); - $this->assertSame("v1.posts.foobar", $route->getName()); + $this->assertSame("v1.posts.fooBar", $route->getName()); $this->assertSame(['api', 'jsonapi:v1'], $route->action['middleware']); $this->assertSame('posts', $route->parameter('resource_type')); $this->assertNull($route->parameter('resource_id_name')); @@ -85,13 +85,43 @@ public function testBaseWithPrefix(string $method): void ->namespace('Api\\V1') ->resources(function ($server) use ($func) { $server->resource('posts')->actions('-actions', function ($actions) use ($func) { - $actions->{$func}('foo-bar')->name('foobar'); + $actions->{$func}('foo-bar'); }); }); }); $route = $this->assertMatch($method, '/api/v1/posts/-actions/foo-bar'); $this->assertSame("App\Http\Controllers\Api\V1\PostController@fooBar", $route->action['controller']); + $this->assertSame("v1.posts.fooBar", $route->getName()); + $this->assertSame(['api', 'jsonapi:v1'], $route->action['middleware']); + $this->assertSame('posts', $route->parameter('resource_type')); + $this->assertNull($route->parameter('resource_id_name')); + $this->assertArrayNotHasKey('post', $route->wheres); + } + + /** + * @param string $method + * @dataProvider methodProvider + */ + public function testBaseWithName(string $method): void + { + $func = strtolower($method); + $server = $this->createServer('v1'); + $this->createSchema($server, 'posts', '\d+'); + + $this->defaultApiRoutesWithNamespace(function () use ($func) { + JsonApiRoute::server('v1') + ->prefix('v1') + ->namespace('Api\\V1') + ->resources(function ($server) use ($func) { + $server->resource('posts')->actions(function ($actions) use ($func) { + $actions->{$func}('foo-bar')->name('foobar'); + }); + }); + }); + + $route = $this->assertMatch($method, '/api/v1/posts/foo-bar'); + $this->assertSame("App\Http\Controllers\Api\V1\PostController@fooBar", $route->action['controller']); $this->assertSame("v1.posts.foobar", $route->getName()); $this->assertSame(['api', 'jsonapi:v1'], $route->action['middleware']); $this->assertSame('posts', $route->parameter('resource_type')); @@ -115,14 +145,14 @@ public function testId(string $method): void ->namespace('Api\\V1') ->resources(function ($server) use ($func) { $server->resource('posts')->actions(function ($actions) use ($func) { - $actions->withId()->{$func}('foo-bar')->name('foobar'); + $actions->withId()->{$func}('foo-bar'); }); }); }); $route = $this->assertMatch($method, '/api/v1/posts/123/foo-bar'); $this->assertSame("App\Http\Controllers\Api\V1\PostController@fooBar", $route->action['controller']); - $this->assertSame("v1.posts.foobar", $route->getName()); + $this->assertSame("v1.posts.fooBar", $route->getName()); $this->assertSame(['api', 'jsonapi:v1'], $route->action['middleware']); $this->assertSame('posts', $route->parameter('resource_type')); $this->assertSame('post', $route->parameter('resource_id_name')); @@ -145,13 +175,43 @@ public function testIdWithPrefix(string $method): void ->namespace('Api\\V1') ->resources(function ($server) use ($func) { $server->resource('posts')->actions('-actions', function ($actions) use ($func) { - $actions->withId()->{$func}('foo-bar')->name('foobar'); + $actions->withId()->{$func}('foo-bar'); }); }); }); $route = $this->assertMatch($method, '/api/v1/posts/123/-actions/foo-bar'); $this->assertSame("App\Http\Controllers\Api\V1\PostController@fooBar", $route->action['controller']); + $this->assertSame("v1.posts.fooBar", $route->getName()); + $this->assertSame(['api', 'jsonapi:v1'], $route->action['middleware']); + $this->assertSame('posts', $route->parameter('resource_type')); + $this->assertSame('post', $route->parameter('resource_id_name')); + $this->assertSame('\d+', $route->wheres['post'] ?? null); + } + + /** + * @param string $method + * @dataProvider methodProvider + */ + public function testIdWithName(string $method): void + { + $func = strtolower($method); + $server = $this->createServer('v1'); + $this->createSchema($server, 'posts', '\d+'); + + $this->defaultApiRoutesWithNamespace(function () use ($func) { + JsonApiRoute::server('v1') + ->prefix('v1') + ->namespace('Api\\V1') + ->resources(function ($server) use ($func) { + $server->resource('posts')->actions(function ($actions) use ($func) { + $actions->withId()->{$func}('foo-bar')->name('foobar'); + }); + }); + }); + + $route = $this->assertMatch($method, '/api/v1/posts/123/foo-bar'); + $this->assertSame("App\Http\Controllers\Api\V1\PostController@fooBar", $route->action['controller']); $this->assertSame("v1.posts.foobar", $route->getName()); $this->assertSame(['api', 'jsonapi:v1'], $route->action['middleware']); $this->assertSame('posts', $route->parameter('resource_type')); @@ -175,7 +235,7 @@ public function testIdConstraintWorks(string $method): void ->namespace('Api\\V1') ->resources(function ($server) use ($func) { $server->resource('posts')->actions('-actions', function ($actions) use ($func) { - $actions->withId()->{$func}('foo-bar')->name('foobar'); + $actions->withId()->{$func}('foo-bar'); }); }); }); From 4d8551b54e9d7e80f6bad9b75ecd8aa43a1fda62 Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 4 Feb 2021 14:03:34 +0000 Subject: [PATCH 5/6] Update route file --- tests/dummy/routes/api.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/dummy/routes/api.php b/tests/dummy/routes/api.php index 3a7f487..a54b92b 100644 --- a/tests/dummy/routes/api.php +++ b/tests/dummy/routes/api.php @@ -9,8 +9,8 @@ $relationships->hasMany('comments')->readOnly(); $relationships->hasMany('tags'); })->actions('-actions', function ($actions) { - $actions->delete('purge')->name('purge'); - $actions->withId()->post('publish')->name('publish'); + $actions->delete('purge'); + $actions->withId()->post('publish'); }); /** Videos */ From 59318e99e60519f8938541c121ae4845b5882da0 Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 4 Feb 2021 14:09:06 +0000 Subject: [PATCH 6/6] Fix accidental change of method visibility --- src/Routing/ResourceRegistrar.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Routing/ResourceRegistrar.php b/src/Routing/ResourceRegistrar.php index 50d4388..2c7a553 100644 --- a/src/Routing/ResourceRegistrar.php +++ b/src/Routing/ResourceRegistrar.php @@ -414,7 +414,7 @@ private function getCustomActions(string $resourceType, array $options) * @param string $resourceType * @return string */ - public function getIdPattern(string $resourceType): string + private function getIdPattern(string $resourceType): string { return $this->server ->schemas()