diff --git a/CHANGELOG.md b/CHANGELOG.md index 4678750..73b82ca 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 +- [#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 new file mode 100644 index 0000000..1f7ed2f --- /dev/null +++ b/src/Routing/ActionRegistrar.php @@ -0,0 +1,271 @@ +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; + } + + /** + * Register a new GET route. + * + * @param string $uri + * @param string|null $method + * @return ActionProxy + */ + public function get(string $uri, string $method = null): ActionProxy + { + return $this->register('get', $uri, $method); + } + + /** + * Register a new POST route. + * + * @param string $uri + * @param string|null $method + * @return ActionProxy + */ + public function post(string $uri, string $method = null): ActionProxy + { + return $this->register('post', $uri, $method); + } + + /** + * Register a new PATCH route. + * + * @param string $uri + * @param string|null $method + * @return ActionProxy + */ + public function patch(string $uri, string $method = null): ActionProxy + { + return $this->register('patch', $uri, $method); + } + + /** + * Register a new PUT route. + * + * @param string $uri + * @param string|null $method + * @return ActionProxy + */ + public function put(string $uri, string $method = null): ActionProxy + { + return $this->register('put', $uri, $method); + } + + /** + * Register a new DELETE route. + * + * @param string $uri + * @param string|null $method + * @return ActionProxy + */ + public function delete(string $uri, string $method = null): ActionProxy + { + return $this->register('delete', $uri, $method); + } + + /** + * Register a new OPTIONS route. + * + * @param string $uri + * @param string|null $method + * @return ActionProxy + */ + public function options(string $uri, string $method = null): ActionProxy + { + return $this->register('options', $uri, $method); + } + + /** + * @param string $method + * @param string $uri + * @param string|null $action + * @return ActionProxy + */ + public function register(string $method, string $uri, string $action = null): ActionProxy + { + $action = $action ?: $this->guessControllerAction($uri); + $parameter = $this->getParameter(); + + $route = $this->router->{$method}( + $this->uri($uri, $parameter), + sprintf('%s@%s', $this->controller, $action) + ); + + $this->route($route, $parameter); + + return new ActionProxy($route, $action); + } + + /** + * @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 guessControllerAction(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..2c7a553 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,20 +386,28 @@ 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; } /** diff --git a/tests/dummy/app/Http/Controllers/Api/V1/PostController.php b/tests/dummy/app/Http/Controllers/Api/V1/PostController.php index 2ceae18..d811611 100644 --- a/tests/dummy/app/Http/Controllers/Api/V1/PostController.php +++ b/tests/dummy/app/Http/Controllers/Api/V1/PostController.php @@ -19,9 +19,16 @@ namespace App\Http\Controllers\Api\V1; +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; -class PostController +class PostController extends Controller { use Actions\FetchMany; @@ -35,4 +42,36 @@ class PostController 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 Responsable + */ + public function publish(Store $store, PostQuery $query, Post $post): Responsable + { + $post->update(['published_at' => now()]); + + $model = $store + ->queryOne('posts', $post) + ->using($query) + ->first(); + + return new DataResponse($model); + } } 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/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 5d600ff..a54b92b 100644 --- a/tests/dummy/routes/api.php +++ b/tests/dummy/routes/api.php @@ -3,12 +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->delete('purge'); + $actions->withId()->post('publish'); }); + /** Videos */ $server->resource('videos')->relationships(function ($relationships) { $relationships->hasMany('tags'); }); diff --git a/tests/dummy/tests/Api/V1/Posts/Actions/PublishTest.php b/tests/dummy/tests/Api/V1/Posts/Actions/PublishTest.php new file mode 100644 index 0000000..f18f600 --- /dev/null +++ b/tests/dummy/tests/Api/V1/Posts/Actions/PublishTest.php @@ -0,0 +1,93 @@ +post = Post::factory()->create(['published_at' => null]); + } + + public function test(): void + { + $this->travelTo($date = now()->milliseconds(0)); + + $expected = $this->serializer + ->post($this->post) + ->replace('publishedAt', $date->jsonSerialize()) + ->replace('author', ['type' => 'users', 'id' => $this->post->author]); + + $response = $this + ->withoutExceptionHandling() + ->actingAs($this->post->author) + ->jsonApi('posts') + ->contentType('application/json') + ->includePaths('author') + ->post(url('/api/v1/posts', [$this->post, '-actions/publish'])); + + $response->assertFetchedOneExact($expected->jsonSerialize()); + $response->assertIncluded([$expected['author']]); + + $this->assertDatabaseHas('posts', array_replace( + $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()); + } +} 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..7915cf5 --- /dev/null +++ b/tests/lib/Integration/Routing/ActionsTest.php @@ -0,0 +1,245 @@ + ['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'); + }); + }); + }); + + $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'); + }); + }); + }); + + $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')); + $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'); + }); + }); + }); + + $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'); + }); + }); + }); + + $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')); + $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'); + }); + }); + }); + + $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'); + } }