diff --git a/README.md b/README.md index 279493577..855f7f83f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Laravel magic REST API builder +

Build Status diff --git a/docs/.vuepress/1.0.js b/docs/.vuepress/1.0.js new file mode 100644 index 000000000..bdf75649a --- /dev/null +++ b/docs/.vuepress/1.0.js @@ -0,0 +1,37 @@ +module.exports = [ + { + title: "Quick Start", + collapsable: false, + children: ['quickstart'] + }, + { + title: "Repository", + collapsable: false, + children: ['repository-pattern/repository-pattern'] + }, + { + title: 'Field', + collapsable: false, + children: ['repository-pattern/field'], + }, + { + title: 'Rest Controller', + collapsable: false, + children: ['rest-methods/rest-methods'], + }, + { + title: 'Filtering', + collapsable: false, + children: ['search/search'], + }, + { + title: 'Auth service', + collapsable: false, + children: ['auth/auth'], + }, + { + title: 'Error handler', + collapsable: false, + children: ['exception-handler/exception-handler'], + }, +]; diff --git a/docs/.vuepress/2.0.js b/docs/.vuepress/2.0.js new file mode 100644 index 000000000..bdf75649a --- /dev/null +++ b/docs/.vuepress/2.0.js @@ -0,0 +1,37 @@ +module.exports = [ + { + title: "Quick Start", + collapsable: false, + children: ['quickstart'] + }, + { + title: "Repository", + collapsable: false, + children: ['repository-pattern/repository-pattern'] + }, + { + title: 'Field', + collapsable: false, + children: ['repository-pattern/field'], + }, + { + title: 'Rest Controller', + collapsable: false, + children: ['rest-methods/rest-methods'], + }, + { + title: 'Filtering', + collapsable: false, + children: ['search/search'], + }, + { + title: 'Auth service', + collapsable: false, + children: ['auth/auth'], + }, + { + title: 'Error handler', + collapsable: false, + children: ['exception-handler/exception-handler'], + }, +]; diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 2bb8392d0..2176743c3 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -1,52 +1,48 @@ +var versions = ["1.0", "2.0"]; + module.exports = { title: 'Laravel Restify', description: 'A package to start the REST API', serviceWorker: true, - base: '/laravel-restify/', + base: '/', themeConfig: { - logo: '/assets/img/logo.svg', + logo: '/assets/img/icon.png', displayAllHeaders: true, sidebarDepth: 2, nav: [ - { text: 'Home', link: '/' }, - { text: 'Guide', link: '/docs/' }, + { text: 'Docs', link: '/docs/2.0/' }, + { + text: "Version", + link: "/", + items: [{ text: "1.0", link: "/docs/1.0/" }, { text: "2.0", link: "/docs/2.0/" }] + }, { text: 'About us', link: 'https://binarcode.com', target: '_blank' } ], - sidebar: [ - { - title: 'Quick Start', - path: '/docs/' - }, - { - title: 'Repository', - path: '/docs/repository-pattern/repository-pattern', - }, - { - title: 'Field', - path: '/docs/repository-pattern/field', - }, - { - title: 'Rest Controller', - path: '/docs/rest-methods/rest-methods', - }, - { - title: 'Filtering', - path: '/docs/search/search', - }, - { - title: 'Auth service', - path: '/docs/auth/auth', - }, - { - title: 'Error handler', - path: '/docs/exception-handler/exception-handler', - }, - ] + sidebar: { + "/docs/1.0/": require("./1.0"), + "/docs/2.0/": require("./2.0") + }, }, plugins: [ '@vuepress/pwa', + (options = {}, context) => ({ + extendPageData($page) { + const { regularPath, frontmatter } = $page; + + frontmatter.meta = []; + + versions.forEach(function(version) { + if ($page.regularPath.includes("/" + version + "/")) { + frontmatter.meta.push({ + name: "docsearch:version", + content: version + ".0" + }); + } + }); + } + }) ], head: [ // Used for PWA diff --git a/docs/.vuepress/public/android-chrome-192x192.png b/docs/.vuepress/public/android-chrome-192x192.png index 8f66923ab..aa17157ad 100644 Binary files a/docs/.vuepress/public/android-chrome-192x192.png and b/docs/.vuepress/public/android-chrome-192x192.png differ diff --git a/docs/.vuepress/public/android-chrome-512x512.png b/docs/.vuepress/public/android-chrome-512x512.png index ea7e43f3f..aa17157ad 100644 Binary files a/docs/.vuepress/public/android-chrome-512x512.png and b/docs/.vuepress/public/android-chrome-512x512.png differ diff --git a/docs/.vuepress/public/assets/img/icon.png b/docs/.vuepress/public/assets/img/icon.png new file mode 100644 index 000000000..edaca7fa2 Binary files /dev/null and b/docs/.vuepress/public/assets/img/icon.png differ diff --git a/docs/.vuepress/public/assets/img/logo.png b/docs/.vuepress/public/assets/img/logo.png new file mode 100644 index 000000000..aa17157ad Binary files /dev/null and b/docs/.vuepress/public/assets/img/logo.png differ diff --git a/docs/.vuepress/public/assets/img/logo.svg b/docs/.vuepress/public/assets/img/logo.svg deleted file mode 100644 index 4984d640a..000000000 --- a/docs/.vuepress/public/assets/img/logo.svg +++ /dev/null @@ -1,181 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/.vuepress/public/icon.png b/docs/.vuepress/public/icon.png index ea7e43f3f..aa17157ad 100644 Binary files a/docs/.vuepress/public/icon.png and b/docs/.vuepress/public/icon.png differ diff --git a/docs/.vuepress/public/manifest.json b/docs/.vuepress/public/manifest.json index 3b5e6e41e..284b07869 100644 --- a/docs/.vuepress/public/manifest.json +++ b/docs/.vuepress/public/manifest.json @@ -13,7 +13,7 @@ "type": "image/png" } ], - "start_url": "/docs/installation.html", + "start_url": "/docs/quickstart.html", "display": "standalone", "background_color": "#ddd", "theme_color": "#7e8ea1" diff --git a/docs/README.md b/docs/README.md index 956b4990d..a4d131d58 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,10 +1,10 @@ --- home: true -heroImage: /assets/img/logo.svg -heroText: Laravel Restify -tagline: A package to start a Rest API +heroImage: /assets/img/logo.png +heroText: +tagline: A package to implement a JSON:API with Laravel actionText: Docs → -actionLink: /docs/ +actionLink: /docs/2.0/ features: - title: Magic CRUD over entities details: Enjoy powerful "CRUD" over your entities in seconds diff --git a/docs/docs/1.0/README.md b/docs/docs/1.0/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/docs/auth/auth.md b/docs/docs/1.0/auth/auth.md similarity index 100% rename from docs/docs/auth/auth.md rename to docs/docs/1.0/auth/auth.md diff --git a/docs/docs/exception-handler/exception-handler.md b/docs/docs/1.0/exception-handler/exception-handler.md similarity index 100% rename from docs/docs/exception-handler/exception-handler.md rename to docs/docs/1.0/exception-handler/exception-handler.md diff --git a/docs/docs/1.0/quickstart.md b/docs/docs/1.0/quickstart.md new file mode 100644 index 000000000..4fa613df8 --- /dev/null +++ b/docs/docs/1.0/quickstart.md @@ -0,0 +1,64 @@ +# Installation 1.0 + +[[toc]] + +## Requirements + +Laravel Restify has a few requirements you should be aware of before installing: + +- Composer +- Laravel Framework 5.5+ + +## Installing Laravel Restify + +```bash +composer require binaryk/laravel-restify +``` + +## Setup Laravel Restify +After the instalation, the package requires a setup process, this will publish configuration, provider and will create the +`app/Restify` directory with an abstract `Repository` and scaffolding a `User` repository you can play with: + +```shell script +php artisan restify:setup +``` + +:::tip Package Stability + +If you are not able to install Restify into your application because of your `minimum-stability` setting, + consider setting your `minimum-stability` option to `dev` and your `prefer-stable` option to `true`. + This will allow you to install Laravel Restify while still preferring stable package + releases for your application. +::: + +## Quick start + +Having the package setup and users table migrated, you should be good to perform the first API request: + +```http request +GET: /restify-api/users?perPage=10 +``` + +This should return the users list paginated and formatted according to [JSON:API](https://jsonapi.org/format/) standard. + +## Generate repository + +Creating a new repository can be done via restify command: + +```shell script +php artisan restify:repository Post +``` + +If you want to generate the Policy, Model and migration as well, then you can use the `--all` option: + +```shell script +php artisan restify:repository Post --all +``` +## Generate policy + +Since the authorization is done through the Laravel Policies, a good way of generating a complete policy for an entity +is by using the restify command: + +```shell script +php artisan restify:policy Post +``` diff --git a/docs/docs/repository-pattern/field.md b/docs/docs/1.0/repository-pattern/field.md similarity index 100% rename from docs/docs/repository-pattern/field.md rename to docs/docs/1.0/repository-pattern/field.md diff --git a/docs/docs/repository-pattern/repository-pattern.md b/docs/docs/1.0/repository-pattern/repository-pattern.md similarity index 100% rename from docs/docs/repository-pattern/repository-pattern.md rename to docs/docs/1.0/repository-pattern/repository-pattern.md diff --git a/docs/docs/rest-methods/rest-methods.md b/docs/docs/1.0/rest-methods/rest-methods.md similarity index 100% rename from docs/docs/rest-methods/rest-methods.md rename to docs/docs/1.0/rest-methods/rest-methods.md diff --git a/docs/docs/search/search.md b/docs/docs/1.0/search/search.md similarity index 100% rename from docs/docs/search/search.md rename to docs/docs/1.0/search/search.md diff --git a/docs/docs/2.0/README.md b/docs/docs/2.0/README.md new file mode 100644 index 000000000..465a744e1 --- /dev/null +++ b/docs/docs/2.0/README.md @@ -0,0 +1,64 @@ +# Installation + +[[toc]] + +## Requirements + +Laravel Restify has a few requirements you should be aware of before installing: + +- Composer +- Laravel Framework 5.5+ + +## Installing Laravel Restify + +```bash +composer require binaryk/laravel-restify +``` + +## Setup Laravel Restify +After the instalation, the package requires a setup process, this will publish configuration, provider and will create the +`app/Restify` directory with an abstract `Repository` and scaffolding a `User` repository you can play with: + +```shell script +php artisan restify:setup +``` + +:::tip Package Stability + +If you are not able to install Restify into your application because of your `minimum-stability` setting, + consider setting your `minimum-stability` option to `dev` and your `prefer-stable` option to `true`. + This will allow you to install Laravel Restify while still preferring stable package + releases for your application. +::: + +## Quick start + +Having the package setup and users table migrated, you should be good to perform the first API request: + +```http request +GET: /restify-api/users?perPage=10 +``` + +This should return the users list paginated and formatted according to [JSON:API](https://jsonapi.org/format/) standard. + +## Generate repository + +Creating a new repository can be done via restify command: + +```shell script +php artisan restify:repository Post +``` + +If you want to generate the Policy, Model and migration as well, then you can use the `--all` option: + +```shell script +php artisan restify:repository Post --all +``` +## Generate policy + +Since the authorization is done through the Laravel Policies, a good way of generating a complete policy for an entity +is by using the restify command: + +```shell script +php artisan restify:policy Post +``` diff --git a/docs/docs/2.0/auth/auth.md b/docs/docs/2.0/auth/auth.md new file mode 100644 index 000000000..f47525f82 --- /dev/null +++ b/docs/docs/2.0/auth/auth.md @@ -0,0 +1,498 @@ +# Authentication setup + +Laravel Restify has the authentication implemented with Passport, so you can use it out of the box. +You'll finally enjoy the auth setup (`register`, `login`, `forgot` and `reset password`). + +:::tip + +First make sure you have installed and configured the Laravel Passport properly. +This can be done easily by using the follow Restify command: + +`php artisan restify:check-passport` + +This command will become with suggestions if anything is setup wrong. +::: + +## Prerequisites +- When using the Restify authentication service, you will need to migrate the `users` and `password_resets` table (these 2 migrations are by default in a fresh laravel app, however you may modify the users table as you prefer) +- Make sure your authenticatable entity (usually `User`) implements: `Illuminate\Contracts\Auth\Authenticatable` +- Make sure your authenticatable implements the `Binaryk\LaravelRestify\Contracts\Passportable` interface. +- Check if `restify:check-passport` passes with success. + +## Register users + +- Define a register route to an action controller: + +```php +Route::post('register', 'AuthController@register'); +``` + +- Inject the AuthService into your controller and call the register method: + +```php +use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Binaryk\LaravelRestify\Services\AuthService; + +class AuthController +{ + /** + * @var AuthService + */ + protected $authService; + + public function __construct(AuthService $authService) + { + $this->authService = $authService; + } + /** + * This will validate the input, + * will register the user and return it back to the api + * + * @param Request $request + * @return JsonResponse + */ + public function register(Request $request) + { + $user = $this->authService->register($request->all()); + + return Response::make(['data' => $user], 201); + } +``` + +- Validating input + +Restify will automatically validate the data send from the request to the `register` method. + +Used validation FormRequest is: `Binaryk\LaravelRestify\Http\Requests\RestifyRegisterRequest` + +However if you want to validate the registration payload yourself you can disable the built in validation by nullifying `$registerFormRequest`: + +```php +public function register(UserRegisterRequest $request) +{ + AuthService::$registerFormRequest = null; + + $user = $this->authService->register($request->only(array_keys($request->rules()))); + + return Response::make(['data' => $user], 201); +} +``` + +Or you could simply override the `$registerFormRequest` with custom FormRequest: + +```php +public function register(Request $request) +{ + AuthService::$registerFormRequest = UserRegisterRequest::class; + + $user = $this->authService->register($request->all()); + + return Response::make(['data' => $user], 201); +} +``` + +- Exceptions + +If something went wrong inside the register method, the AuthService will thrown few suggestive exceptions you can handle in the controller: + + > `Binaryk\LaravelRestify\Exceptions\PassportUserException` - Make sure your authenticatable entity (usually `User`) is implementing the `Binaryk\LaravelRestify\Contracts\Passportable` interface. + + > `\Binaryk\LaravelRestify\Exceptions\AuthenticatableUserException` - Make sure your authenticatable entity (usually `User`) is implementing the `Illuminate\Contracts\Auth\Authenticatable` interface. + + > `\Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException` - Class (usually `App\User`) defined in the configuration `auth.providers.users.model` could not been instantiated (may be abstract or missing at all) + +- After successfully registering user, an `Illuminate\Auth\Events\Registered` event will be dispatched. + +## Verifying users (optional) + +This is an optional feature, but sometimes we may want users to validation the registered email. + +- Prerequisites + +Make sure `User` model implementing the `Illuminate\Contracts\Auth\MustVerifyEmail` contract. + +The `MustVerifyEmail` contract will wait for a `sendEmailVerificationNotification` method definition. + +This method could look like this: + +```php +// app/User.php + +public function sendEmailVerificationNotification() +{ + $this->notify(new VerifyEmail); +} +``` + +The `VerifyEmail` should send the notification email to the user. This email should include two required data: +> the sha1 hash of the user email + +> user id + +so your frontend application could easily make a verify call to the API with this data. + +Example of notification: + +```php +use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Notifications\Notification; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Lang; +use Illuminate\Support\Facades\URL; + +class VerifyEmail extends Notification +{ + /** + * Get the notification's channels. + * + * @param mixed $notifiable + * @return array|string + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * Build the mail representation of the notification. + * + * @param mixed $notifiable + * @return \Illuminate\Notifications\Messages\MailMessage + */ + public function toMail($notifiable) + { + $verificationUrl = $this->verificationUrl($notifiable); + + return (new MailMessage) + ->subject(Lang::get('Verify Email Address')) + ->line(Lang::get('Please click the button below to verify your email address.')) + ->action(Lang::get('Verify Email Address'), $verificationUrl) + ->line(Lang::get('If you did not create an account, no further action is required.')); + } + + /** + * Get the verification URL for the given notifiable. + * + * @param mixed $notifiable // the User entity in our case + * @return string + */ + protected function verificationUrl($notifiable) + { + return URL::temporarySignedRoute( + 'register.verify', + Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), + [ + 'id' => $notifiable->getKey(), + 'hash' => sha1($notifiable->getEmailForVerification()), + ] + ); + } +} +``` + +As you may noticed it uses a route, let's scaffolding an verify route example as well: + +```php +Route::get('email/verify/{id}/{hash}', 'AuthController@verify') + ->name('register.verify') + ->middleware([ 'signed', 'throttle:6,1' ]); +``` + +In a real life use case, the email content will look a bit different, because the `action` URL you want to send to the user +should match your frontend domain, not the API domain, and request to the API should be done from the frontend application. + +- Next let's define the controller action: + +```php +use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Binaryk\LaravelRestify\Services\AuthService; + +class AuthController +{ + /** + * @var AuthService + */ + protected $authService; + + public function __construct(AuthService $authService) + { + $this->authService = $authService; + } + + /** + * This will mark the email verified if the email sha1 hash and user id matches + * + * @param Request $request + * @return JsonResponse + * @throws AuthorizationException - thrown if hash or id doesn't match + * @throws PassportUserException - thrown if the User don't implements Passportable + * @throws \Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException - thrown if the user not found + */ + public function verify(Request $request) + { + $user = $this->authService->verify($request->route('id'), $request->route('hash')); + + return Response::make(['data' => $user]); + } +} +``` + +- After verifying with success an `Illuminate\Auth\Events\Verified` event is dispatched. + +## Login users (issue token) +After having user registered and verified (if the case) the API should be able to issue personal authorization tokens. + +- Define a login route to an action controller: + +```php +Route::post('login', 'AuthController@login'); +``` + +- Inject the AuthService into your controller and call the login method: + +```php +use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Binaryk\LaravelRestify\Services\AuthService; +use Binaryk\LaravelRestify\Exceptions\UnverifiedUser; +use Binaryk\LaravelRestify\Exceptions\CredentialsDoesntMatch; +use Binaryk\LaravelRestify\Exceptions\PassportUserException; + +class AuthController +{ + /** + * @var AuthService + */ + protected $authService; + + public function __construct(AuthService $authService) + { + $this->authService = $authService; + } + /** + * This will validate the input, + * will register the user and return it back to the api + * + * @param Request $request + * @return JsonResponse + */ + public function login(Request $request) + { + try { + $token = $this->authService->login($request->only('email', 'password')); + + return Response::make(compact('token')); + } catch (CredentialsDoesntMatch | UnverifiedUser | PassportUserException $e) { + return Response::make('Something went wrong.', 401); + } + } +} +``` + +The login method will thrown few exceptions: +> `Binaryk\LaravelRestify\Exceptions\CredentialsDoesntMatch` - when email or password doesn't match + +> `Binaryk\LaravelRestify\Exceptions\UnverifiedUser` - when `User` model implements `Illuminate\Contracts\Auth\MustVerifyEmail` +and he did not verified the email + +> `Binaryk\LaravelRestify\Exceptions\PassportUserException` - when `User` didn't implement `Binaryk\LaravelRestify\Contracts\Passportable`, the +authenticatable entity should implement this contract, this way Restify will take the control over generating tokens. + +- After login with success a personal token is issued and an `Binaryk\LaravelRestify\Events\UserLoggedIn` event is dispatched. + +## Forgot password + +Forgot password is the action performing by user in terms of recovering his lost password. Usually the API should send an email +with a unique URL that allow users to reset password. + +- Prerequisites: + +If you want your users to be able to reset their passwords, make sure your `User` model implements the +`Illuminate\Contracts\Auth\CanResetPassword` contract. + +This contract requires the `sendPasswordResetNotification` to be implemented. It could looks like this: + +```php +/** + * Send the password reset notification. + * + * @param string $token + * @return void + */ +public function sendPasswordResetNotification($token) +{ + $this->notify(new ResetPassword($token)); +} +``` + +Next let's define the `ResetPassword` notification. It should include a unique token, and should provide some information about the +user email. The token will be resolved by the Laravel Restify and injected into your notification, so you don't have to worry about it: + +```php +use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Notifications\Notification; +use Illuminate\Support\Facades\Lang; + +class ResetPassword extends Notification +{ + /** + * The password reset token. + * + * @var string + */ + public $token; + + /** + * The token is generated by the Restify through the Broker class + * + * @param string $token + * @return void + */ + public function __construct($token) + { + $this->token = $token; + } + + /** + * Get the notification's channels. + * + * @param mixed $notifiable + * @return array|string + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * Build the mail representation of the notification. + * + * @param mixed $notifiable + * @return \Illuminate\Notifications\Messages\MailMessage + */ + public function toMail($notifiable) + { + + $frontendUrl = url(config('app.url') . '/reset-password?'; + + return (new MailMessage) + ->subject(Lang::get('Reset Password Notification')) + ->line(Lang::get('You are receiving this email because we received a password reset request for your account.')) + ->action(Lang::get('Reset Password'), $frontendUrl . http_build_query(['token' => $this->token, 'email' => $notifiable->getEmailForPasswordReset()]))) + ->line(Lang::get('This password reset link will expire in :count minutes.', ['count' => config('auth.passwords.' . config('auth.defaults.passwords') . '.expire')])) + ->line(Lang::get('If you did not request a password reset, no further action is required.')); + } +``` + +The `getEmailForPasswordReset` method simply returns the user email: + +```php +/** + * @inheritDoc + */ +public function getEmailForPasswordReset() +{ + return $this->email; +} +``` + +- Define a forgot password route to an action controller: + +```php +Route::post('password/email', 'AuthController@sendResetLinkEmail'); +``` + +- Inject the AuthService into your controller and call the `sendResetPasswordLinkEmail` method: + +```php +use Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException;use Binaryk\LaravelRestify\Exceptions\PasswordResetException;use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Binaryk\LaravelRestify\Services\AuthService;use Illuminate\Validation\ValidationException; + +class AuthController +{ + /** + * @var AuthService + */ + protected $authService; + + public function __construct(AuthService $authService) + { + $this->authService = $authService; + } + + /** + * This will validate the request input (email exists for example) and will send an reset passw + * + * @param Request $request + * @return JsonResponse + */ + public function sendResetLinkEmail(Request $request) + { + try { + $this->authService->sendResetPasswordLinkEmail($request->get('email')); + + return Response::make('', 204); + } catch (EntityNotFoundException $e) { + // Defined in the configuration auth.providers.users.model could not been instantiated (may be abstract or missing at all) + } catch (PasswordResetException $e) { + // Something unexpected from the Broker class + } catch (ValidationException $e) { + // The email is not valid + } + } +``` + +## Reset password + +Finally we have to reset the users passwords. This can easily be done by using Restify AuthService as well. + +- Define a reset password route to an action controller: + +```php +Route::post('password/reset', 'AuthController@resetPassword')->name('password.reset'); +``` + +- Inject the AuthService into your controller and call the resetPassword method: + +```php +use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Binaryk\LaravelRestify\Services\AuthService; + +class AuthController +{ + /** + * @var AuthService + */ + protected $authService; + + public function __construct(AuthService $authService) + { + $this->authService = $authService; + } + + /** + * @param Request $request + * @return JsonResponse + * @throws \Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException + * @throws \Illuminate\Contracts\Container\BindingResolutionException + * @throws \Illuminate\Validation\ValidationException + */ + public function resetPassword(Request $request) + { + try { + $this->authService->resetPassword($request); + + return Response::make(__('Password reset')); + } catch (PasswordResetException|PasswordResetInvalidTokenException $e) { + return Response::make('Something went wrong', 401); + } + } + +``` + +After successfully password reset an `Illuminate\Auth\Events\PasswordReset` event is dispatched. diff --git a/docs/docs/2.0/exception-handler/exception-handler.md b/docs/docs/2.0/exception-handler/exception-handler.md new file mode 100644 index 000000000..c371daff5 --- /dev/null +++ b/docs/docs/2.0/exception-handler/exception-handler.md @@ -0,0 +1,56 @@ +# Restify Exception Handler +When creating an API the exceptions usually should be handled and resolved before sending to the client. +This is usually done in the Laravel ExceptionHandler which transform the exception in a RestResponse and debug it for you into: +- line +- code +- file +- stack trace + + +## Disable Restify exception handler +However Restify gives you a handy exception handler which is configured in the +`restify.exception_handler` you may want to delegate the exception handling to your application ExceptionHandler for more control. + +You can do that changing config by nullifying it or replace with another handler class: + +```php +[ + // config/restify.php + ... + + 'exception_handler' => null +] +``` + +## Intercept exceptions + +Intercepting exceptions for a specific request is breeze to do with Restify. +Let's assume we have the store users controller action: + +```php +use App\User; +use Binaryk\LaravelRestify\Controllers\RestController; +use Binaryk\LaravelRestify\Restify;use Illuminate\Http\Request;use Illuminate\Http\Response;use Illuminate\Support\Facades\Log; + +class UserController extends RestController +{ + /** + * Store a newly created resource in storage. + * + * @param Request $request + * @return Response + */ + public function store(Request $request) + { + // Intercept the exception handler and log the exception message + Restify::exceptionHandler(function ($request, Exception $exception) { + Log::alert($exception->getMessage()); + return Response::make('Something went wrong', $exception->getCode()); + }); + + return $this->response(User::create($request->all())); + } +} +``` + +As we can see the `exceptionHandler` callback receive the `$request` and thrown `$exception`. diff --git a/docs/docs/2.0/quickstart.md b/docs/docs/2.0/quickstart.md new file mode 100644 index 000000000..e3b7e3e90 --- /dev/null +++ b/docs/docs/2.0/quickstart.md @@ -0,0 +1,64 @@ +# Installation 2.0 + +[[toc]] + +## Requirements + +Laravel Restify has a few requirements you should be aware of before installing: + +- Composer +- Laravel Framework 5.5+ + +## Installing Laravel Restify + +```bash +composer require binaryk/laravel-restify +``` + +## Setup Laravel Restify +After the instalation, the package requires a setup process, this will publish configuration, provider and will create the +`app/Restify` directory with an abstract `Repository` and scaffolding a `User` repository you can play with: + +```shell script +php artisan restify:setup +``` + +:::tip Package Stability + +If you are not able to install Restify into your application because of your `minimum-stability` setting, + consider setting your `minimum-stability` option to `dev` and your `prefer-stable` option to `true`. + This will allow you to install Laravel Restify while still preferring stable package + releases for your application. +::: + +## Quick start + +Having the package setup and users table migrated, you should be good to perform the first API request: + +```http request +GET: /restify-api/users?perPage=10 +``` + +This should return the users list paginated and formatted according to [JSON:API](https://jsonapi.org/format/) standard. + +## Generate repository + +Creating a new repository can be done via restify command: + +```shell script +php artisan restify:repository Post +``` + +If you want to generate the Policy, Model and migration as well, then you can use the `--all` option: + +```shell script +php artisan restify:repository Post --all +``` +## Generate policy + +Since the authorization is done through the Laravel Policies, a good way of generating a complete policy for an entity +is by using the restify command: + +```shell script +php artisan restify:policy Post +``` diff --git a/docs/docs/2.0/repository-pattern/field.md b/docs/docs/2.0/repository-pattern/field.md new file mode 100644 index 000000000..0dcdc1a6f --- /dev/null +++ b/docs/docs/2.0/repository-pattern/field.md @@ -0,0 +1,118 @@ +# Field + +Field is basically the model attribute representation. Each Field generally extend the Field class from the Restify. +This class ships a variety of mutators, interceptors, validators chaining methods you can use for defining your attribute +according with your needed. + +To add a field to a repository, we can simply add it to the repository's fields method. +Typically, fields may be created using their static make method. This method accepts the underlying database column as +argument: + +```php + +use Illuminate\Support\Facades\Hash; +use Binaryk\LaravelRestify\Fields\Field; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; + +/** + * @param RestifyRequest $request + * @return array + */ +public function fields(RestifyRequest $request) +{ + return [ + Field::make('email')->rules('required')->storingRules('unique:users')->messages([ + 'required' => 'This field is required.', + ]), + Field::make('password')->storeCallback(function ($value) { + return Hash::make($value); + })->rules('required')->storingRules('confirmed'), + ]; +} +``` + +# Validation + +There is a gold rule saying - catch the exception as soon as possible on it's request way. +Validations are the first bridge of your request information, it would be a good start to validate +your input so you don't have to worry about payload anymore. + +## Attaching rules + +Validation rules could be add by chaining the `rules` method to attach [validation rules](https://laravel.com/docs/validation#available-validation-rules) +to the field: + +```php +Field::make('email')->rules('required'), +``` + +Of course, if you are leveraging Laravel's support for [validation rule objects](https://laravel.com/docs/validation#using-rule-objects), +you may attach those to resources as well: + +```php +Field::make('email')->rules('required', new CustomRule), +``` + +Additionally, you may use [custom Closure rules](https://laravel.com/docs/validation#using-closures) +to validate your resource fields: + +```php +Field::make('email')->rules('required', function($attribute, $value, $fail) { + if (strtolower($value) !== $value) { + return $fail('The '.$attribute.' field must be lowercase.'); + } +}), +``` + +## Storing Rules + +If you would like to define rules that only apply when a resource is being storing, you may use the `storingRules` method: + +```php +Field::make('email') + ->rules('required', 'email', 'max:255') + ->storingRules('unique:users,email'); +``` + +## Update Rules + +Likewise, if you would like to define rules that only apply when a resource is being updated, you may use the `updatingRules` method. + +```php +Field::make('email')->updatingRules('required', 'email'); +``` + + +# Interceptors +However the default storing process is done automatically, sometimes you may want to take the control over it. +That's a breeze with Restify, since Field expose few useful chained helpers for that. + +## Fill callback + +There are two steps before the value from the request is attached to model attribute. +Firstly it is get from the application request, and go to the `fillCallback` and secondly, +the value is transforming by the `storeCallback`. + +You may intercept each of those with closures. + +```php +Field::make('title') + ->fillCallback(function (RestifyRequest $request, $model, $attribute) { + $model->{$attribute} = strtoupper($request->get('title_from_the_request')); +}) +``` + +This way you can get anything from the `$request` and perform any transformations with the value before storing. + + +## Store callback + +Another handy interceptor is the `storeCallback`, this is the step immediately before attaching the value from the request to the model attribute: + +This interceptor may be useful for modifying the value passed through the `$request`. + +```php +Field::make('password')->storeCallback(function ($value) { + return Hash::make($value); + }); +``` diff --git a/docs/docs/2.0/repository-pattern/repository-pattern.md b/docs/docs/2.0/repository-pattern/repository-pattern.md new file mode 100644 index 000000000..b5b901263 --- /dev/null +++ b/docs/docs/2.0/repository-pattern/repository-pattern.md @@ -0,0 +1,413 @@ +# Repository + +[[toc]] + +## Introduction + +The Repository is the main core of the Laravel Restify, included with Laravel provides the an easy way of +managing (usually called "CRUD"). It works along with +[Laravel API Resource](https://laravel.com/docs/6.x/eloquent-resources), +that means you can use all helpers from there right away. + +## Quick start +The follow command will generate you the Repository which will take the control over the post resource. + +```shell script +php artisan restify:repository Post +``` + +The newly created repository could be found in the `app/Restify` directory. + +## Defining Repositories + +```php + +use Binaryk\LaravelRestify\Repositories\Repository; + +class Post extends Repository +{ + /** + * The model the repository corresponds to. + * + * @var string + */ + public static $model = 'App\\Post'; +} +``` + +### Actions handled by the Repository + +Having this in place you're basically ready for the CRUD actions over posts. +You have available the follow endpoints: + +| Verb | URI | Action | +| :------------- |:----------------------------- | :-------| +| GET | `/restify-api/posts` | index | +| GET | `/restify-api/posts/{post}` | show | +| POST | `/restify-api/posts` | store | +| PATCH | `/restify-api/posts/{post}` | update | +| DELETE | `/restify-api/posts/{post}` | destroy | + +### Fields +When storing or updating a model - Restify will get from the request all of the attributes matching by key +with those from the `fillable` array of the model definition. +Restify will fill these fields with the value from the request. +However if you want to transform some attributes before they are filled into the model +you can do that by defining the `fields` method: + +```php +use Binaryk\LaravelRestify\Fields\Field; +use Binaryk\LaravelRestify\Repositories\Repository; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; + +class Post extends Repository +{ + /** + * The model the repository corresponds to. + * + * @var string + */ + public static $model = 'App\\Post'; + + /** + * Resolvable attributes before storing/updating + * + * @param RestifyRequest $request + * @return array + */ + public function fields(RestifyRequest $request) + { + return [ + Field::make('title')->storeCallback(function ($requestValue) { + return is_string($requestValue) ? $requestValue : `N/A`; + }) + ]; + } +} +``` + +:::tip + +`Field` class has many mutations, validators and interactions you can use, these are documented [here](/laravel-restify/docs/repository-pattern/field) + +::: + + +## Dependency injection + +The Laravel [service container](https://laravel.com/docs/6.x/container) is used to resolve all Laravel Restify repositories. +As a result, you are able to type-hint any dependencies your `Repository` may need in its constructor. +The declared dependencies will automatically be resolved and injected into the repository instance: + +:::tip +Don't forget to to call the parent `contructor` +::: + +```php +use Binaryk\LaravelRestify\Repositories\Repository; + +class Post extends Repository +{ + /** + * The model the repository corresponds to. + * + * @var string + */ + public static $model = 'App\\Post'; + + /** + * @var PostService + */ + private $postService; + + /** + * Post constructor. + * @param PostService $service + */ + public function __construct(PostService $service) + { + parent::__construct(); + $this->postService = $service; + } + +} +``` + +## Restify Repository Conventions +Let's diving deeper into the repository, and take step by step each of its available tools and customizable +modules. Since this is just a helper, it should not break your normal development flow. + +### Model name +As we already noticed, each repository basically works as a wrapper over a specific resource. +The fancy naming `resource` is nothing more than a database entity (posts, users etc.). Well, to make the +repository aware of the entity it should take care of, we have to define the model property: + +```php +/** +* The model the repository corresponds to. +* +* @var string +*/ +public static $model = 'App\\Post'; +``` + +## CRUD Methods overriding + +Laravel Restify magically made all "CRUD" operations for you. But sometimes you may want to intercept, or override the +entire logic of a specific action. Let's say your `save` method has to do something different than just storing +the newly created entity in the database. In this case you can easily override each action ([defined here](#actions-handled-by-the-repository)) from the repository: + +### index + +```php + public function index(RestifyRequest $request, Paginator $paginated) + { + // Silence is golden + } +``` + +### show + +```php + public function show(RestifyRequest $request, $repositoryId) + { + // Silence is golden + } +``` + +### store + +```php + /** + * @param RestifyRequest $request + * @return \Illuminate\Http\JsonResponse|void + */ + public function store(Binaryk\LaravelRestify\Http\Requests\RestifyRequest $request) + { + // Silence is golden + } +``` + +### update + +```php + public function update(RestifyRequest $request, $repositoryId) + { + // Silence is golden + } +``` + +### destroy + +```php + public function destroy(RestifyRequest $request, $repositoryId) + { + // Silence is golden + } +``` + +## Transformation layer + +When you call the `posts/{post}` endpoint, the repository will return the following primary +data for a single resource object: + +```json +{ + "data": { + "type": "post", + "id": "1", + "attributes": { + // ... this post's attributes + }, + "meta": { + // ... by default meta includes information about user authorizations over the entity + "authorizedToView": true, + "authorizedToCreate": true, + "authorizedToUpdate": true, + "authorizedToDelete": true + } + } +} +``` + +This response is made according to [JSON:API format](https://jsonapi.org/format/). You can change it for all +repositories at once by modifying the `resolveDetails` method of the abstract Repository, or for a specific +repository by overriding it: + +```php +/** + * Resolve the response for the details + * + * @param $request + * @param $serialized + * @return array + */ +public function serializeDetails($request, $serialized) +{ + return $serialized; +} +``` + +You can change the index response by modifying the `resolveIndex` method: + +```php +/** + * Resolve the response for the details + * + * @param $request + * @param $serialized + * @return array + */ +public function serializeIndex($request, $serialized) +{ + return $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(); + } +} +``` + +### Route 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. + + +### Route 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. + +### Route 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/docs/docs/2.0/rest-methods/rest-methods.md b/docs/docs/2.0/rest-methods/rest-methods.md new file mode 100644 index 000000000..fbeb93e37 --- /dev/null +++ b/docs/docs/2.0/rest-methods/rest-methods.md @@ -0,0 +1,354 @@ +[[toc]] + +## Introduction +The API response format must stay consistent along the application. Ideally it would be good to follow a standard +as the [JSON:API](https://jsonapi.org/format/) so your frontend app could align with the API. + +Restify provides several different approaches to respond consistent to the application's incoming request. +By default, Restify's base rest controller class uses a `RestResponse` structure which provides a convenient +method to respond to the HTTP request with a variety of handy magic methods. + +## Restify Response Quickstart +To learn about Restify's handy response, let's look at a complete example of responding a +request and returning the data back to the client. + +### Defining The Route +First, let's assume we have the following routes defined in our `routes/api.php` file: + +```php +Route::post('users', 'UserController@store'); + +Route::get('users/{id}', 'UserController@show'); +``` + +The `GET` route will return back a user for the given `id`. + +### Creating The Controller + +Next, let's take a look at a simple `API` controller that handles this route. We'll leave the `show` and `store` methods empty for now: + +```php +response(User::find($id)); +} +``` + +As you can see, we pass the desired data into the `respond` method. This method will wrap the passed data into a +JSON object and attach it to the `data` response property. + +### Receiving API Response + +Once the `respond` method wrapping the data, the HTTP request will receive back a response having always the +structure: + +```json +{ + "data": { + "id": 1, + "name": "User name", + "email": "kshlerin.hertha@example.com", + "email_verified_at": "2019-12-20 09:48:54", + "created_at": "2019-12-20 09:48:54", + "updated_at": "2020-01-10 12:01:17" + } +} + +``` + +or: + +```json +{ + "errors": [...] +} +``` + +## Response factory +In addition the parent `RestController` provides a powerful `response` factory method. +To understand this let's return back to our `store` method from the `UserController`: + +```php +/** + * Store a newly created resource in storage. + * + * @param Request $request + * @return Response + */ +public function store(Request $request) +{ + return $this->response(); +} +``` + +The `response()` method will be an instance of `Binaryk\LaravelRestify\Controllers\RestResponse`. For more information on working with this object instance, +[check out its documentation](#rest-response-methods). + +```php +$this->response() +->data($user) +->message('This is the first user'); +``` + +The response will look like: + +```json +{ + "data": { + "id": 1, + "name": "User name", + "email": "kshlerin.hertha@example.com", + "email_verified_at": "2019-12-20 09:48:54", + "created_at": "2019-12-20 09:48:54", + "updated_at": "2020-01-10 12:01:17" + }, + "meta": { + "message": "This is the first user" + } +} +``` + +### Displaying Response Errors + +As we saw above, the response always contains an `errors` property. This can be either an empty array, or a list with errors. +For example, what if the incoming request parameters do not pass the given validation rules? This can be handled by the `errors` proxy +method: + +```php +/** + * Store a newly created resource in storage. + * + * @param Request $request + * @return Response + */ +public function store(Request $request) +{ + try { + $this->validate($request, [ + 'title' => 'required|unique:users|max:255', + ]); + + // The user is valid + } catch (ValidationException $exception) { + // The user is not valid + return $this->errors($exception->errors()); + } +} +``` + +And returned `API` response will have the `400` HTTP code and the following format: + +```json +{ + "errors": { + "title": [ + "The title field is required." + ] + } +} +``` + +## Custom Header + +Sometimes you may need to respond with a custom header, for example according with [JSON:API](https://jsonapi.org/format/#crud-creating-responses-201) +after storing an entity, we should respond with a `Location` header which has the value endpoint to the resource: + +```php +return $this->response() + ->header('Location', 'api/users/1') + ->data($user); +``` + +## Optional Attributes + +By default Restify returns `data` and `errors` attributes in the API response. It also wrap the message into a `meta` object. +But what if we have to send some custom attributes. In addition to generating default fields, you may add extra fields to the +response by using `setMeta` method from the `RestResponse` object: + +```php +return $this->response() + ->data($user) + ->setMeta('related', [ 'William Shakespeare', 'Agatha Christie', 'Leo Tolstoy' ]); +``` + +## Hiding Default Attribute + +Restify has a list of predefined attributes: `'line', 'file', 'stack', 'data', 'errors', 'meta'`. + +Some of those are hidden in production: `'line', 'file', 'stack'`, since they are only used for tracking exceptions. + +If you would like the API response to not contain any of these fields (or hiding a specific one, `errors` for example), +this can be done by setting in the application provider the: + +```php +RestResponse::$RESPONSE_DEFAULT_ATTRIBUTES = ['data', 'meta']; +``` + +## Rest Response Methods + +The `$this->response()` returns an instance of `Binaryk\LaravelRestify\Controllers\RestResponse`. This expose multiple +magic methods for your consistent API response. + +### Data attaching +As we already have seen, attaching data to the response can be done by using: + +```php +->data($info) +``` + +### Headers setup +Header could be set by using `header` method, it accept two arguments, the header name and header value: + +```php +->header('Location', 'api/users/1') +``` + +### Meta information +In addition with `data` you may want to send some extra attributes to the client, a message for example, or anything else: + +```php +->setMeta('name', 'Eduard Lupacescu') +``` + +```php +->message(__('Silence is golden.')) +``` + +## Response code modifiers +Very often we have to send an informative response code. The follow methods are used for setting the response code: + +### Auth 401 +```php +->auth() +`````` +### Refresh 103 +```php +->refresh() +```` +### Created 201 + +```php +->created() +```` +### Deleted (No Content) 204 +```php +->deleted() +```` +```php +->blank() +```` +### Invalid 400 +```php +->invalid() +```` +### Unauthorized 401 +```php +->unauthorized() +```` +### Forbidden 403 +```php +->forbidden() +```` +### Missing 404 +```php +->missing() +```` +### success +```php +->success() +```` +### Unavailable 503 +```php +->unavailable() +```` + +### Throttle 429 +```php +->throttle() +``` + +### Debug methods +The follow methods could be used to debug some information in the dev mode: + +### line +```php +$lineNumber = 201; +$this->line($lineNumber) +``` + +### file +This could be used for debugging the file name + +```php +$this->file($exception->getFile()) +``` +### stack +With this you may log the exception stach trace + +```php +$this->stack($exception->getTraceAsString()) +``` + +### Errors methods +The follow methods could be used for adding errors to the response: + +### errors +Adding a set of errors at once: + +```php +$this->errors([ 'Something went wrong' ]) +``` + +### addError +Adding error by error in a response instance: + +```php +$this->addError('Something went wrong') +``` diff --git a/docs/docs/2.0/search/search.md b/docs/docs/2.0/search/search.md new file mode 100644 index 000000000..f18513340 --- /dev/null +++ b/docs/docs/2.0/search/search.md @@ -0,0 +1,135 @@ +# Filtering entities + +Laravel Restify provides configurable and powerful way of filtering over entities. + +- Prerequisites + +In order to make a model searchable, it should implement the `Binaryk\LaravelRestify\Contracts\RestifySearchable` contract. +After running this command, add the `Binaryk\LaravelRestify\Traits\InteractWithSearch` trait to your model. +This trait will provide a few helper methods to your model which allow you to filter. + +:::tip +The searchable feature is available as for the Restify generated endpoints as well as from a custom Controller searching, +`$this->search(Model::class)` +::: + +## Search + +If you want search for some specific fields from a model, you have to define these fields in the `$search` static +property: + +```php +use Illuminate\Database\Eloquent\Model; +use Binaryk\LaravelRestify\Traits\InteractWithSearch; +use Binaryk\LaravelRestify\Contracts\RestifySearchable; + +class Post extends Model implements RestifySearchable +{ + use InteractWithSearch; + + public static $search = ['id', 'title']; +``` + +Now the `Post` entity is searchable by `id` and `title`, so you could use `search` query param for filtering the index +request: + +```http request +GET: /restify-api/posts?search="Test title" +``` + +## Match + +Matching by specific attributes may be useful if you want an exact matching. Model +configuration: + +```php +use Illuminate\Database\Eloquent\Model; +use Binaryk\LaravelRestify\Traits\InteractWithSearch; +use Binaryk\LaravelRestify\Contracts\RestifySearchable; + +class Post extends Model implements RestifySearchable +{ + use InteractWithSearch; + + public static $search = ['id', 'title']; + + public static $match = ['id' => 'int', 'title' => 'string']; + +``` + +As we may notice the match configuration is an associative array, defining the attribute name and type mapping. + +Available types: + +- text (or `string`) +- bool +- int (or `integer`) + +When performing the request you may pass the match field and value as query params: + +```http request +GET: /restify-api/posts?id=1 +``` + +or by title: + +```http request +GET: /restify-api/posts?title="Some title" +``` + + +## Sort +When index query entities, usually we have to sort by specific attributes. + +This requires the `$sort` configuration: + +```php +use Illuminate\Database\Eloquent\Model; +use Binaryk\LaravelRestify\Traits\InteractWithSearch; +use Binaryk\LaravelRestify\Contracts\RestifySearchable; + +class Post extends Model implements RestifySearchable +{ + use InteractWithSearch; + + public static $search = ['id', 'title']; + + public static $match = ['id' => 'int', 'title' => 'string']; + + public static $sort = ['id']; +``` + + Performing request requires the sort query param: + + Sorting DESC requires a minus sign before the attribute name: + + ```http request +GET: /restify-api/posts?sort=-id +``` + + Sorting ASC: + + ```http request +GET: /restify-api/posts?sort=id +``` + +or with plus sign before the field: + + ```http request +GET: /restify-api/posts?sort=+id +``` + +## Eager loading - aka withs + +When get a repository index or details about a single entity, often we have to get the related entities (we have access to). +This eager loading is configurable by Restify as follow: + +```php +public static $withs = ['posts']; +``` + +This means that we could use `posts` query for eager loading posts: + +```http request +GET: /restify-api/users?with=posts +``` diff --git a/src/Commands/stubs/policy.stub b/src/Commands/stubs/policy.stub index 2e090991f..1908cf2bb 100644 --- a/src/Commands/stubs/policy.stub +++ b/src/Commands/stubs/policy.stub @@ -16,19 +16,19 @@ class DummyClass * @param \App\User $user * @return mixed */ - public function viewAny(User $user = null) + public function showAny(User $user = null) { // } /** - * Determine whether the user can view the model. + * Determine whether the user can get the model. * * @param \App\User $user * @param DummyModel $model * @return mixed */ - public function view(User $user, DummyModel $model) + public function show(User $user, DummyModel $model) { // } diff --git a/src/Controllers/RestController.php b/src/Controllers/RestController.php index 3488e34c7..6db68502e 100644 --- a/src/Controllers/RestController.php +++ b/src/Controllers/RestController.php @@ -23,6 +23,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Routing\Controller as BaseController; use Illuminate\Support\Facades\Password; +use Illuminate\Support\Traits\ForwardsCalls; use Throwable; /** @@ -36,7 +37,7 @@ */ abstract class RestController extends BaseController { - use AuthorizesRequests, DispatchesJobs, ValidatesRequests; + use AuthorizesRequests, DispatchesJobs, ValidatesRequests, ForwardsCalls; /** * @var RestResponse @@ -93,23 +94,6 @@ public function config() return $this->config; } - /** - * Returns a generic response to the client. - * - * @param mixed $data - * @param int $httpCode - * - * @return JsonResponse - * @throws BindingResolutionException - */ - protected function respond($data = null, $httpCode = 200) - { - $response = new \stdClass(); - $response->data = $data; - - return $this->response()->data($data)->code($httpCode)->respond(); - } - /** * Get Response object. * @@ -230,14 +214,13 @@ public function broker() /** * Returns with a message. * @param $msg - * @return JsonResponse + * @return RestResponse * @throws BindingResolutionException */ public function message($msg) { return $this->response() - ->message($msg) - ->respond(); + ->message($msg); } /** @@ -251,7 +234,13 @@ protected function errors(array $errors) { return $this->response() ->invalid() - ->errors($errors) - ->respond(); + ->errors($errors); + } + + public function __call($method, $parameters) + { + $this->response(); + + $this->forwardCallTo($this->response, $method, $parameters); } } diff --git a/src/Controllers/RestResponse.php b/src/Controllers/RestResponse.php index 7af6770e9..be073c2e5 100644 --- a/src/Controllers/RestResponse.php +++ b/src/Controllers/RestResponse.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Support\Responsable; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Arr; /** * Class RestResponse. @@ -18,6 +19,7 @@ * @method RestResponse created() * @method RestResponse deleted() * @method RestResponse blank() + * @method RestResponse notFound() * @method RestResponse error() 500 * @method RestResponse invalid() 400 * @method RestResponse unauthorized() 401 - don't have correct password/email @@ -29,9 +31,10 @@ * * @author lupacescueduard */ -class RestResponse implements Responsable +class RestResponse extends JsonResponse implements Responsable { public static $RESPONSE_DEFAULT_ATTRIBUTES = [ + 'attributes', 'line', 'file', 'stack', @@ -55,14 +58,20 @@ class RestResponse implements Responsable const REST_RESPONSE_UNAUTHORIZED_CODE = 401; const REST_RESPONSE_FORBIDDEN_CODE = 403; const REST_RESPONSE_MISSING_CODE = 404; + const REST_RESPONSE_NOTFOUND_CODE = 404; const REST_RESPONSE_THROTTLE_CODE = 429; const REST_RESPONSE_SUCCESS_CODE = 200; const REST_RESPONSE_UNAVAILABLE_CODE = 503; + /** + * @var ResponseFactory + */ + public $response; + /** * @var int */ - protected $code = self::REST_RESPONSE_SUCCESS_CODE; + protected $code; /** * @var int */ @@ -105,16 +114,6 @@ class RestResponse implements Responsable */ private $errors; - /** - * @var array|null - */ - private $data; - - /** - * @var array - */ - protected $headers; - /** * @var string */ @@ -132,28 +131,23 @@ class RestResponse implements Responsable protected $relationships; /** - * RestResponse constructor. - * @param mixed $content - * @param int $status - * @param array $headers + * Indicate if response could include sensitive information (file, line). + * @var bool */ - public function __construct($content = null, $status = 200, array $headers = []) - { - $this->data = $content; - $this->code = $status; - $this->headers = $headers; - } + public $debug = false; /** * Set response data. * - * @param mixed $data + * @param mixed $data * @return $this|mixed */ public function data($data = null) { if (func_num_args()) { - $this->data = ($data instanceof Arrayable) ? $data->toArray() : $data; + $data = ($data instanceof Arrayable) ? $data->toArray() : $data; + + $this->setData(compact('data')); return $this; } @@ -164,24 +158,24 @@ public function data($data = null) /** * Set response errors. * - * @param array $errors + * @param mixed $errors * @return $this|null */ - public function errors(array $errors = null) + public function errors($errors) { if (func_num_args()) { - $this->errors = $errors; + $this->errors = Arr::wrap($errors); return $this; } - return $this->errors; + return $this; } /** * Add error to response errors. * - * @param mixed $message + * @param mixed $message * @return $this */ public function addError($message) @@ -198,7 +192,7 @@ public function addError($message) /** * Set response Http code. * - * @param int $code + * @param int $code * @return $this|int */ public function code($code = self::REST_RESPONSE_SUCCESS_CODE) @@ -215,7 +209,7 @@ public function code($code = self::REST_RESPONSE_SUCCESS_CODE) /** * Set response Http code. * - * @param int $line + * @param int $line * @return $this|int */ public function line($line = null) @@ -230,7 +224,7 @@ public function line($line = null) } /** - * @param string $file + * @param string $file * @return $this|int */ public function file(string $file = null) @@ -245,7 +239,7 @@ public function file(string $file = null) } /** - * @param string|null $stack + * @param string|null $stack * @return $this|int */ public function stack(string $stack = null) @@ -262,7 +256,7 @@ public function stack(string $stack = null) /** * Magic to get response code constants. * - * @param string $key + * @param string $key * @return mixed|null */ public function __get($key) @@ -298,7 +292,7 @@ public function __call($func, $args) /** * Build a new response with our response data. * - * @param mixed $response + * @param mixed $response * * @return JsonResponse */ @@ -322,7 +316,9 @@ public function respond($response = null) } } - return $this->response()->json(static::beforeRespond($response), is_int($this->code()) ? $this->code() : self::REST_RESPONSE_SUCCESS_CODE, $this->headers); + return tap($this->response()->json(static::beforeRespond($response), is_int($this->code()) ? $this->code() : self::REST_RESPONSE_SUCCESS_CODE), function ($response) { + $this->withResponse($response, request()); + }); } /** @@ -411,7 +407,7 @@ public function getAttribute($name) /** * Set attributes at root level. * - * @param array $attributes + * @param array $attributes * @return mixed */ public function setAttributes(array $attributes) @@ -422,32 +418,37 @@ public function setAttributes(array $attributes) } /** - * @return array + * Set "id" at root level for a model. + * + * @param $id + * @return mixed */ - public function fillable(): array + public function id($id) { - return static::$RESPONSE_DEFAULT_ATTRIBUTES; + $this->id = $id; + + return $this; } /** - * @return ResponseFactory - * @throws \Illuminate\Contracts\Container\BindingResolutionException + * @return array */ - protected function response() + public function fillable(): array { - return app()->make(ResponseFactory::class); + return static::$RESPONSE_DEFAULT_ATTRIBUTES; } /** - * @param $name - * @param $value - * @return RestResponse + * @return ResponseFactory + * @throws \Illuminate\Contracts\Container\BindingResolutionException */ - public function header($name, $value) + public function response() { - $this->headers[$name] = $value; + if (is_null($this->response)) { + $this->response = app()->make(ResponseFactory::class); + } - return $this; + return $this->response; } /** @@ -465,8 +466,8 @@ public function type($type) * Useful when newly created repository, will prepare the response according * with JSON:API https://jsonapi.org/format/#document-resource-object-fields. * - * @param Repository $repository - * @param bool $withRelations + * @param Repository $repository + * @param bool $withRelations * @return $this */ public function forRepository(Repository $repository, $withRelations = false) @@ -514,11 +515,100 @@ public static function beforeRespond($response) } /** - * @param \Illuminate\Http\Request $request + * @param \Illuminate\Http\Request $request * @return JsonResponse|\Symfony\Component\HttpFoundation\Response */ - public function toResponse($request) + public function toResponse($request = null) + { + if ($this->errors) { + $this->setData([ + 'errors' => $this->getErrors(), + ]); + } + + if ($this->code) { + $this->setStatusCode(is_int($this->code()) ? $this->code() : self::REST_RESPONSE_SUCCESS_CODE); + } + + if ($this->debug) { + $extra = []; + + foreach (['line', 'errors', 'file', 'stack', 'meta'] as $property) { + if (isset($this->{$property})) { + $extra[$property] = $this->{$property}; + } + } + + $this->setData($extra); + } + + if ($this->meta) { + $this->original['meta'] = $this->meta; + $this->setData($this->original); + } + + // Single resource ($this->model(...)) + if ($this->id) { + $original = [ + 'data' => [ + 'attributes' => $this->attributes, + 'type' => $this->type, + 'id' => $this->id, + ], + ]; + + $this->setData($original); + } + + return $this; + } + + /** + * @param $response + * @param $request + */ + public function withResponse($response, $request) + { + // + } + + /** + * @return array|null + */ + public function getErrors() + { + return $this->errors instanceof Arrayable ? $this->errors->toArray() : $this->errors; + } + + public function debug(\Exception $exception, $condition) { - return $this->respond(); + if ($condition) { + $this->debug = true; + + $this->line($exception->getLine()) + ->code($exception->getCode()) + ->file($exception->getFile()) + ->errors($exception->getMessage()) + ->stack($exception->getTraceAsString()); + } + + return $this; + } + + /** + * Set the JSON:API format for a single resource. + * + * $this->model( User::find(1) ) + * + * @param Model $model + * @return $this + */ + public function model(Model $model) + { + $this->setAttributes($model->jsonSerialize()) + ->type($model->getTable()) + ->id($model->getKey()); + + return $this; } } diff --git a/src/Exceptions/RestifyHandler.php b/src/Exceptions/RestifyHandler.php index 3832b2b06..98d759073 100644 --- a/src/Exceptions/RestifyHandler.php +++ b/src/Exceptions/RestifyHandler.php @@ -106,18 +106,15 @@ public function render($request, Exception $exception) break; default: - if (App::environment('production') === 'production') { + if (App::environment('production') === true) { $response->addError(__('messages.something_went_wrong')); } else { - $response->addError($exception->getMessage())->code($exception->getCode()) - ->line($exception->getLine()) - ->file($exception->getFile()) - ->stack($exception->getTraceAsString()); + $response->debug($exception, true); } $response->error(); } - return $response->respond(); + return $response->toResponse($request); } /** diff --git a/src/Http/Controllers/RepositoryIndexController.php b/src/Http/Controllers/RepositoryIndexController.php index ad3046f19..221fb2ac8 100644 --- a/src/Http/Controllers/RepositoryIndexController.php +++ b/src/Http/Controllers/RepositoryIndexController.php @@ -2,6 +2,9 @@ namespace Binaryk\LaravelRestify\Http\Controllers; +use Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException; +use Binaryk\LaravelRestify\Exceptions\InstanceOfException; +use Binaryk\LaravelRestify\Exceptions\UnauthorizedException; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; /** @@ -20,8 +23,16 @@ class RepositoryIndexController extends RepositoryController */ public function handle(RestifyRequest $request) { - $data = $this->paginator($request->newRepository()); - - return $request->newRepositoryWith($data)->index($request, $data); + try { + return $request->newRepository()->index($request); + } catch (EntityNotFoundException $e) { + return $this->response()->notFound() + ->addError($e->getMessage()) + ->debug($e, $request->isDev()); + } catch (UnauthorizedException $e) { + return $this->response()->forbidden()->addError($e->getMessage())->debug($e, $request->isDev()); + } catch (InstanceOfException | \Throwable $e) { + return $this->response()->error()->debug($e, $request->isDev()); + } } } diff --git a/src/Http/Middleware/RestifyInjector.php b/src/Http/Middleware/RestifyInjector.php index 9d96709a1..0a5f555aa 100644 --- a/src/Http/Middleware/RestifyInjector.php +++ b/src/Http/Middleware/RestifyInjector.php @@ -5,6 +5,7 @@ use Binaryk\LaravelRestify\Events\RestifyBeforeEach; use Binaryk\LaravelRestify\Events\RestifyServiceProviderRegistered; use Binaryk\LaravelRestify\Restify; +use Binaryk\LaravelRestify\RestifyCustomRoutesProvider; use Binaryk\LaravelRestify\RestifyServiceProvider; use Closure; @@ -28,6 +29,8 @@ public function handle($request, Closure $next) $request->is(trim($path.'/*', '/')) || $request->is('restify-api/*'); + app()->register(RestifyCustomRoutesProvider::class); + if ($isRestify) { RestifyBeforeEach::dispatch($request); app()->register(RestifyServiceProvider::class); diff --git a/src/Http/Requests/InteractWithRepositories.php b/src/Http/Requests/InteractWithRepositories.php index 54716682d..c426bfc1c 100644 --- a/src/Http/Requests/InteractWithRepositories.php +++ b/src/Http/Requests/InteractWithRepositories.php @@ -44,7 +44,7 @@ public function repository() ]), 404); } - if (! $repository::authorizedToViewAny($this)) { + if (! $repository::authorizedToShowAny($this)) { throw new UnauthorizedException(__('Unauthorized to view repository :name.', [ 'name' => $repository, ]), 403); diff --git a/src/Http/Requests/RestifyRequest.php b/src/Http/Requests/RestifyRequest.php index 711031f43..86d9f9ac9 100644 --- a/src/Http/Requests/RestifyRequest.php +++ b/src/Http/Requests/RestifyRequest.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\App; /** * @author Eduard Lupacescu @@ -10,4 +11,20 @@ class RestifyRequest extends FormRequest { use InteractWithRepositories; + + /** + * @return bool + */ + public function isProduction() + { + return App::environment('production'); + } + + /** + * @return bool + */ + public function isDev() + { + return false === $this->isProduction(); + } } diff --git a/src/LaravelRestifyServiceProvider.php b/src/LaravelRestifyServiceProvider.php index 968880b87..c766f9618 100644 --- a/src/LaravelRestifyServiceProvider.php +++ b/src/LaravelRestifyServiceProvider.php @@ -27,6 +27,7 @@ public function boot() ]); $this->registerPublishing(); + $this->app->register(RestifyCustomRoutesProvider::class); $this->app->register(RestifyServiceProvider::class); } diff --git a/src/Repositories/Crudable.php b/src/Repositories/Crudable.php index 225499f1c..3f53c80a4 100644 --- a/src/Repositories/Crudable.php +++ b/src/Repositories/Crudable.php @@ -2,14 +2,16 @@ namespace Binaryk\LaravelRestify\Repositories; +use Binaryk\LaravelRestify\Contracts\RestifySearchable; use Binaryk\LaravelRestify\Controllers\RestResponse; use Binaryk\LaravelRestify\Exceptions\UnauthorizedException; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Restify; +use Binaryk\LaravelRestify\Services\Search\SearchService; use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Arr; +use Illuminate\Pagination\AbstractPaginator; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -18,27 +20,50 @@ */ trait Crudable { - /** - * @param null $request - * @return \Illuminate\Http\JsonResponse - */ - abstract public function response($request = null); - /** * @param RestifyRequest $request - * @param Paginator $paginated * @return JsonResponse + * @throws \Binaryk\LaravelRestify\Exceptions\InstanceOfException + * @throws \Throwable */ - public function index(RestifyRequest $request, Paginator $paginated) + public function index(RestifyRequest $request) { - return $this->response($request); + $results = SearchService::instance()->search($request, $this->model()); + + $results = $results->tap(function ($query) use ($request) { + self::indexQuery($request, $query); + }); + + /** + * @var AbstractPaginator + */ + $paginator = $results->paginate($request->get('perPage') ?? (static::$defaultPerPage ?? RestifySearchable::DEFAULT_PER_PAGE)); + + $items = $paginator->getCollection()->map(function ($value) { + return static::resolveWith($value); + }); + + try { + $this->allowToViewAny($request, $items); + } catch (UnauthorizedException | AuthorizationException $e) { + return $this->response()->forbidden()->addError($e->getMessage()); + } + + // Filter out items the request user don't have enough permissions for show + $items = $items->filter(function ($repository) use ($request) { + return $repository->authorizedToShow($request); + }); + + return $this->response([ + 'meta' => RepositoryCollection::meta($paginator->toArray()), + 'links' => RepositoryCollection::paginationLinks($paginator->toArray()), + 'data' => $items, + ]); } /** * @param RestifyRequest $request * @return JsonResponse - * @throws \Illuminate\Auth\Access\AuthorizationException - * @throws \Throwable */ public function show(RestifyRequest $request, $repositoryId) { @@ -46,12 +71,16 @@ public function show(RestifyRequest $request, $repositoryId) * Dive into the Search service to attach relations. */ $this->withResource(tap($this->resource, function ($query) use ($request) { - $request->newRepository()->detailQuery($request, $query); + static::detailQuery($request, $query); })->firstOrFail()); - $this->authorizeToView($request); + try { + $this->allowToShow($request); + } catch (AuthorizationException $e) { + return $this->response()->forbidden()->addError($e->getMessage()); + } - return $this->response($request); + return $this->response()->model($this->resource); } /** @@ -63,13 +92,10 @@ 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); + return $this->response()->addError($e->getMessage())->code(RestResponse::REST_RESPONSE_FORBIDDEN_CODE); } catch (ValidationException $e) { - return $this->response()->setData([ - 'errors' => $e->errors(), - ])->setStatusCode(RestResponse::REST_RESPONSE_INVALID_CODE); + return $this->response()->addError($e->errors()) + ->code(RestResponse::REST_RESPONSE_INVALID_CODE); } $model = DB::transaction(function () use ($request) { @@ -82,9 +108,8 @@ public function store(RestifyRequest $request) return $model; }); - return (new static ($model)) - ->response() - ->setStatusCode(RestResponse::REST_RESPONSE_CREATED_CODE) + return $this->response('', RestResponse::REST_RESPONSE_CREATED_CODE) + ->model($model) ->header('Location', Restify::path().'/'.static::uriKey().'/'.$model->id); } @@ -107,7 +132,7 @@ public function update(RestifyRequest $request, $repositoryId) return $this; }); - return $this->response()->setStatusCode(RestResponse::REST_RESPONSE_UPDATED_CODE); + return response()->json($this->jsonSerialize(), RestResponse::REST_RESPONSE_UPDATED_CODE); } /** @@ -165,4 +190,23 @@ public function allowToDestroy(RestifyRequest $request) { $this->authorizeToDelete($request); } + + /** + * @param $request + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function allowToShow($request) + { + $this->authorizeToShow($request); + } + + /** + * @param $request + * @param Collection $items + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function allowToViewAny($request, Collection $items) + { + $this->authorizeToShowAny($request); + } } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 5f1616494..67b51eadd 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -3,16 +3,22 @@ namespace Binaryk\LaravelRestify\Repositories; use Binaryk\LaravelRestify\Contracts\RestifySearchable; +use Binaryk\LaravelRestify\Controllers\RestResponse; use Binaryk\LaravelRestify\Fields\Field; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Traits\InteractWithSearch; use Binaryk\LaravelRestify\Traits\PerformsQueries; +use Illuminate\Container\Container; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\Resources\ConditionallyLoadsAttributes; use Illuminate\Routing\Router; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Illuminate\Support\Traits\ForwardsCalls; +use JsonSerializable; /** * This class serve as repository collection and repository single model @@ -20,13 +26,16 @@ * response). * @author Eduard Lupacescu */ -abstract class Repository extends RepositoryCollection implements RestifySearchable +abstract class Repository implements RestifySearchable, JsonSerializable { use InteractWithSearch, ValidatingTrait, RepositoryFillFields, PerformsQueries, - Crudable; + Crudable, + ResponseResolver, + ConditionallyLoadsAttributes, + ForwardsCalls; /** * This is named `resource` because of the forwarding properties from DelegatesToResource trait. @@ -36,16 +45,6 @@ abstract class Repository extends RepositoryCollection implements RestifySearcha */ public $resource; - /** - * Create a new resource instance. - * - * @param \Illuminate\Database\Eloquent\Model $model - */ - public function __construct($model = null) - { - parent::__construct($model); - } - /** * Get the underlying model instance for the resource. * @@ -53,11 +52,7 @@ public function __construct($model = null) */ public function model() { - if ($this->isRenderingCollection() || $this->isRenderingPaginated()) { - return $this->modelFromIterator() ?? static::newModel(); - } - - return $this->resource; + return $this->resource ?? static::newModel(); } /** @@ -91,19 +86,16 @@ public static function query() } /** + * @param $request * @return array */ public function toArray($request) { - if ($this->isRenderingCollection()) { - return $this->toArrayForCollection($request); - } - $serialized = [ - 'id' => $this->when($this->isRenderingRepository(), function () { - return $this->getKey(); + 'id' => $this->when($this->resource instanceof Model, function () { + return $this->resource->getKey(); }), - 'type' => self::model()->getTable(), + 'type' => $this->model()->getTable(), 'attributes' => $this->resolveDetailsAttributes($request), 'relationships' => $this->when(value($this->resolveDetailsRelationships($request)), $this->resolveDetailsRelationships($request)), 'meta' => $this->when(value($this->resolveDetailsMeta($request)), $this->resolveDetailsMeta($request)), @@ -172,6 +164,17 @@ public static function __callStatic($method, $parameters) return (new static)->$method(...$parameters); } + /** + * Forward calls to the model (getKey() for example). + * @param $method + * @param $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + return $this->forwardCallTo($this->model(), $method, $parameters); + } + /** * Defining custom routes. * @@ -182,10 +185,52 @@ public static function __callStatic($method, $parameters) * However all options could be customized by passing an $options argument * * @param Router $router - * @param $options + * @param array $attributes + */ + public static function routes(Router $router, $attributes) + { + $router->group($attributes, function ($router) { + // override for custom routes + }); + } + + /** + * Resolve the resource to an array. + * + * @param \Illuminate\Http\Request|null $request + * @return array + */ + public function resolve($request = null) + { + $data = $this->toArray( + $request = $request ?: Container::getInstance()->make('request') + ); + + if ($data instanceof Arrayable) { + $data = $data->toArray(); + } elseif ($data instanceof JsonSerializable) { + $data = $data->jsonSerialize(); + } + + return $this->filter((array) $data); + } + + /** + * @return array|mixed + */ + public function jsonSerialize() + { + return $this->resolve(); + } + + /** + * @param string $content + * @param int $status + * @param array $headers + * @return RestResponse */ - public static function routes(Router $router, $options = []) + public function response($content = '', $status = 200, array $headers = []) { - // override for custom routes + return new RestResponse($content, $status, $headers); } } diff --git a/src/Repositories/RepositoryCollection.php b/src/Repositories/RepositoryCollection.php index 4d1384fc1..5353f9804 100644 --- a/src/Repositories/RepositoryCollection.php +++ b/src/Repositories/RepositoryCollection.php @@ -2,55 +2,13 @@ namespace Binaryk\LaravelRestify\Repositories; -use ArrayIterator; -use Binaryk\LaravelRestify\Restify; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Http\Resources\Json\Resource; -use Illuminate\Pagination\AbstractPaginator; use Illuminate\Support\Arr; /** * @author Eduard Lupacescu */ -class RepositoryCollection extends Resource +class RepositoryCollection { - use ResponseResolver; - - /** - * When the repository is used as a response for a collection list (index controller). - * - * @param $request - * @return array - */ - public function toArrayForCollection($request) - { - $paginated = parent::toArray($request); - - $currentRepository = Restify::repositoryForModel(get_class($this->model())); - - if (is_null($currentRepository)) { - return Arr::only(parent::toArray($request), 'data'); - } - - $data = collect([]); - $iterator = $this->iterator(); - - while ($iterator->valid()) { - $data->push($iterator->current()); - $iterator->next(); - } - - $response = $data->map(function ($value) use ($currentRepository) { - return static::resolveWith($value); - })->toArray($request); - - return $this->serializeIndex($request, [ - 'meta' => $this->when($this->isRenderingPaginated(), static::meta($paginated)), - 'links' => $this->when($this->isRenderingPaginated(), static::paginationLinks($paginated)), - 'data' => $response, - ]); - } - /** * Get the pagination links for the response. * @@ -83,62 +41,4 @@ public static function meta($paginated) 'next_page_url', ]); } - - /** - * Check if the repository is used as a response for a list of items or for a single - * model entity. - * @return bool - */ - protected function isRenderingRepository() - { - return $this->resource instanceof Model; - } - - /** - * Check if the repository is used as a response for a list of items or for a single - * model entity. - * @return bool - */ - protected function isRenderingCollection() - { - return false === $this->resource instanceof Model; - } - - /** - * @return bool - */ - public function isRenderingPaginated() - { - return $this->resource instanceof AbstractPaginator; - } - - /** - * If collection or paginator then return model from the first item. - * - * @return Model - */ - protected function modelFromIterator() - { - /** - * @var ArrayIterator - */ - $iterator = $this->iterator(); - - /** - * This is the first element from the response collection, now we have the class of the restify - * engine. - * @var Model - */ - $model = $iterator->current(); - - return $model; - } - - /** - * @return ArrayIterator - */ - protected function iterator() - { - return $this->resource->getIterator(); - } } diff --git a/src/Repositories/ResponseResolver.php b/src/Repositories/ResponseResolver.php index 0bde2f4d6..55729c3c8 100644 --- a/src/Repositories/ResponseResolver.php +++ b/src/Repositories/ResponseResolver.php @@ -4,6 +4,8 @@ use Binaryk\LaravelRestify\Contracts\RestifySearchable; use Binaryk\LaravelRestify\Restify; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Pagination\AbstractPaginator; /** * @author Eduard Lupacescu @@ -18,7 +20,7 @@ trait ResponseResolver */ public function resolveDetailsAttributes($request) { - return parent::toArray($request); + return method_exists($this->resource, 'toArray') ? $this->resource->toArray() : []; } /** @@ -28,7 +30,7 @@ public function resolveDetailsAttributes($request) public function resolveDetailsMeta($request) { return [ - 'authorizedToView' => $this->authorizedToView($request), + 'authorizedToShow' => $this->authorizedToShow($request), 'authorizedToCreate' => $this->authorizedToCreate($request), 'authorizedToUpdate' => $this->authorizedToUpdate($request), 'authorizedToDelete' => $this->authorizedToDelete($request), @@ -53,14 +55,23 @@ public function resolveDetailsRelationships($request) with(explode(',', $request->get('with')), function ($relations) use ($request, &$withs) { foreach ($relations as $relation) { if (in_array($relation, $this->resource::getWiths())) { + /** + * @var AbstractPaginator + */ $paginator = $this->resource->{$relation}()->paginate($request->get('relatablePerPage') ?? ($this->resource::$defaultRelatablePerPage ?? RestifySearchable::DEFAULT_RELATABLE_PER_PAGE)); + /** * @var Builder $q */ $q = $this->resource->{$relation}->first(); + /** * @var Repository $repository */ if ($q && $repository = Restify::repositoryForModel($q->getModel())) { // This will serialize into the repository dedicated for model - $relatable = $repository::resolveWith($paginator)->toArray($request); + $relatable = $paginator->getCollection()->map(function ($value) use ($repository) { + return $repository::resolveWith($value); + }); } else { // This will fallback into serialization of the parent formatting - $relatable = static::resolveWith($paginator)->toArray($request); + $relatable = $paginator->getCollection()->map(function ($value) use ($repository) { + return $repository::resolveWith($value); + }); } unset($relatable['meta']); diff --git a/src/RestifyCustomRoutesProvider.php b/src/RestifyCustomRoutesProvider.php new file mode 100644 index 000000000..269143269 --- /dev/null +++ b/src/RestifyCustomRoutesProvider.php @@ -0,0 +1,55 @@ +registerRoutes(); + } + + /** + * Register the package routes. + * + * @return void + */ + protected function registerRoutes() + { + 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'); + + $parameters = $method->getParameters(); + + if (count($parameters) === 2 && $parameters[1] instanceof \ReflectionParameter) { +// $config = array_merge($config, $parameters[1]->getDefaultValue()); + } + + Route::group([], function ($router) use ($repository, $config) { + if ($repository === 'Binaryk\LaravelRestify\Tests\RepositoryWithRoutes') { + } + $repository::routes($router, $config); + }); + }); + } +} diff --git a/src/RestifyServiceProvider.php b/src/RestifyServiceProvider.php index 464ecd55e..615ecb231 100644 --- a/src/RestifyServiceProvider.php +++ b/src/RestifyServiceProvider.php @@ -4,7 +4,6 @@ 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,39 +33,7 @@ protected function registerRoutes() 'middleware' => config('restify.middleware', []), ]; - $this->customDefinitions() - ->defaultRoutes($config); - } - - /** - * @return RestifyServiceProvider - */ - public function customDefinitions() - { - 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'); - - $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; + $this->defaultRoutes($config); } /** diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index 6639d513e..7b0b9753f 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -7,7 +7,6 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; -use Illuminate\Support\Str; /** * Could be used as a trait in a model class and in a repository class. @@ -38,82 +37,79 @@ public static function authorizable() /** * Determine if the resource should be available for the given request. * - * @param \Illuminate\Http\Request $request + * @param \Illuminate\Http\Request $request * @return void * @throws AuthorizationException - * @throws \Throwable */ - public function authorizeToViewAny(Request $request) + public function authorizeToShowAny(Request $request) { if (! static::authorizable()) { return; } - if (method_exists(Gate::getPolicyFor(static::newModel()), 'viewAny')) { - $this->authorizeTo($request, 'viewAny'); + if (method_exists(Gate::getPolicyFor(static::newModel()), 'showAny')) { + $this->authorizeTo($request, 'showAny'); } } /** * Determine if the resource should be available for the given request. * - * @param \Illuminate\Http\Request $request + * @param \Illuminate\Http\Request $request * @return bool */ - public static function authorizedToViewAny(Request $request) + public static function authorizedToShowAny(Request $request) { if (! static::authorizable()) { return true; } - return method_exists(Gate::getPolicyFor(static::newModel()), 'viewAny') - ? Gate::check('viewAny', get_class(static::newModel())) + return method_exists(Gate::getPolicyFor(static::newModel()), 'showAny') + ? Gate::check('showAny', get_class(static::newModel())) : true; } /** - * Determine if the current user can view the given resource or throw an exception. + * Determine if the current user can view the given resource or throw. * - * @param \Illuminate\Http\Request $request - * @return bool - * - * @throws \Illuminate\Auth\Access\AuthorizationException - * @throws \Throwable + * @param Request $request + * @throws AuthorizationException */ - public function authorizeToView(Request $request) + public function authorizeToShow(Request $request) { - return $this->authorizeTo($request, 'view') && $this->authorizeToViewAny($request); + $this->authorizeTo($request, 'show'); } /** * Determine if the current user can view the given resource. * - * @param \Illuminate\Http\Request $request + * @param Request $request * @return bool */ - public function authorizedToView(Request $request) + public function authorizedToShow(Request $request) { - return $this->authorizedTo($request, 'view') && $this->authorizedToViewAny($request); + return $this->authorizedTo($request, 'show'); } /** * Determine if the current user can create new repositories or throw an exception. * - * @param \Illuminate\Http\Request $request + * @param \Illuminate\Http\Request $request * @return void * * @throws \Illuminate\Auth\Access\AuthorizationException - * @throws \Throwable */ public static function authorizeToCreate(Request $request) { - throw_unless(static::authorizedToCreate($request), AuthorizationException::class, 'Unauthorized to create.'); + if (! static::authorizedToCreate($request)) { + throw new AuthorizationException('Unauthorized to create.'); + } } /** * Determine if the current user can create new repositories. * - * @param \Illuminate\Http\Request $request + * @param \Illuminate\Http\Request $request * @return bool */ public static function authorizedToCreate(Request $request) @@ -128,21 +124,20 @@ public static function authorizedToCreate(Request $request) /** * Determine if the current user can update the given resource or throw an exception. * - * @param \Illuminate\Http\Request $request + * @param \Illuminate\Http\Request $request * @return void * * @throws \Illuminate\Auth\Access\AuthorizationException - * @throws \Throwable */ public function authorizeToUpdate(Request $request) { - return $this->authorizeTo($request, 'update'); + $this->authorizeTo($request, 'update'); } /** * Determine if the current user can update the given resource. * - * @param \Illuminate\Http\Request $request + * @param \Illuminate\Http\Request $request * @return bool */ public function authorizedToUpdate(Request $request) @@ -153,20 +148,20 @@ public function authorizedToUpdate(Request $request) /** * Determine if the current user can delete the given resource or throw an exception. * - * @param \Illuminate\Http\Request $request + * @param \Illuminate\Http\Request $request * @return void * * @throws \Illuminate\Auth\Access\AuthorizationException */ public function authorizeToDelete(Request $request) { - return $this->authorizeTo($request, 'delete'); + $this->authorizeTo($request, 'delete'); } /** * Determine if the current user can delete the given resource. * - * @param \Illuminate\Http\Request $request + * @param \Illuminate\Http\Request $request * @return bool */ public function authorizedToDelete(Request $request) @@ -174,129 +169,27 @@ public function authorizedToDelete(Request $request) return $this->authorizedTo($request, 'delete'); } - /** - * Determine if the current user can restore the given resource. - * - * @param \Illuminate\Http\Request $request - * @return bool - */ - public function authorizedToRestore(Request $request) - { - return $this->authorizedTo($request, 'restore'); - } - - /** - * Determine if the current user can force delete the given resource. - * - * @param \Illuminate\Http\Request $request - * @return bool - */ - public function authorizedToForceDelete(Request $request) - { - return $this->authorizedTo($request, 'forceDelete'); - } - - /** - * Determine if the user can add / associate models of the given type to the resource. - * - * @param Request $request - * @param \Illuminate\Database\Eloquent\Model|string $model - * @return bool - */ - public function authorizedToAdd(Request $request, $model) - { - if (! static::authorizable()) { - return true; - } - - $method = 'add'.class_basename($model); - - return method_exists(Gate::getPolicyFor($this->model()), $method) - ? Gate::check($method, $this->model()) - : true; - } - - /** - * Determine if the user can attach any models of the given type to the resource. - * - * @param Request $request - * @param \Illuminate\Database\Eloquent\Model|string $model - * @return bool - */ - public function authorizedToAttachAny(Request $request, $model) - { - if (! static::authorizable()) { - return true; - } - - $method = 'attachAny'.Str::singular(class_basename($model)); - - return method_exists(Gate::getPolicyFor($this->model()), $method) - ? Gate::check($method, [$this->model()]) - : true; - } - - /** - * Determine if the user can attach models of the given type to the resource. - * - * @param Request $request - * @param \Illuminate\Database\Eloquent\Model|string $model - * @return bool - */ - public function authorizedToAttach(Request $request, $model) - { - if (! static::authorizable()) { - return true; - } - - $method = 'attach'.Str::singular(class_basename($model)); - - return method_exists(Gate::getPolicyFor($this->model()), $method) - ? Gate::check($method, [$this->model(), $model]) - : true; - } - - /** - * Determine if the user can detach models of the given type to the resource. - * - * @param Request $request - * @param \Illuminate\Database\Eloquent\Model|string $model - * @param string $relationship - * @return bool - */ - public function authorizedToDetach(Request $request, $model, $relationship) - { - if (! static::authorizable()) { - return true; - } - - $method = 'detach'.Str::singular(class_basename($model)); - - return method_exists(Gate::getPolicyFor($this->model()), $method) - ? Gate::check($method, [$this->model(), $model]) - : true; - } - /** * Determine if the current user has a given ability. * - * @param \Illuminate\Http\Request $request - * @param string $ability + * @param \Illuminate\Http\Request $request + * @param string $ability * @return void * * @throws \Illuminate\Auth\Access\AuthorizationException - * @throws \Throwable */ public function authorizeTo(Request $request, $ability) { - throw_unless($this->authorizedTo($request, $ability), AuthorizationException::class); + if ($this->authorizedTo($request, $ability) === false) { + throw new AuthorizationException(); + } } /** * Determine if the current user can view the given resource. * - * @param \Illuminate\Http\Request $request - * @param string $ability + * @param \Illuminate\Http\Request $request + * @param string $ability * @return bool */ public function authorizedTo(Request $request, $ability) @@ -305,14 +198,19 @@ public function authorizedTo(Request $request, $ability) } /** - * @return AuthorizableModels|Model|mixed - * @throws \Throwable + * Since this trait could be used by a repository or by a model, we have to + * detect the model from either class. + * + * @return AuthorizableModels|Model|mixed|null + * @throws ModelNotFoundException */ public function determineModel() { $model = $this instanceof Model ? $this : ($this->resource ?? null); - throw_if(is_null($model), new ModelNotFoundException(__('Model is not declared in :class', ['class' => self::class]))); + if (is_null($model)) { + throw new ModelNotFoundException(__('Model is not declared in :class', ['class' => self::class])); + } return $model; } diff --git a/tests/Controllers/RepositoryIndexControllerTest.php b/tests/Controllers/RepositoryIndexControllerTest.php index 6b24fe3bc..2a173b9a6 100644 --- a/tests/Controllers/RepositoryIndexControllerTest.php +++ b/tests/Controllers/RepositoryIndexControllerTest.php @@ -34,7 +34,7 @@ public function test_the_rest_controller_can_paginate() $class = (new class extends RestController { public function users() { - return $this->respond($this->search(User::class)); + return $this->response($this->search(User::class)); } }); @@ -45,7 +45,7 @@ public function users() ]); $this->assertIsArray($class->search(User::class)); $this->assertCount(1, $response['data']); - $this->assertEquals(count($class->users()->getData()->data->data), User::$defaultPerPage); + $this->assertEquals(count($class->users()->getData()->data), User::$defaultPerPage); } public function test_that_default_per_page_works() @@ -56,7 +56,7 @@ public function test_that_default_per_page_works() $class = (new class extends RestController { public function users() { - return $this->respond($this->search(User::class)); + return $this->response($this->search(User::class)); } }); @@ -67,7 +67,7 @@ public function users() ]); $this->assertIsArray($class->search(User::class)); $this->assertCount(1, $response['data']); - $this->assertEquals(count($class->users()->getData()->data->data), 40); + $this->assertEquals(count($class->users()->getData()->data), 40); User::$defaultPerPage = RestifySearchable::DEFAULT_PER_PAGE; } @@ -77,30 +77,32 @@ public function test_search_query_works() $request = Mockery::mock(RestifyRequest::class); $model = $users->where('email', 'eduard.lupacescu@binarcode.com')->first(); //find manually the model $repository = Restify::repositoryForModel(get_class($model)); - $expected = (new $repository($model))->toArray($request); + $expected = $repository::resolveWith($model)->toArray(request()); unset($expected['relationships']); - $this->withExceptionHandling() + $r = $this->withExceptionHandling() ->getJson('/restify-api/users?search=eduard.lupacescu@binarcode.com') ->assertStatus(200) - ->assertJson([ + ->assertJsonStructure([ 'links' => [ - 'last' => 'http://localhost/restify-api/users?page=1', - 'next' => null, - 'first' => 'http://localhost/restify-api/users?page=1', - 'prev' => null, + 'last', + 'next', + 'first', + 'prev', ], 'meta' => [ - 'path' => 'http://localhost/restify-api/users', - 'current_page' => 1, - 'from' => 1, - 'last_page' => 1, - 'per_page' => 15, - 'to' => 1, - 'total' => 1, + 'path', + 'current_page', + 'from', + 'last_page', + 'per_page', + 'to', + 'total', ], - 'data' => [$expected], - ]); + 'data', + ])->decodeResponseJson(); + + $this->assertCount(1, $r['data']); $this->withExceptionHandling() ->getJson('/restify-api/users?search=some_unexpected_string_here') @@ -114,12 +116,12 @@ public function test_search_query_works() ], 'meta' => [ 'current_page' => 1, - 'from' => 1, + 'from' => null, 'last_page' => 1, 'per_page' => 15, - 'to' => 1, + 'to' => null, 'path' => 'http://localhost/restify-api/users', - 'total' => 1, + 'total' => 0, ], 'data' => [], ]); @@ -132,8 +134,8 @@ public function test_that_desc_sort_query_param_works() ->assertStatus(200) ->getOriginalContent(); - $this->assertSame($response->getCollection()->first()->id, 10); - $this->assertSame($response->getCollection()->last()->id, 1); + $this->assertSame($response['data']->first()->resource->id, 10); + $this->assertSame($response['data']->last()->resource->id, 1); } public function test_that_asc_sort_query_param_works() @@ -172,7 +174,8 @@ public function test_that_match_param_works() $model = $users->where('email', 'eduard.lupacescu@binarcode.com')->first(); $repository = Restify::repositoryForModel(get_class($model)); - $expected = (new $repository($model))->toArray($request); + $expected = $repository::resolveWith($model)->toArray($request); + unset($expected['relationships']); $this->withExceptionHandling() ->get('/restify-api/users?email=eduard.lupacescu@binarcode.com') diff --git a/tests/Controllers/RepositoryShowControllerTest.php b/tests/Controllers/RepositoryShowControllerTest.php new file mode 100644 index 000000000..b1e42dc18 --- /dev/null +++ b/tests/Controllers/RepositoryShowControllerTest.php @@ -0,0 +1,33 @@ + + */ +class RepositoryShowControllerTest extends IntegrationTest +{ + protected function setUp(): void + { + parent::setUp(); + $this->authenticate(); + } + + public function test_basic_show() + { + factory(Post::class)->create(['user_id' => 1]); + + $this->withoutExceptionHandling()->get('/restify-api/posts/1') + ->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'id', + 'type', + 'attributes', + ], + ]); + } +} diff --git a/tests/Controllers/RepositoryStoreControllerTest.php b/tests/Controllers/RepositoryStoreControllerTest.php index 1aac732dc..acc205e1a 100644 --- a/tests/Controllers/RepositoryStoreControllerTest.php +++ b/tests/Controllers/RepositoryStoreControllerTest.php @@ -26,8 +26,10 @@ public function test_basic_validation_works() ->assertStatus(400) ->assertJson([ 'errors' => [ - 'description' => [ - 'Description field is required', + [ + 'description' => [ + 'Description field is required', + ], ], ], ]); diff --git a/tests/Fixtures/RepositoryWithRoutes.php b/tests/Fixtures/RepositoryWithRoutes.php deleted file mode 100644 index 8d749ec2f..000000000 --- a/tests/Fixtures/RepositoryWithRoutes.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ -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/Fixtures/UserController.php b/tests/Fixtures/UserController.php index 6ccef829a..a69c739a8 100644 --- a/tests/Fixtures/UserController.php +++ b/tests/Fixtures/UserController.php @@ -20,7 +20,7 @@ public function index() { $users = User::all(); - return $this->respond($users); + return $this->response()->data($users); } /** @@ -31,7 +31,7 @@ public function index() */ public function store(Request $request) { - return $this->respond(factory(User::class)->create()); + return $this->response()->data(factory(User::class)->create()); } /** @@ -49,7 +49,7 @@ public function show($id) $this->gate('access', $user); - return $this->respond($user); + return $this->response()->model($user)->toResponse($this->request()); } /** @@ -93,6 +93,6 @@ public function destroy($id) $this->gate('access', $user); $user->delete(); - return $this->message('User deleted.'); + return $this->message('User deleted.')->toResponse($this->request()); } } diff --git a/tests/Fixtures/UserRepository.php b/tests/Fixtures/UserRepository.php index 397badde6..12f5fe9ed 100644 --- a/tests/Fixtures/UserRepository.php +++ b/tests/Fixtures/UserRepository.php @@ -27,11 +27,4 @@ public function fields(RestifyRequest $request) return [ ]; } - - public function resolveDetailsRelationships($request) - { - return [ - 'posts' => PostRepository::collection($this->whenLoaded('posts')), - ]; - } } diff --git a/tests/HandlerTest.php b/tests/HandlerTest.php index d35b66a94..1def7bac5 100644 --- a/tests/HandlerTest.php +++ b/tests/HandlerTest.php @@ -142,7 +142,7 @@ public function test_default_unhandled_exception_production() { App::shouldReceive('environment') ->times(1) - ->andReturn('production'); + ->andReturnTrue(); $response = $this->handler->render($this->request, new \Exception('Foo')); $this->assertObjectNotHasAttribute('file', $response->getData()); $this->assertObjectNotHasAttribute('line', $response->getData()); diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index eb3dc801d..f3a292b6d 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -5,7 +5,6 @@ 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; @@ -163,7 +162,6 @@ public function loadRepositories() Restify::repositories([ UserRepository::class, PostRepository::class, - RepositoryWithRoutes::class, ]); } diff --git a/tests/RepositoryWithRoutesTest.php b/tests/RepositoryWithRoutesTest.php index b8b8eda6f..53c0f0503 100644 --- a/tests/RepositoryWithRoutesTest.php +++ b/tests/RepositoryWithRoutesTest.php @@ -3,8 +3,8 @@ namespace Binaryk\LaravelRestify\Tests; use Binaryk\LaravelRestify\Controllers\RestController; +use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; -use Binaryk\LaravelRestify\Tests\Fixtures\RepositoryWithRoutes; use Illuminate\Routing\Router; /** @@ -17,6 +17,7 @@ protected function setUp(): void $this->loadRepositories(); Restify::repositories([ + RepositoryWithRoutes::class, WithCustomPrefix::class, WithCustomMiddleware::class, WithCustomNamespace::class, @@ -27,12 +28,12 @@ protected function setUp(): void public function test_can_add_custom_routes() { - $this->get(Restify::path(RepositoryWithRoutes::uriKey()).'/testing')->assertStatus(200) + $this->get(Restify::path(RepositoryWithRoutes::uriKey()).'/main-testing')->assertStatus(200) ->assertJson([ 'success' => true, ]); - $this->get(route('testing.route'))->assertStatus(200) + $this->get(route('main.testing.route'))->assertStatus(200) ->assertJson([ 'success' => true, ]); @@ -40,7 +41,7 @@ public function test_can_add_custom_routes() public function test_can_use_custom_prefix() { - $this->get('/custom-prefix/testing')->assertStatus(200) + $this->withoutExceptionHandling()->get('/custom-prefix/testing')->assertStatus(200) ->assertJson([ 'success' => true, ]); @@ -63,15 +64,42 @@ public function test_can_use_custom_namespace() } } +class RepositoryWithRoutes extends Repository +{ + /** + * @param Router $router + * @param array $attributes + */ + public static function routes(Router $router, $attributes) + { + $router->group($attributes, function ($router) { + $router->get('/main-testing', function () { + return response()->json([ + 'success' => true, + ]); + })->name('main.testing.route'); + }); + } + + public static function uriKey() + { + return 'posts'; + } +} + class WithCustomPrefix extends RepositoryWithRoutes { - public static function routes(Router $router, $options = ['prefix' => 'custom-prefix']) + public static function routes(Router $router, $attributes) { - $router->get('testing', function () { - return response()->json([ - 'success' => true, - ]); - })->name('custom.testing.route'); + $attributes['prefix'] = 'custom-prefix'; + + $router->group($attributes, function ($router) { + $router->get('testing', function () { + return response()->json([ + 'success' => true, + ]); + })->name('custom.testing.route'); + }); } } @@ -87,23 +115,29 @@ public function handle($request, $next) class WithCustomMiddleware extends RepositoryWithRoutes { - public static function routes(Router $router, $options = ['middleware' => [MiddlewareFail::class]]) + public static function routes(Router $router, $options) { - $router->get('with-middleware', function () { - return response()->json([ - 'success' => true, - ]); - })->name('middleware.failing.route'); + $options['middleware'] = [MiddlewareFail::class]; + + $router->group($options, function ($router) { + $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', - ]) + public static function routes(Router $router, $options) { - $router->get('custom-namespace', 'HandleController@sayHello')->name('namespace.route'); + $options['namespace'] = 'Binaryk\LaravelRestify\Tests'; + + $router->group($options, function ($router) { + $router->get('custom-namespace', 'HandleController@sayHello')->name('namespace.route'); + }); } } diff --git a/tests/RestControllerTest.php b/tests/RestControllerTest.php index 50796c1cc..c3673301d 100644 --- a/tests/RestControllerTest.php +++ b/tests/RestControllerTest.php @@ -80,7 +80,7 @@ public function test_gate_allow_access() $this->assertTrue($this->controller->gate('access', $user)); $response = $this->controller->show($user->id); - $this->assertEquals($user->email, data_get($response->getData(), 'data.email')); + $this->assertEquals($user->email, data_get($response->getData(), 'data.attributes.email')); } public function test_making_custom_response()