diff --git a/docs/docs/repository-pattern/repository-pattern.md b/docs/docs/repository-pattern/repository-pattern.md index f3571d21e..2a0e6e5a9 100644 --- a/docs/docs/repository-pattern/repository-pattern.md +++ b/docs/docs/repository-pattern/repository-pattern.md @@ -264,3 +264,150 @@ public function serializeIndex($request, $serialized) } ``` +## Custom routes + +Laravel Restify has its own "CRUD" routes, however you're able to define your own routes right from your Repository class: + +```php +/** + * Defining custom routes + * + * The default prefix of this route is the uriKey (e.g. 'restify-api/posts'), + * + * The default namespace is AppNamespace/Http/Controllers + * + * The default middlewares are the same from config('restify.middleware') + * + * However all options could be overrided by passing an $options argument + * + * @param \Illuminate\Routing\Router $router + * @param $options + */ +public static function routes(\Illuminate\Routing\Router $router, $options = []) +{ + $router->get('hello-world', function () { + return 'Hello World'; + }); +} +``` + +Let's diving into a more "real life" example. Let's take the Post repository we had above: + +```php +use Illuminate\Routing\Router; +use Binaryk\LaravelRestify\Repositories\Repository; + +class Post extends Repository +{ + /* + * @param \Illuminate\Routing\Router $router + * @param $options + */ + public static function routes(Router $router, $options = []) + { + $router->get('/{id}/kpi', 'PostController@kpi'); + } + + public static function uriKey() + { + return 'posts'; + } +} +``` + +At this moment Restify built the new route as a child of the `posts`, so it has the route: + +```http request +GET: /restify-api/posts/{id}/kpi +``` + +This route is pointing to the `PostsController`, let's define it: + +```php +response(); + } +} +``` + +### Custom prefix + +As we noticed in the example above, the route is generated as a child of the current repository `uriKey` route, +however sometimes you may want to have a separate prefix, which doesn't depends of the URI of the current repository. +Restify provide you an easy of doing that, by adding default value `prefix` for the second `$options` argument: + +```php +/** + * @param \Illuminate\Routing\Router $router + * @param $options + */ +public static function routes(Router $router, $options = ['prefix' => 'api',]) +{ + $router->get('hello-world', function () { + return 'Hello World'; + }); +} +```` + +Now the generated route will look like this: + +```http request +GET: '/api/hello-world +``` + +With `api` as a custom prefix. + + +### Custom middleware + +All routes declared in the `routes` method, will have the same middelwares defined in your `restify.middleware` configuration file. +Overriding default middlewares is a breeze with Restify: + +```php +/** + * @param \Illuminate\Routing\Router $router + * @param $options + */ +public static function routes(Router $router, $options = ['middleware' => [CustomMiddleware::class],]) +{ + $router->get('hello-world', function () { + return 'Hello World'; + }); +} +```` + +In that case, the single middleware of the route will be defined by the `CustomMiddleware` class. + +### Custom Namespace + +By default each route defined in the `routes` method, will have the namespace `AppRootNamespace\Http\Controllers`. +You can override it easily by using `namespace` configuration key: + +```php +/** + * @param \Illuminate\Routing\Router $router + * @param $options + */ +public static function routes(Router $router, $options = ['namespace' => 'App\Services',]) +{ + $router->get('hello-world', 'WorldController@hello'); +} +```` diff --git a/src/Http/Controllers/RepositoryStoreController.php b/src/Http/Controllers/RepositoryStoreController.php index a91f71b2b..f14aa555f 100644 --- a/src/Http/Controllers/RepositoryStoreController.php +++ b/src/Http/Controllers/RepositoryStoreController.php @@ -32,14 +32,6 @@ public function handle(RepositoryStoreRequest $request) */ $repository = $request->repository(); - $repository::authorizeToCreate($request); - - $validator = $repository::validatorForStoring($request); - - if ($validator->fails()) { - return $this->response()->invalid()->errors($validator->errors()->toArray())->respond(); - } - return $request->newRepositoryWith($repository::newModel())->store($request); } } diff --git a/src/Repositories/Crudable.php b/src/Repositories/Crudable.php index 71885da2a..225499f1c 100644 --- a/src/Repositories/Crudable.php +++ b/src/Repositories/Crudable.php @@ -3,10 +3,13 @@ namespace Binaryk\LaravelRestify\Repositories; use Binaryk\LaravelRestify\Controllers\RestResponse; +use Binaryk\LaravelRestify\Exceptions\UnauthorizedException; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Restify; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -57,6 +60,18 @@ public function show(RestifyRequest $request, $repositoryId) */ public function store(RestifyRequest $request) { + try { + $this->allowToStore($request); + } catch (AuthorizationException | UnauthorizedException $e) { + return $this->response()->setData([ + 'errors' => Arr::wrap($e->getMessage()), + ])->setStatusCode(RestResponse::REST_RESPONSE_FORBIDDEN_CODE); + } catch (ValidationException $e) { + return $this->response()->setData([ + 'errors' => $e->errors(), + ])->setStatusCode(RestResponse::REST_RESPONSE_INVALID_CODE); + } + $model = DB::transaction(function () use ($request) { $model = self::fillWhenStore( $request, self::newModel() @@ -127,6 +142,21 @@ public function allowToUpdate(RestifyRequest $request) $validator->validate(); } + /** + * @param RestifyRequest $request + * @return mixed + * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws ValidationException + */ + public function allowToStore(RestifyRequest $request) + { + self::authorizeToCreate($request); + + $validator = self::validatorForStoring($request); + + $validator->validate(); + } + /** * @param RestifyRequest $request * @throws \Illuminate\Auth\Access\AuthorizationException diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index fb5c69f06..5f1616494 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Routing\Router; use Illuminate\Support\Collection; use Illuminate\Support\Str; @@ -158,4 +159,33 @@ public static function resolveWith($model) return $self->withResource($model); } + + /** + * Handle dynamic static method calls into the method. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public static function __callStatic($method, $parameters) + { + return (new static)->$method(...$parameters); + } + + /** + * Defining custom routes. + * + * The prefix of this route is the uriKey (e.g. 'restify-api/orders'), + * The namespace is Http/Controllers + * Middlewares are the same from config('restify.middleware'). + * + * However all options could be customized by passing an $options argument + * + * @param Router $router + * @param $options + */ + public static function routes(Router $router, $options = []) + { + // override for custom routes + } } diff --git a/src/RestifyServiceProvider.php b/src/RestifyServiceProvider.php index 0dcc9dcd9..464ecd55e 100644 --- a/src/RestifyServiceProvider.php +++ b/src/RestifyServiceProvider.php @@ -2,9 +2,9 @@ namespace Binaryk\LaravelRestify; -use Illuminate\Support\Arr; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use ReflectionClass; /** * This provider is injected in console context by the main provider or by the RestifyInjector @@ -34,27 +34,37 @@ protected function registerRoutes() 'middleware' => config('restify.middleware', []), ]; - $this->customDefinitions($config) + $this->customDefinitions() ->defaultRoutes($config); } /** - * @param $config * @return RestifyServiceProvider */ - public function customDefinitions($config) + public function customDefinitions() { - collect(Restify::$repositories)->filter(function ($repository) { - return isset($repository::$middleware) || isset($repository::$prefix); - }) - ->each(function ($repository) use ($config) { - $config['middleware'] = array_merge(config('restify.middleware', []), Arr::wrap($repository::$middleware)); - $config['prefix'] = Restify::path($repository::$prefix); + collect(Restify::$repositories)->each(function ($repository) { + $config = [ + 'namespace' => trim(app()->getNamespace(), '\\').'\Http\Controllers', + 'as' => '', + 'prefix' => Restify::path($repository::uriKey()), + 'middleware' => config('restify.middleware', []), + ]; + + $reflector = new ReflectionClass($repository); + + $method = $reflector->getMethod('routes'); - Route::group($config, function () { - $this->loadRoutesFrom(__DIR__.'/../routes/api.php'); - }); + $parameters = $method->getParameters(); + + if (count($parameters) === 2 && $parameters[1] instanceof \ReflectionParameter) { + $config = array_merge($config, $parameters[1]->getDefaultValue()); + } + + Route::group($config, function ($router) use ($repository) { + $repository::routes($router); }); + }); return $this; } diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index bd7d3836b..6639d513e 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -107,7 +107,7 @@ public function authorizedToView(Request $request) */ public static function authorizeToCreate(Request $request) { - throw_unless(static::authorizedToCreate($request), AuthorizationException::class); + throw_unless(static::authorizedToCreate($request), AuthorizationException::class, 'Unauthorized to create.'); } /** @@ -119,7 +119,7 @@ public static function authorizeToCreate(Request $request) public static function authorizedToCreate(Request $request) { if (static::authorizable()) { - return Gate::check('create', get_class(static::newModel())); + return Gate::check('create', static::$model); } return true; diff --git a/tests/Controllers/RepositoryStoreControllerTest.php b/tests/Controllers/RepositoryStoreControllerTest.php index bc82b7b30..1aac732dc 100644 --- a/tests/Controllers/RepositoryStoreControllerTest.php +++ b/tests/Controllers/RepositoryStoreControllerTest.php @@ -2,7 +2,10 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; +use Binaryk\LaravelRestify\Tests\Fixtures\Post; +use Binaryk\LaravelRestify\Tests\Fixtures\PostPolicy; use Binaryk\LaravelRestify\Tests\IntegrationTest; +use Illuminate\Support\Facades\Gate; /** * @author Eduard Lupacescu @@ -12,6 +15,7 @@ class RepositoryStoreControllerTest extends IntegrationTest protected function setUp(): void { parent::setUp(); + $this->authenticate(); } public function test_basic_validation_works() @@ -23,12 +27,25 @@ public function test_basic_validation_works() ->assertJson([ 'errors' => [ 'description' => [ - 'Description field is required bro.', + 'Description field is required', ], ], ]); } + public function test_unauthorized_store() + { + $_SERVER['restify.user.creatable'] = false; + + Gate::policy(Post::class, PostPolicy::class); + + $this->withExceptionHandling()->post('/restify-api/posts', [ + 'title' => 'Title', + 'description' => 'Title', + ])->assertStatus(403) + ->assertJson(['errors' => ['Unauthorized to create.']]); + } + public function test_success_storing() { $user = $this->mockUsers()->first(); diff --git a/tests/Fixtures/PostPolicy.php b/tests/Fixtures/PostPolicy.php new file mode 100644 index 000000000..87cc4e15f --- /dev/null +++ b/tests/Fixtures/PostPolicy.php @@ -0,0 +1,25 @@ + + */ +class PostPolicy +{ + /** + * Determine if the given user can view resources. + */ + public function viewAny($user) + { + return $_SERVER['restify.user.viewAnyable'] ?? true; + } + + /** + * Determine if users can be created. + */ + public function create($user) + { + return $_SERVER['restify.user.creatable'] ?? true; + } +} diff --git a/tests/Fixtures/PostRepository.php b/tests/Fixtures/PostRepository.php index d7f45312c..04201be69 100644 --- a/tests/Fixtures/PostRepository.php +++ b/tests/Fixtures/PostRepository.php @@ -31,10 +31,11 @@ public function fields(RestifyRequest $request) { return [ Field::make('title')->storingRules('required')->messages([ - 'required' => 'This field is required bro.', + 'required' => 'This field is required', ]), + Field::make('description')->storingRules('required')->messages([ - 'required' => 'Description field is required bro.', + 'required' => 'Description field is required', ]), ]; } diff --git a/tests/Fixtures/RepositoryWithRoutes.php b/tests/Fixtures/RepositoryWithRoutes.php new file mode 100644 index 000000000..8d749ec2f --- /dev/null +++ b/tests/Fixtures/RepositoryWithRoutes.php @@ -0,0 +1,30 @@ + + */ +class RepositoryWithRoutes extends Repository +{ + /** + * @param Router $router + * @param array $options + */ + public static function routes(Router $router, $options = []) + { + $router->get('testing', function () { + return response()->json([ + 'success' => true, + ]); + })->name('testing.route'); + } + + public static function uriKey() + { + return 'posts'; + } +} diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 910fc60fa..eb3dc801d 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -5,12 +5,15 @@ use Binaryk\LaravelRestify\LaravelRestifyServiceProvider; use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Tests\Fixtures\PostRepository; +use Binaryk\LaravelRestify\Tests\Fixtures\RepositoryWithRoutes; use Binaryk\LaravelRestify\Tests\Fixtures\User; use Binaryk\LaravelRestify\Tests\Fixtures\UserRepository; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Translation\Translator; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Route; +use Mockery; use Orchestra\Testbench\TestCase; /** @@ -19,6 +22,12 @@ abstract class IntegrationTest extends TestCase { use InteractWithModels; + + /** + * @var mixed + */ + protected $authenticatedAs; + /** * @var mixed */ @@ -34,11 +43,7 @@ protected function setUp(): void $this->loadRoutes(); $this->withFactories(__DIR__.'/Factories'); $this->injectTranslator(); - - Restify::repositories([ - UserRepository::class, - PostRepository::class, - ]); + $this->loadRepositories(); } protected function getPackageProviders($app) @@ -152,4 +157,26 @@ public function lastQuery() return end($queries); } + + public function loadRepositories() + { + Restify::repositories([ + UserRepository::class, + PostRepository::class, + RepositoryWithRoutes::class, + ]); + } + + /** + * Authenticate as an anonymous user. + */ + protected function authenticate() + { + $this->actingAs($this->authenticatedAs = Mockery::mock(Authenticatable::class)); + + $this->authenticatedAs->shouldReceive('getAuthIdentifier')->andReturn(1); + $this->authenticatedAs->shouldReceive('getKey')->andReturn(1); + + return $this; + } } diff --git a/tests/RepositoryWithRoutesTest.php b/tests/RepositoryWithRoutesTest.php new file mode 100644 index 000000000..b8b8eda6f --- /dev/null +++ b/tests/RepositoryWithRoutesTest.php @@ -0,0 +1,121 @@ + + */ +class RepositoryWithRoutesTest extends IntegrationTest +{ + protected function setUp(): void + { + $this->loadRepositories(); + + Restify::repositories([ + WithCustomPrefix::class, + WithCustomMiddleware::class, + WithCustomNamespace::class, + ]); + + parent::setUp(); + } + + public function test_can_add_custom_routes() + { + $this->get(Restify::path(RepositoryWithRoutes::uriKey()).'/testing')->assertStatus(200) + ->assertJson([ + 'success' => true, + ]); + + $this->get(route('testing.route'))->assertStatus(200) + ->assertJson([ + 'success' => true, + ]); + } + + public function test_can_use_custom_prefix() + { + $this->get('/custom-prefix/testing')->assertStatus(200) + ->assertJson([ + 'success' => true, + ]); + } + + public function test_can_use_custom_middleware() + { + $this->get(route('middleware.failing.route'))->assertStatus(403); + } + + public function test_can_use_custom_namespace() + { + $this->getJson(route('namespace.route')) + ->assertStatus(200) + ->assertJson([ + 'meta' => [ + 'message' => 'From the sayHello method', + ], + ]); + } +} + +class WithCustomPrefix extends RepositoryWithRoutes +{ + public static function routes(Router $router, $options = ['prefix' => 'custom-prefix']) + { + $router->get('testing', function () { + return response()->json([ + 'success' => true, + ]); + })->name('custom.testing.route'); + } +} + +class MiddlewareFail +{ + public function handle($request, $next) + { + if (true) { + return abort(403); + } + } +} + +class WithCustomMiddleware extends RepositoryWithRoutes +{ + public static function routes(Router $router, $options = ['middleware' => [MiddlewareFail::class]]) + { + $router->get('with-middleware', function () { + return response()->json([ + 'success' => true, + ]); + })->name('middleware.failing.route'); + } +} + +class WithCustomNamespace extends RepositoryWithRoutes +{ + public static function routes(Router $router, $options = [ + 'namespace' => 'Binaryk\LaravelRestify\Tests', + ]) + { + $router->get('custom-namespace', 'HandleController@sayHello')->name('namespace.route'); + } +} + +class HandleController extends RestController +{ + /** + * Just saying hello. + * + * @return \Binaryk\LaravelRestify\Controllers\RestResponse + */ + public function sayHello() + { + return $this->response()->message('From the sayHello method'); + } +}