From 888eb12771478c874e645dd703cb60ffebc4eed2 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Tue, 16 Nov 2021 17:45:32 +0200 Subject: [PATCH 1/5] Revert "Clean auth services. (#427)" (#432) This reverts commit a0eb60366e9bb23ccd51e054074e782e7a4ae97a. --- src/Http/Controllers/AuthController.php | 42 ++++ src/Services/AuthService.php | 121 ++++++++++++ src/Services/Concerns/AuthenticatesUsers.php | 172 ++++++++++++++++ src/Services/Concerns/ResetsPasswords.php | 178 +++++++++++++++++ src/Services/Concerns/ThrottlesLogins.php | 120 ++++++++++++ src/Services/ForgotPasswordService.php | 80 ++++++++ src/Services/LoginService.php | 45 +++++ src/Services/LogoutService.php | 28 +++ src/Services/RegisterService.php | 93 +++++++++ src/Services/ResetPasswordService.php | 26 +++ src/Services/RestifyService.php | 10 + .../AuthServiceForgotPasswordTest.php | 183 ++++++++++++++++++ .../AuthServiceRegisterTest.php | 143 ++++++++++++++ .../ResetPasswordRequestTest.php | 39 ++++ .../RestifyLoginRequestTest.php | 38 ++++ .../RestifyPasswordEmailRequestTest.php | 37 ++++ 16 files changed, 1355 insertions(+) create mode 100644 src/Http/Controllers/AuthController.php create mode 100644 src/Services/AuthService.php create mode 100644 src/Services/Concerns/AuthenticatesUsers.php create mode 100644 src/Services/Concerns/ResetsPasswords.php create mode 100644 src/Services/Concerns/ThrottlesLogins.php create mode 100644 src/Services/ForgotPasswordService.php create mode 100644 src/Services/LoginService.php create mode 100644 src/Services/LogoutService.php create mode 100644 src/Services/RegisterService.php create mode 100644 src/Services/ResetPasswordService.php create mode 100644 src/Services/RestifyService.php create mode 100644 tests/Feature/Authentication/AuthServiceForgotPasswordTest.php create mode 100644 tests/Feature/Authentication/AuthServiceRegisterTest.php create mode 100644 tests/Feature/Authentication/ResetPasswordRequestTest.php create mode 100644 tests/Feature/Authentication/RestifyLoginRequestTest.php create mode 100644 tests/Feature/Authentication/RestifyPasswordEmailRequestTest.php diff --git a/src/Http/Controllers/AuthController.php b/src/Http/Controllers/AuthController.php new file mode 100644 index 000000000..d5b0b448f --- /dev/null +++ b/src/Http/Controllers/AuthController.php @@ -0,0 +1,42 @@ +authService = $authService; + } + + public function login(Request $request) + { + return $this->authService->login($request); + } + + public function register(Request $request) + { + return $this->authService->register($request); + } + + public function verify(Request $request, $id, $hash = null) + { + return $this->authService->verify($request, $id, $hash); + } + + public function forgotPassword(Request $request) + { + return $this->authService->forgotPassword($request); + } + + public function resetPassword(Request $request) + { + return $this->authService->resetPassword($request); + } +} diff --git a/src/Services/AuthService.php b/src/Services/AuthService.php new file mode 100644 index 000000000..24871994d --- /dev/null +++ b/src/Services/AuthService.php @@ -0,0 +1,121 @@ +userQuery()->query()->findOrFail($id); + + if ($user instanceof Sanctumable && ! hash_equals((string) $hash, sha1($user->getEmailForVerification()))) { + throw new AuthorizationException('Invalid hash'); + } + + if ($user instanceof MustVerifyEmail && $user->markEmailAsVerified()) { + event(new Verified($user)); + } + + return $user; + } + + public function resetPassword(Request $request) + { + return ResetPasswordService::make($request, $this); + } + + /** + * @return PasswordBroker + */ + public function broker() + { + return Password::broker(); + } + + /** + * Returns query for User model and validate if it exists. + * + * @return Model + * @throws SanctumUserException + * @throws EntityNotFoundException + */ + public function userQuery() + { + $userClass = Config::get('auth.providers.users.model'); + + try { + $container = Container::getInstance(); + $userInstance = $container->make($userClass); + $this->validateUserModel($userInstance); + + return $userInstance; + } catch (BindingResolutionException $e) { + throw new EntityNotFoundException("The model $userClass from he follow configuration -> 'auth.providers.users.model' cannot be instantiated (may be an abstract class).", $e->getCode(), $e); + } catch (ReflectionException $e) { + throw new EntityNotFoundException("The model from the follow configuration -> 'auth.providers.users.model' doesn't exists.", $e->getCode(), $e); + } + } + + /** + * @param $userInstance + * @throws SanctumUserException + */ + public function validateUserModel($userInstance) + { + if (config('restify.auth.provider') === 'sanctum' && false === $userInstance instanceof Sanctumable) { + throw new SanctumUserException(__("User is not implementing Binaryk\LaravelRestify\Contracts\Sanctumable contract. User should use 'Laravel\Sanctum\HasApiTokens' trait to provide")); + } + } + + public function logout(Request $request) + { + return LogoutService::make($request); + } +} diff --git a/src/Services/Concerns/AuthenticatesUsers.php b/src/Services/Concerns/AuthenticatesUsers.php new file mode 100644 index 000000000..98d42249e --- /dev/null +++ b/src/Services/Concerns/AuthenticatesUsers.php @@ -0,0 +1,172 @@ +redirectTo(); + } + + return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; + } + + /** + * Handle a login request to the application. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response + */ + public function login(Request $request) + { + $this->validateLogin($request); + + // If the class is using the ThrottlesLogins trait, we can automatically throttle + // the login attempts for this application. We'll key this by the username and + // the IP address of the client making these requests into this application. + if ($this->hasTooManyLoginAttempts($request)) { + $this->fireLockoutEvent($request); + + return $this->sendLockoutResponse($request); + } + + if ($this->attemptLogin($request)) { + return $this->sendLoginResponse($request); + } + + // If the login attempt was unsuccessful we will increment the number of attempts + // to login and redirect the user back to the login form. Of course, when this + // user surpasses their maximum number of attempts they will get locked out. + $this->incrementLoginAttempts($request); + + return $this->sendFailedLoginResponse($request); + } + + /** + * Validate the user login request. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function validateLogin(Request $request) + { + $request->validate([ + $this->username() => 'required|string', + 'password' => 'required|string', + ]); + } + + /** + * Attempt to log the user into the application. + * + * @param \Illuminate\Http\Request $request + * @return bool + */ + protected function attemptLogin(Request $request) + { + return $this->guard()->attempt( + $this->credentials($request), + $request->has('remember') + ); + } + + /** + * Get the needed authorization credentials from the request. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function credentials(Request $request) + { + return $request->only($this->username(), 'password'); + } + + /** + * Send the response after the user was authenticated. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Response + */ + protected function sendLoginResponse(Request $request) + { + $request->session()->regenerate(); + + $this->clearLoginAttempts($request); + + return $this->authenticated($request, $this->guard()->user()) + ?: redirect()->intended($this->redirectPath()); + } + + /** + * The user has been authenticated. + * + * @param \Illuminate\Http\Request $request + * @param mixed $user + * @return mixed + */ + protected function authenticated(Request $request, $user) + { + // + } + + /** + * Get the failed login response instance. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse + */ + protected function sendFailedLoginResponse(Request $request) + { + throw ValidationException::withMessages([ + $this->username() => [trans('auth.failed')], + ]); + } + + /** + * Get the login username to be used by the controller. + * + * @return string + */ + public function username() + { + return 'email'; + } + + /** + * Log the user out of the application. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Response + */ + public function logout(Request $request) + { + $this->guard()->logout(); + + $request->session()->invalidate(); + + return redirect('/'); + } + + /** + * Get the guard to be used during authentication. + * + * @return \Illuminate\Contracts\Auth\StatefulGuard + */ + protected function guard() + { + return Auth::guard(); + } +} diff --git a/src/Services/Concerns/ResetsPasswords.php b/src/Services/Concerns/ResetsPasswords.php new file mode 100644 index 000000000..a0ca65825 --- /dev/null +++ b/src/Services/Concerns/ResetsPasswords.php @@ -0,0 +1,178 @@ +redirectTo(); + } + + return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; + } + + /** + * Display the password reset view for the given token. + * + * If no token is present, display the link request form. + * + * @param \Illuminate\Http\Request $request + * @param string|null $token + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function showResetForm(Request $request, $token = null) + { + return view('auth.passwords.reset')->with( + ['token' => $token, 'email' => $request->email] + ); + } + + /** + * Reset the given user's password. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function reset(Request $request) + { + $request->validate($this->rules(), $this->validationErrorMessages()); + + // Here we will attempt to reset the user's password. If it is successful we + // will update the password on an actual user model and persist it to the + // database. Otherwise we will parse the error and return the response. + $response = $this->broker()->reset( + $this->credentials($request), + function ($user, $password) { + $this->resetPassword($user, $password); + } + ); + + // If the password was successfully reset, we will redirect the user back to + // the application's home authenticated view. If there is an error we can + // redirect them back to where they came from with their error message. + return $response == Password::PASSWORD_RESET + ? $this->sendResetResponse($response) + : $this->sendResetFailedResponse($request, $response); + } + + /** + * Get the password reset validation rules. + * + * @return array + */ + protected function rules() + { + return [ + 'token' => 'required', + 'email' => 'required|email', + 'password' => 'required|confirmed|min:6', + ]; + } + + /** + * Get the password reset validation error messages. + * + * @return array + */ + protected function validationErrorMessages() + { + return []; + } + + /** + * Get the password reset credentials from the request. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function credentials(Request $request) + { + return $request->only( + 'email', + 'password', + 'password_confirmation', + 'token' + ); + } + + /** + * Reset the given user's password. + * + * @param \Illuminate\Contracts\Auth\CanResetPassword $user + * @param string $password + * @return void + */ + protected function resetPassword($user, $password) + { + $user->password = Hash::make($password); + + $user->setRememberToken(Str::random(60)); + + $user->save(); + + event(new PasswordReset($user)); + + $this->guard()->login($user); + } + + /** + * Get the response for a successful password reset. + * + * @param string $response + * @return \Illuminate\Http\RedirectResponse + */ + protected function sendResetResponse($response) + { + return response()->json([ + 'status' => trans($response), + ]); + } + + /** + * Get the response for a failed password reset. + * + * @param \Illuminate\Http\Request + * @param string $response + * @return \Illuminate\Http\RedirectResponse + */ + protected function sendResetFailedResponse(Request $request, $response) + { + return response()->json(['email' => [ + trans($response), + ]])->setStatusCode(400); + } + + /** + * Get the broker to be used during password reset. + * + * @return \Illuminate\Contracts\Auth\PasswordBroker + */ + public function broker() + { + return Password::broker(); + } + + /** + * Get the guard to be used during password reset. + * + * @return \Illuminate\Contracts\Auth\StatefulGuard + */ + protected function guard() + { + return Auth::guard(); + } +} diff --git a/src/Services/Concerns/ThrottlesLogins.php b/src/Services/Concerns/ThrottlesLogins.php new file mode 100644 index 000000000..6ff6de986 --- /dev/null +++ b/src/Services/Concerns/ThrottlesLogins.php @@ -0,0 +1,120 @@ +limiter()->tooManyAttempts( + $this->throttleKey($request), + $this->maxAttempts(), + $this->decayMinutes() + ); + } + + /** + * Increment the login attempts for the user. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function incrementLoginAttempts(Request $request) + { + $this->limiter()->hit($this->throttleKey($request)); + } + + /** + * Redirect the user after determining they are locked out. + * + * @param \Illuminate\Http\Request $request + * @return void + * @throws \Illuminate\Validation\ValidationException + */ + protected function sendLockoutResponse(Request $request) + { + $seconds = $this->limiter()->availableIn( + $this->throttleKey($request) + ); + + throw ValidationException::withMessages([ + $this->username() => [Lang::get('auth.throttle', ['seconds' => $seconds])], + ])->status(423); + } + + /** + * Clear the login locks for the given user credentials. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function clearLoginAttempts(Request $request) + { + $this->limiter()->clear($this->throttleKey($request)); + } + + /** + * Fire an event when a lockout occurs. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function fireLockoutEvent(Request $request) + { + event(new Lockout($request)); + } + + /** + * Get the throttle key for the given request. + * + * @param \Illuminate\Http\Request $request + * @return string + */ + protected function throttleKey(Request $request) + { + return Str::lower($request->input($this->username())).'|'.$request->ip(); + } + + /** + * Get the rate limiter instance. + * + * @return \Illuminate\Cache\RateLimiter + */ + protected function limiter() + { + return app(RateLimiter::class); + } + + /** + * Get the maximum number of attempts to allow. + * + * @return int + */ + public function maxAttempts() + { + return property_exists($this, 'maxAttempts') ? $this->maxAttempts : 5; + } + + /** + * Get the number of minutes to throttle for. + * + * @return int + */ + public function decayMinutes() + { + return property_exists($this, 'decayMinutes') ? $this->decayMinutes : 1; + } +} diff --git a/src/Services/ForgotPasswordService.php b/src/Services/ForgotPasswordService.php new file mode 100644 index 000000000..c324d091f --- /dev/null +++ b/src/Services/ForgotPasswordService.php @@ -0,0 +1,80 @@ +getEmailForPasswordReset(), $withToken); + + return url($withEmail); + }); + + return resolve(static::class)->sendResetLinkEmail($request); + } + + /** + * Send a reset link to the given user. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function sendResetLinkEmail(Request $request) + { + $request->validate(['email' => 'required|email']); + + // We will send the password reset link to this user. Once we have attempted + // to send the link, we will examine the response then see the message we + // need to show to the user. Finally, we'll send out a proper response. + $response = $this->broker()->sendResetLink( + $request->only('email') + ); + + return $response == Password::RESET_LINK_SENT + ? $this->sendResetLinkResponse($response) + : $this->sendResetLinkFailedResponse($request, $response); + } + + public function broker() + { + return Password::broker(); + } + + /** + * Get the response for a successful password reset link. + * + * @param string $response + * @return \Illuminate\Http\RedirectResponse + */ + protected function sendResetLinkResponse($response) + { + return response()->json([ + 'status' => trans($response), + ]); + } + + /** + * Get the response for a failed password reset link. + * + * @param \Illuminate\Http\Request + * @param string $response + * @return \Illuminate\Http\RedirectResponse + */ + protected function sendResetLinkFailedResponse(Request $request, $response) + { + return response()->json([ + 'errors' => [ + 'email' => [ + trans($response), + ], + ], + ])->setStatusCode(400); + } +} diff --git a/src/Services/LoginService.php b/src/Services/LoginService.php new file mode 100644 index 000000000..5d2dd415f --- /dev/null +++ b/src/Services/LoginService.php @@ -0,0 +1,45 @@ +login($request); + } + + public function login(Request $request) + { + $this->validateLogin($request); + + // If the class is using the ThrottlesLogins trait, we can automatically throttle + // the login attempts for this application. We'll key this by the username and + // the IP address of the client making these requests into this application. + if (method_exists($this, 'hasTooManyLoginAttempts') && + $this->hasTooManyLoginAttempts($request)) { + $this->fireLockoutEvent($request); + + return $this->sendLockoutResponse($request); + } + + if ($this->attemptLogin($request)) { + event(new UserLoggedIn($this->guard()->user())); + + return $this->guard()->user()->createToken('login'); + } + + // If the login attempt was unsuccessful we will increment the number of attempts + // to login and redirect the user back to the login form. Of course, when this + // user surpasses their maximum number of attempts they will get locked out. + $this->incrementLoginAttempts($request); + + return $this->sendFailedLoginResponse($request); + } +} diff --git a/src/Services/LogoutService.php b/src/Services/LogoutService.php new file mode 100644 index 000000000..a9d68b3a4 --- /dev/null +++ b/src/Services/LogoutService.php @@ -0,0 +1,28 @@ +logout($request); + } + + throw new AuthenticatableUserException(__('User is not authenticated.')); + } +} diff --git a/src/Services/RegisterService.php b/src/Services/RegisterService.php new file mode 100644 index 000000000..52a657f44 --- /dev/null +++ b/src/Services/RegisterService.php @@ -0,0 +1,93 @@ +all(); + + $this->validateRegister($payload); + + $builder = $this->authService->userQuery(); + + if (false === $builder instanceof Authenticatable) { + throw AuthenticatableUserException::wrongInstance(); + } + + $userData = array_merge($payload, [ + 'password' => Hash::make(data_get($payload, 'password')), + ]); + + if (is_callable(static::$creating)) { + $user = call_user_func(static::$creating, $userData); + } else { + $user = $builder->query()->create($userData); + } + + if ($user instanceof Authenticatable) { + event(new Registered($user)); + } + + return $user; + } + + public static function make(Request $request, AuthService $authService) + { + return resolve(static::class) + ->usingAuthService($authService) + ->register($request); + } + + public function validateRegister(array $payload) + { + try { + if (class_exists(static::$registerFormRequest) && (new \ReflectionClass(static::$registerFormRequest))->isInstantiable()) { + $validator = Validator::make($payload, (new static::$registerFormRequest)->rules(), (new static::$registerFormRequest)->messages()); + if ($validator->fails()) { + throw new ValidationException($validator); + } + } + } catch (ReflectionException $e) { + $concrete = static::$registerFormRequest; + + throw new BindingResolutionException("Target class [$concrete] does not exist.", 0, $e); + } + + return true; + } + + protected function usingAuthService(AuthService $service) + { + $this->authService = $service; + + return $this; + } +} diff --git a/src/Services/ResetPasswordService.php b/src/Services/ResetPasswordService.php new file mode 100644 index 000000000..0bbf5acd9 --- /dev/null +++ b/src/Services/ResetPasswordService.php @@ -0,0 +1,26 @@ +reset($request); + } + + protected function usingAuthService(AuthService $authService) + { + $this->authService = $authService; + + return $this; + } +} diff --git a/src/Services/RestifyService.php b/src/Services/RestifyService.php new file mode 100644 index 000000000..c05612bd5 --- /dev/null +++ b/src/Services/RestifyService.php @@ -0,0 +1,10 @@ + + */ +class RestifyService +{ +} diff --git a/tests/Feature/Authentication/AuthServiceForgotPasswordTest.php b/tests/Feature/Authentication/AuthServiceForgotPasswordTest.php new file mode 100644 index 000000000..93cf51a8a --- /dev/null +++ b/tests/Feature/Authentication/AuthServiceForgotPasswordTest.php @@ -0,0 +1,183 @@ + + */ +class AuthServiceForgotPasswordTest extends IntegrationTest +{ + use MailTracking; + use InteractsWithContainer; + + /** + * @var AuthService + */ + protected $authService; + + protected function setUp(): void + { + parent::setUp(); + $this->setUpMailTracking(); + RegisterService::$registerFormRequest = null; + $this->authService = resolve(AuthService::class); + $this->app['config']->set('restify.auth.provider', 'sanctum'); + $this->app['config']->set('restify.auth.frontend_app_url', 'https://laravel-restify.dev'); + $this->app['config']->set('restify.auth.password_reset_url', 'https://laravel-restify.dev/password/reset?token={token}&email={email}'); + } + + public function test_email_was_sent_and_contain_token() + { + Notification::fake(); + + $user = $this->register(); + $request = new Request([], []); + $request->merge(['email' => $user->email]); + + $this->authService->forgotPassword($request); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) { + $this->assertNotEmpty($notification->token); + + return true; + }); + } + + public function test_email_was_sent_and_has_default_or_custom_url_callback() + { + Notification::fake(); + + $user = $this->register(); + $request = new Request([], []); + $request->merge(['email' => $user->email]); + + $this->authService->forgotPassword($request); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $this->assertEquals( + "https://laravel-restify.dev/password/reset?token={$notification->token}&email={$user->email}", + call_user_func($notification::$createUrlCallback, $user, $notification->token), + ); + + return true; + }); + + $this->authService->forgotPassword( + $request, + 'https://subdomain.domain.test/password/reset?token={token}&email={email}', + ); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $this->assertEquals( + "https://subdomain.domain.test/password/reset?token={$notification->token}&email={$user->email}", + call_user_func($notification::$createUrlCallback, $user, $notification->token), + ); + + return true; + }); + } + + public function test_reset_password_invalid_payload() + { + $this->expectException(ValidationException::class); + $request = new Request([], []); + $request->merge([ + 'email' => null, + 'password' => 'password', + 'password_confirmation' => 'password', + 'token' => 'secret', + ]); + $this->authService->resetPassword($request); + } + + public function test_reset_password_successfully() + { + Notification::fake(); + $user = $this->register(); + + $request = new Request([], []); + $request->merge(['email' => $user->email]); + $this->authService->verify($request, $user->id, sha1($user->email)); + + $this->authService->forgotPassword($request); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $token = $notification->token; + $password = Str::random(10); + + $request = new Request([], []); + $request->merge([ + 'email' => $user->email, + 'password' => $password, + 'password_confirmation' => $password, + 'token' => $token, + ]); + + $this->authService->resetPassword($request); + + Event::assertDispatched(PasswordReset::class, function ($e) use ($user) { + $this->assertEquals($e->user->email, $user->email); + + return $e->user instanceof User; + }); + + $request = new Request([], []); + $request->merge([ + 'email' => $user->email, + 'password' => $password, + ]); + + $this->authService->login($request); + + Event::assertDispatched(UserLoggedIn::class, function ($e) use ($user) { + $this->assertEquals($e->user->email, $user->email); + + return $e->user instanceof User; + }); + + return true; + }); + } + + public function register() + { + Event::fake([ + Registered::class, + PasswordReset::class, + UserLoggedIn::class, + ]); + + $this->app->instance(User::class, new User); + + $request = new Request([], []); + + $user = [ + 'name' => 'Eduard Lupacescu', + 'email' => 'eduard.lupacescu@binarcode.com', + 'password' => 'secret!', + 'remember_token' => Str::random(10), + ]; + + $request->merge($user); + + $this->authService->register($request); + + return User::query()->get()->last(); + } +} diff --git a/tests/Feature/Authentication/AuthServiceRegisterTest.php b/tests/Feature/Authentication/AuthServiceRegisterTest.php new file mode 100644 index 000000000..3496cc284 --- /dev/null +++ b/tests/Feature/Authentication/AuthServiceRegisterTest.php @@ -0,0 +1,143 @@ + + */ +class AuthServiceRegisterTest extends IntegrationTest +{ + use InteractsWithContainer; + /** + * @var AuthService + */ + protected $authService; + + protected function setUp(): void + { + parent::setUp(); + $this->authService = resolve(AuthService::class); + } + + public function test_user_query_throw_container_does_not_have_model_reflection_exception() + { + $this->app['config']->set('auth.providers.users.model', null); + $this->expectException(EntityNotFoundException::class); + $this->authService->userQuery(); + } + + public function test_user_query_throw_container_cannot_instantiate_abstract_model() + { + $this->app['config']->set('auth.providers.users.model', LaravelRestifyModel::class); + $this->expectException(EntityNotFoundException::class); + $this->authService->userQuery(); + } + + public function test_register_successfully() + { + Event::fake([ + Registered::class, + ]); + + $this->app->instance(User::class, new User); + + $user = [ + 'name' => 'Eduard Lupacescu', + 'email' => 'eduard.lupacescu@binarcode.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'remember_token' => Str::random(10), + ]; + + $request = new Request([], []); + + $request->merge($user); + + $this->authService->register($request); + + Event::assertDispatched(Registered::class, function ($e) use ($user) { + $this->assertEquals($e->user->email, $user['email']); + + return $e->user instanceof \Binaryk\LaravelRestify\Tests\Fixtures\User\User; + }); + + $lastUser = User::query()->get()->last(); + + $this->assertEquals($lastUser->email, $user['email']); + } + + public function test_verify_user_throw_hash_not_match() + { + Event::fake([ + Registered::class, + ]); + + $this->app->instance(User::class, new User); + + $user = [ + 'name' => 'Eduard Lupacescu', + 'email' => 'eduard.lupacescu@binarcode.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'remember_token' => Str::random(10), + ]; + + $request = new Request([], []); + + $request->merge($user); + + $this->authService->register($request); + $lastUser = User::query()->get()->last(); + + $this->expectException(AuthorizationException::class); + $this->authService->verify($request, $lastUser->id, sha1('random@email.com')); + } + + public function test_verify_user_successfully() + { + Event::fake([ + Verified::class, + Registered::class, + ]); + + $this->app->instance(User::class, new User); + + $user = [ + 'name' => 'Eduard Lupacescu', + 'email' => 'eduard.lupacescu@binarcode.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'remember_token' => Str::random(10), + ]; + + $request = new Request([], []); + + $request->merge($user); + + $this->authService->register($request); + $lastUser = User::query()->get()->last(); + + $this->assertNull($lastUser->email_verified_at); + $this->authService->verify($request, $lastUser->id, sha1('eduard.lupacescu@binarcode.com')); + $lastUser->refresh(); + $this->assertNotNull($lastUser->email_verified_at); + Event::assertDispatched(Verified::class, function ($e) use ($user) { + $this->assertEquals($e->user->email, $user['email']); + + return $e->user instanceof \Binaryk\LaravelRestify\Tests\Fixtures\User\User; + }); + } +} diff --git a/tests/Feature/Authentication/ResetPasswordRequestTest.php b/tests/Feature/Authentication/ResetPasswordRequestTest.php new file mode 100644 index 000000000..97552355c --- /dev/null +++ b/tests/Feature/Authentication/ResetPasswordRequestTest.php @@ -0,0 +1,39 @@ + + */ +class ResetPasswordRequestTest extends IntegrationTest +{ + /** @var ResetPasswordRequest */ + private $subject; + + protected function setUp(): void + { + parent::setUp(); + + $this->subject = new ResetPasswordRequest; + } + + public function testRules() + { + $this->assertEquals( + [ + 'token' => 'required', + 'email' => 'required|email', + 'password' => 'required|confirmed|min:8', + ], + $this->subject->rules() + ); + } + + public function testAuthorize() + { + $this->assertTrue($this->subject->authorize()); + } +} diff --git a/tests/Feature/Authentication/RestifyLoginRequestTest.php b/tests/Feature/Authentication/RestifyLoginRequestTest.php new file mode 100644 index 000000000..1d609a851 --- /dev/null +++ b/tests/Feature/Authentication/RestifyLoginRequestTest.php @@ -0,0 +1,38 @@ + + */ +class RestifyLoginRequestTest extends IntegrationTest +{ + /** @var RestifyLoginRequest */ + private $subject; + + protected function setUp(): void + { + parent::setUp(); + + $this->subject = new RestifyLoginRequest; + } + + public function testRules() + { + $this->assertEquals( + [ + 'email' => 'required|email', + 'password' => 'required|min:6', + ], + $this->subject->rules() + ); + } + + public function testAuthorize() + { + $this->assertTrue($this->subject->authorize()); + } +} diff --git a/tests/Feature/Authentication/RestifyPasswordEmailRequestTest.php b/tests/Feature/Authentication/RestifyPasswordEmailRequestTest.php new file mode 100644 index 000000000..600767d16 --- /dev/null +++ b/tests/Feature/Authentication/RestifyPasswordEmailRequestTest.php @@ -0,0 +1,37 @@ + + */ +class RestifyPasswordEmailRequestTest extends IntegrationTest +{ + /** @var RestifyPasswordEmailRequest */ + private $subject; + + protected function setUp(): void + { + parent::setUp(); + + $this->subject = new RestifyPasswordEmailRequest; + } + + public function testRules() + { + $this->assertEquals( + [ + 'email' => 'required|email', + ], + $this->subject->rules() + ); + } + + public function testAuthorize() + { + $this->assertTrue($this->subject->authorize()); + } +} From 648f424b9fb38225b74addd77b4fcb9cd9cfe60c Mon Sep 17 00:00:00 2001 From: Diana Sindrilaru <87819215+dsindrilaru@users.noreply.github.com> Date: Wed, 17 Nov 2021 13:19:45 +0200 Subject: [PATCH 2/5] Use actionable fields in bulk requests (#433) * Use actionable fields in bulk requests * Add tests for actionable fields on bulk requests --- src/Actions/Action.php | 2 +- src/Fields/FieldCollection.php | 11 ++-- src/Repositories/Repository.php | 16 +++++ tests/Actions/FieldActionTest.php | 106 ++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/Actions/Action.php b/src/Actions/Action.php index 84c4607c5..f551d559d 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -22,7 +22,7 @@ /** * Class Action - * @method JsonResponse handle(Request $request, Model|Collection $models) + * @method JsonResponse handle(Request $request, Model|Collection $models, ?int $row) * @package Binaryk\LaravelRestify\Actions */ abstract class Action implements JsonSerializable diff --git a/src/Fields/FieldCollection.php b/src/Fields/FieldCollection.php index 83ae188ad..07f4d081f 100644 --- a/src/Fields/FieldCollection.php +++ b/src/Fields/FieldCollection.php @@ -78,10 +78,10 @@ public function forStore(RestifyRequest $request, $repository): self })->values(); } - public function withActions(RestifyRequest $request, $repository): self + public function withActions(RestifyRequest $request, $repository, $row = null): self { return $this - ->inRequest($request) + ->inRequest($request, $row) ->filter(fn (Field $field) => $field->isActionable()) ->values(); } @@ -154,10 +154,13 @@ public function findFieldByAttribute($attribute, $default = null) return null; } - public function inRequest(RestifyRequest $request): self + public function inRequest(RestifyRequest $request, $row = null): self { return $this - ->filter(fn (Field $field) => $request->has($field->attribute) || $request->hasFile($field->attribute)) + ->filter(fn (Field $field) => + $request->hasAny($field->attribute, $row.'.'.$field->attribute) + || $request->hasFile($field->attribute) + ) ->values(); } } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index ccbfc3268..203bc1020 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -672,6 +672,7 @@ public function storeBulk(RepositoryStoreBulkRequest $request) $this->resource, $fields = $this->collectFields($request) ->forStoreBulk($request, $this) + ->withoutActions($request, $this) ->authorizedUpdateBulk($request), $row ); @@ -680,6 +681,13 @@ public function storeBulk(RepositoryStoreBulkRequest $request) $fields->each(fn (Field $field) => $field->invokeAfter($request, $this->resource)); + $this + ->collectFields($request) + ->forStoreBulk($request, $this) + ->withActions($request, $this, $row) + ->authorizedUpdateBulk($request) + ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource, $row)); + return $this->resource; }); }); @@ -764,12 +772,20 @@ public function updateBulk(RestifyRequest $request, $repositoryId, int $row) { $fields = $this->collectFields($request) ->forUpdateBulk($request, $this) + ->withoutActions($request, $this) ->authorizedUpdateBulk($request); static::fillBulkFields($request, $this->resource, $fields, $row); $this->resource->save(); + $this + ->collectFields($request) + ->forUpdateBulk($request, $this) + ->withActions($request, $this, $row) + ->authorizedUpdateBulk($request) + ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource, $row)); + static::updatedBulk($this->resource, $request); return response()->json(); diff --git a/tests/Actions/FieldActionTest.php b/tests/Actions/FieldActionTest.php index 913e9b00f..99d7d1499 100644 --- a/tests/Actions/FieldActionTest.php +++ b/tests/Actions/FieldActionTest.php @@ -49,4 +49,110 @@ public function handle(RestifyRequest $request, Post $post) ->etc() ); } + + /** @test */ + public function can_use_actionable_field_on_bulk_store(): void + { + $action = new class extends Action { + public bool $showOnShow = true; + + public function handle(RestifyRequest $request, Post $post, int $row) + { + $description = data_get($request[$row], 'description'); + + $post->update([ + 'description' => 'Actionable ' . $description, + ]); + } + }; + + PostRepository::partialMock() + ->shouldReceive('fieldsForStoreBulk') + ->andreturn([ + Field::new('title'), + + Field::new('description')->action($action), + ]); + + $this + ->withoutExceptionHandling() + ->postJson(PostRepository::to('bulk'), [ + [ + 'title' => $title1 = 'First title', + 'description' => 'first description', + ], + [ + 'title' => $title2 = 'Second title', + 'description' => 'second description', + ], + ]) + ->assertJson( + fn (AssertableJson $json) => $json + ->where('data.0.title', $title1) + ->where('data.0.description', 'Actionable first description') + ->where('data.1.title', $title2) + ->where('data.1.description', 'Actionable second description') + ->etc() + ); + } + + /** @test */ + public function can_use_actionable_field_on_bulk_update(): void + { + $action = new class extends Action { + public bool $showOnShow = true; + + public function handle(RestifyRequest $request, Post $post, int $row) + { + $description = data_get($request[$row], 'description'); + + $post->update([ + 'description' => 'Actionable ' . $description, + ]); + } + }; + + PostRepository::partialMock() + ->shouldReceive('fieldsForUpdateBulk') + ->andreturn([ + Field::new('title'), + + Field::new('description')->action($action), + ]); + + $postId1 = $this + ->withoutExceptionHandling() + ->postJson(PostRepository::to(), [ + 'title' => 'First title', + ])->json('data.id'); + + $postId2 = $this + ->withoutExceptionHandling() + ->postJson(PostRepository::to(), [ + 'title' => 'Second title', + ])->json('data.id'); + + $this + ->withoutExceptionHandling() + ->postJson(PostRepository::to('bulk/update'), [ + [ + 'id' => $postId1, + 'description' => 'first description', + ], + [ + 'id' => $postId2, + 'description' => 'second description', + ], + ])->assertOk(); + + $this->assertSame( + 'Actionable first description', + Post::find($postId1)->description + ); + + $this->assertSame( + 'Actionable second description', + Post::find($postId2)->description + ); + } } From 7534c1d113e1f764c4dccdd0b87fb6a8b0010a00 Mon Sep 17 00:00:00 2001 From: binaryk Date: Wed, 17 Nov 2021 11:20:05 +0000 Subject: [PATCH 3/5] Fix styling --- src/Fields/FieldCollection.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Fields/FieldCollection.php b/src/Fields/FieldCollection.php index 07f4d081f..baf964a51 100644 --- a/src/Fields/FieldCollection.php +++ b/src/Fields/FieldCollection.php @@ -157,7 +157,8 @@ public function findFieldByAttribute($attribute, $default = null) public function inRequest(RestifyRequest $request, $row = null): self { return $this - ->filter(fn (Field $field) => + ->filter( + fn (Field $field) => $request->hasAny($field->attribute, $row.'.'.$field->attribute) || $request->hasFile($field->attribute) ) From 6cc16d901b147beb1d666f7d2a974473e8b61cba Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Wed, 17 Nov 2021 13:23:24 +0200 Subject: [PATCH 4/5] fix: wip --- src/Http/Controllers/AuthController.php | 42 ---- src/Services/AuthService.php | 121 ------------ src/Services/Concerns/AuthenticatesUsers.php | 172 ---------------- src/Services/Concerns/ResetsPasswords.php | 178 ----------------- src/Services/Concerns/ThrottlesLogins.php | 120 ------------ src/Services/ForgotPasswordService.php | 80 -------- src/Services/LoginService.php | 45 ----- src/Services/LogoutService.php | 28 --- src/Services/RegisterService.php | 93 --------- src/Services/ResetPasswordService.php | 26 --- src/Services/RestifyService.php | 10 - .../AuthServiceForgotPasswordTest.php | 183 ------------------ .../AuthServiceRegisterTest.php | 143 -------------- .../ResetPasswordRequestTest.php | 39 ---- .../RestifyLoginRequestTest.php | 38 ---- .../RestifyPasswordEmailRequestTest.php | 37 ---- 16 files changed, 1355 deletions(-) delete mode 100644 src/Http/Controllers/AuthController.php delete mode 100644 src/Services/AuthService.php delete mode 100644 src/Services/Concerns/AuthenticatesUsers.php delete mode 100644 src/Services/Concerns/ResetsPasswords.php delete mode 100644 src/Services/Concerns/ThrottlesLogins.php delete mode 100644 src/Services/ForgotPasswordService.php delete mode 100644 src/Services/LoginService.php delete mode 100644 src/Services/LogoutService.php delete mode 100644 src/Services/RegisterService.php delete mode 100644 src/Services/ResetPasswordService.php delete mode 100644 src/Services/RestifyService.php delete mode 100644 tests/Feature/Authentication/AuthServiceForgotPasswordTest.php delete mode 100644 tests/Feature/Authentication/AuthServiceRegisterTest.php delete mode 100644 tests/Feature/Authentication/ResetPasswordRequestTest.php delete mode 100644 tests/Feature/Authentication/RestifyLoginRequestTest.php delete mode 100644 tests/Feature/Authentication/RestifyPasswordEmailRequestTest.php diff --git a/src/Http/Controllers/AuthController.php b/src/Http/Controllers/AuthController.php deleted file mode 100644 index d5b0b448f..000000000 --- a/src/Http/Controllers/AuthController.php +++ /dev/null @@ -1,42 +0,0 @@ -authService = $authService; - } - - public function login(Request $request) - { - return $this->authService->login($request); - } - - public function register(Request $request) - { - return $this->authService->register($request); - } - - public function verify(Request $request, $id, $hash = null) - { - return $this->authService->verify($request, $id, $hash); - } - - public function forgotPassword(Request $request) - { - return $this->authService->forgotPassword($request); - } - - public function resetPassword(Request $request) - { - return $this->authService->resetPassword($request); - } -} diff --git a/src/Services/AuthService.php b/src/Services/AuthService.php deleted file mode 100644 index 24871994d..000000000 --- a/src/Services/AuthService.php +++ /dev/null @@ -1,121 +0,0 @@ -userQuery()->query()->findOrFail($id); - - if ($user instanceof Sanctumable && ! hash_equals((string) $hash, sha1($user->getEmailForVerification()))) { - throw new AuthorizationException('Invalid hash'); - } - - if ($user instanceof MustVerifyEmail && $user->markEmailAsVerified()) { - event(new Verified($user)); - } - - return $user; - } - - public function resetPassword(Request $request) - { - return ResetPasswordService::make($request, $this); - } - - /** - * @return PasswordBroker - */ - public function broker() - { - return Password::broker(); - } - - /** - * Returns query for User model and validate if it exists. - * - * @return Model - * @throws SanctumUserException - * @throws EntityNotFoundException - */ - public function userQuery() - { - $userClass = Config::get('auth.providers.users.model'); - - try { - $container = Container::getInstance(); - $userInstance = $container->make($userClass); - $this->validateUserModel($userInstance); - - return $userInstance; - } catch (BindingResolutionException $e) { - throw new EntityNotFoundException("The model $userClass from he follow configuration -> 'auth.providers.users.model' cannot be instantiated (may be an abstract class).", $e->getCode(), $e); - } catch (ReflectionException $e) { - throw new EntityNotFoundException("The model from the follow configuration -> 'auth.providers.users.model' doesn't exists.", $e->getCode(), $e); - } - } - - /** - * @param $userInstance - * @throws SanctumUserException - */ - public function validateUserModel($userInstance) - { - if (config('restify.auth.provider') === 'sanctum' && false === $userInstance instanceof Sanctumable) { - throw new SanctumUserException(__("User is not implementing Binaryk\LaravelRestify\Contracts\Sanctumable contract. User should use 'Laravel\Sanctum\HasApiTokens' trait to provide")); - } - } - - public function logout(Request $request) - { - return LogoutService::make($request); - } -} diff --git a/src/Services/Concerns/AuthenticatesUsers.php b/src/Services/Concerns/AuthenticatesUsers.php deleted file mode 100644 index 98d42249e..000000000 --- a/src/Services/Concerns/AuthenticatesUsers.php +++ /dev/null @@ -1,172 +0,0 @@ -redirectTo(); - } - - return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; - } - - /** - * Handle a login request to the application. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response - */ - public function login(Request $request) - { - $this->validateLogin($request); - - // If the class is using the ThrottlesLogins trait, we can automatically throttle - // the login attempts for this application. We'll key this by the username and - // the IP address of the client making these requests into this application. - if ($this->hasTooManyLoginAttempts($request)) { - $this->fireLockoutEvent($request); - - return $this->sendLockoutResponse($request); - } - - if ($this->attemptLogin($request)) { - return $this->sendLoginResponse($request); - } - - // If the login attempt was unsuccessful we will increment the number of attempts - // to login and redirect the user back to the login form. Of course, when this - // user surpasses their maximum number of attempts they will get locked out. - $this->incrementLoginAttempts($request); - - return $this->sendFailedLoginResponse($request); - } - - /** - * Validate the user login request. - * - * @param \Illuminate\Http\Request $request - * @return void - */ - protected function validateLogin(Request $request) - { - $request->validate([ - $this->username() => 'required|string', - 'password' => 'required|string', - ]); - } - - /** - * Attempt to log the user into the application. - * - * @param \Illuminate\Http\Request $request - * @return bool - */ - protected function attemptLogin(Request $request) - { - return $this->guard()->attempt( - $this->credentials($request), - $request->has('remember') - ); - } - - /** - * Get the needed authorization credentials from the request. - * - * @param \Illuminate\Http\Request $request - * @return array - */ - protected function credentials(Request $request) - { - return $request->only($this->username(), 'password'); - } - - /** - * Send the response after the user was authenticated. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\Response - */ - protected function sendLoginResponse(Request $request) - { - $request->session()->regenerate(); - - $this->clearLoginAttempts($request); - - return $this->authenticated($request, $this->guard()->user()) - ?: redirect()->intended($this->redirectPath()); - } - - /** - * The user has been authenticated. - * - * @param \Illuminate\Http\Request $request - * @param mixed $user - * @return mixed - */ - protected function authenticated(Request $request, $user) - { - // - } - - /** - * Get the failed login response instance. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\RedirectResponse - */ - protected function sendFailedLoginResponse(Request $request) - { - throw ValidationException::withMessages([ - $this->username() => [trans('auth.failed')], - ]); - } - - /** - * Get the login username to be used by the controller. - * - * @return string - */ - public function username() - { - return 'email'; - } - - /** - * Log the user out of the application. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\Response - */ - public function logout(Request $request) - { - $this->guard()->logout(); - - $request->session()->invalidate(); - - return redirect('/'); - } - - /** - * Get the guard to be used during authentication. - * - * @return \Illuminate\Contracts\Auth\StatefulGuard - */ - protected function guard() - { - return Auth::guard(); - } -} diff --git a/src/Services/Concerns/ResetsPasswords.php b/src/Services/Concerns/ResetsPasswords.php deleted file mode 100644 index a0ca65825..000000000 --- a/src/Services/Concerns/ResetsPasswords.php +++ /dev/null @@ -1,178 +0,0 @@ -redirectTo(); - } - - return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; - } - - /** - * Display the password reset view for the given token. - * - * If no token is present, display the link request form. - * - * @param \Illuminate\Http\Request $request - * @param string|null $token - * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View - */ - public function showResetForm(Request $request, $token = null) - { - return view('auth.passwords.reset')->with( - ['token' => $token, 'email' => $request->email] - ); - } - - /** - * Reset the given user's password. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\RedirectResponse - */ - public function reset(Request $request) - { - $request->validate($this->rules(), $this->validationErrorMessages()); - - // Here we will attempt to reset the user's password. If it is successful we - // will update the password on an actual user model and persist it to the - // database. Otherwise we will parse the error and return the response. - $response = $this->broker()->reset( - $this->credentials($request), - function ($user, $password) { - $this->resetPassword($user, $password); - } - ); - - // If the password was successfully reset, we will redirect the user back to - // the application's home authenticated view. If there is an error we can - // redirect them back to where they came from with their error message. - return $response == Password::PASSWORD_RESET - ? $this->sendResetResponse($response) - : $this->sendResetFailedResponse($request, $response); - } - - /** - * Get the password reset validation rules. - * - * @return array - */ - protected function rules() - { - return [ - 'token' => 'required', - 'email' => 'required|email', - 'password' => 'required|confirmed|min:6', - ]; - } - - /** - * Get the password reset validation error messages. - * - * @return array - */ - protected function validationErrorMessages() - { - return []; - } - - /** - * Get the password reset credentials from the request. - * - * @param \Illuminate\Http\Request $request - * @return array - */ - protected function credentials(Request $request) - { - return $request->only( - 'email', - 'password', - 'password_confirmation', - 'token' - ); - } - - /** - * Reset the given user's password. - * - * @param \Illuminate\Contracts\Auth\CanResetPassword $user - * @param string $password - * @return void - */ - protected function resetPassword($user, $password) - { - $user->password = Hash::make($password); - - $user->setRememberToken(Str::random(60)); - - $user->save(); - - event(new PasswordReset($user)); - - $this->guard()->login($user); - } - - /** - * Get the response for a successful password reset. - * - * @param string $response - * @return \Illuminate\Http\RedirectResponse - */ - protected function sendResetResponse($response) - { - return response()->json([ - 'status' => trans($response), - ]); - } - - /** - * Get the response for a failed password reset. - * - * @param \Illuminate\Http\Request - * @param string $response - * @return \Illuminate\Http\RedirectResponse - */ - protected function sendResetFailedResponse(Request $request, $response) - { - return response()->json(['email' => [ - trans($response), - ]])->setStatusCode(400); - } - - /** - * Get the broker to be used during password reset. - * - * @return \Illuminate\Contracts\Auth\PasswordBroker - */ - public function broker() - { - return Password::broker(); - } - - /** - * Get the guard to be used during password reset. - * - * @return \Illuminate\Contracts\Auth\StatefulGuard - */ - protected function guard() - { - return Auth::guard(); - } -} diff --git a/src/Services/Concerns/ThrottlesLogins.php b/src/Services/Concerns/ThrottlesLogins.php deleted file mode 100644 index 6ff6de986..000000000 --- a/src/Services/Concerns/ThrottlesLogins.php +++ /dev/null @@ -1,120 +0,0 @@ -limiter()->tooManyAttempts( - $this->throttleKey($request), - $this->maxAttempts(), - $this->decayMinutes() - ); - } - - /** - * Increment the login attempts for the user. - * - * @param \Illuminate\Http\Request $request - * @return void - */ - protected function incrementLoginAttempts(Request $request) - { - $this->limiter()->hit($this->throttleKey($request)); - } - - /** - * Redirect the user after determining they are locked out. - * - * @param \Illuminate\Http\Request $request - * @return void - * @throws \Illuminate\Validation\ValidationException - */ - protected function sendLockoutResponse(Request $request) - { - $seconds = $this->limiter()->availableIn( - $this->throttleKey($request) - ); - - throw ValidationException::withMessages([ - $this->username() => [Lang::get('auth.throttle', ['seconds' => $seconds])], - ])->status(423); - } - - /** - * Clear the login locks for the given user credentials. - * - * @param \Illuminate\Http\Request $request - * @return void - */ - protected function clearLoginAttempts(Request $request) - { - $this->limiter()->clear($this->throttleKey($request)); - } - - /** - * Fire an event when a lockout occurs. - * - * @param \Illuminate\Http\Request $request - * @return void - */ - protected function fireLockoutEvent(Request $request) - { - event(new Lockout($request)); - } - - /** - * Get the throttle key for the given request. - * - * @param \Illuminate\Http\Request $request - * @return string - */ - protected function throttleKey(Request $request) - { - return Str::lower($request->input($this->username())).'|'.$request->ip(); - } - - /** - * Get the rate limiter instance. - * - * @return \Illuminate\Cache\RateLimiter - */ - protected function limiter() - { - return app(RateLimiter::class); - } - - /** - * Get the maximum number of attempts to allow. - * - * @return int - */ - public function maxAttempts() - { - return property_exists($this, 'maxAttempts') ? $this->maxAttempts : 5; - } - - /** - * Get the number of minutes to throttle for. - * - * @return int - */ - public function decayMinutes() - { - return property_exists($this, 'decayMinutes') ? $this->decayMinutes : 1; - } -} diff --git a/src/Services/ForgotPasswordService.php b/src/Services/ForgotPasswordService.php deleted file mode 100644 index c324d091f..000000000 --- a/src/Services/ForgotPasswordService.php +++ /dev/null @@ -1,80 +0,0 @@ -getEmailForPasswordReset(), $withToken); - - return url($withEmail); - }); - - return resolve(static::class)->sendResetLinkEmail($request); - } - - /** - * Send a reset link to the given user. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\RedirectResponse - */ - public function sendResetLinkEmail(Request $request) - { - $request->validate(['email' => 'required|email']); - - // We will send the password reset link to this user. Once we have attempted - // to send the link, we will examine the response then see the message we - // need to show to the user. Finally, we'll send out a proper response. - $response = $this->broker()->sendResetLink( - $request->only('email') - ); - - return $response == Password::RESET_LINK_SENT - ? $this->sendResetLinkResponse($response) - : $this->sendResetLinkFailedResponse($request, $response); - } - - public function broker() - { - return Password::broker(); - } - - /** - * Get the response for a successful password reset link. - * - * @param string $response - * @return \Illuminate\Http\RedirectResponse - */ - protected function sendResetLinkResponse($response) - { - return response()->json([ - 'status' => trans($response), - ]); - } - - /** - * Get the response for a failed password reset link. - * - * @param \Illuminate\Http\Request - * @param string $response - * @return \Illuminate\Http\RedirectResponse - */ - protected function sendResetLinkFailedResponse(Request $request, $response) - { - return response()->json([ - 'errors' => [ - 'email' => [ - trans($response), - ], - ], - ])->setStatusCode(400); - } -} diff --git a/src/Services/LoginService.php b/src/Services/LoginService.php deleted file mode 100644 index 5d2dd415f..000000000 --- a/src/Services/LoginService.php +++ /dev/null @@ -1,45 +0,0 @@ -login($request); - } - - public function login(Request $request) - { - $this->validateLogin($request); - - // If the class is using the ThrottlesLogins trait, we can automatically throttle - // the login attempts for this application. We'll key this by the username and - // the IP address of the client making these requests into this application. - if (method_exists($this, 'hasTooManyLoginAttempts') && - $this->hasTooManyLoginAttempts($request)) { - $this->fireLockoutEvent($request); - - return $this->sendLockoutResponse($request); - } - - if ($this->attemptLogin($request)) { - event(new UserLoggedIn($this->guard()->user())); - - return $this->guard()->user()->createToken('login'); - } - - // If the login attempt was unsuccessful we will increment the number of attempts - // to login and redirect the user back to the login form. Of course, when this - // user surpasses their maximum number of attempts they will get locked out. - $this->incrementLoginAttempts($request); - - return $this->sendFailedLoginResponse($request); - } -} diff --git a/src/Services/LogoutService.php b/src/Services/LogoutService.php deleted file mode 100644 index a9d68b3a4..000000000 --- a/src/Services/LogoutService.php +++ /dev/null @@ -1,28 +0,0 @@ -logout($request); - } - - throw new AuthenticatableUserException(__('User is not authenticated.')); - } -} diff --git a/src/Services/RegisterService.php b/src/Services/RegisterService.php deleted file mode 100644 index 52a657f44..000000000 --- a/src/Services/RegisterService.php +++ /dev/null @@ -1,93 +0,0 @@ -all(); - - $this->validateRegister($payload); - - $builder = $this->authService->userQuery(); - - if (false === $builder instanceof Authenticatable) { - throw AuthenticatableUserException::wrongInstance(); - } - - $userData = array_merge($payload, [ - 'password' => Hash::make(data_get($payload, 'password')), - ]); - - if (is_callable(static::$creating)) { - $user = call_user_func(static::$creating, $userData); - } else { - $user = $builder->query()->create($userData); - } - - if ($user instanceof Authenticatable) { - event(new Registered($user)); - } - - return $user; - } - - public static function make(Request $request, AuthService $authService) - { - return resolve(static::class) - ->usingAuthService($authService) - ->register($request); - } - - public function validateRegister(array $payload) - { - try { - if (class_exists(static::$registerFormRequest) && (new \ReflectionClass(static::$registerFormRequest))->isInstantiable()) { - $validator = Validator::make($payload, (new static::$registerFormRequest)->rules(), (new static::$registerFormRequest)->messages()); - if ($validator->fails()) { - throw new ValidationException($validator); - } - } - } catch (ReflectionException $e) { - $concrete = static::$registerFormRequest; - - throw new BindingResolutionException("Target class [$concrete] does not exist.", 0, $e); - } - - return true; - } - - protected function usingAuthService(AuthService $service) - { - $this->authService = $service; - - return $this; - } -} diff --git a/src/Services/ResetPasswordService.php b/src/Services/ResetPasswordService.php deleted file mode 100644 index 0bbf5acd9..000000000 --- a/src/Services/ResetPasswordService.php +++ /dev/null @@ -1,26 +0,0 @@ -reset($request); - } - - protected function usingAuthService(AuthService $authService) - { - $this->authService = $authService; - - return $this; - } -} diff --git a/src/Services/RestifyService.php b/src/Services/RestifyService.php deleted file mode 100644 index c05612bd5..000000000 --- a/src/Services/RestifyService.php +++ /dev/null @@ -1,10 +0,0 @@ - - */ -class RestifyService -{ -} diff --git a/tests/Feature/Authentication/AuthServiceForgotPasswordTest.php b/tests/Feature/Authentication/AuthServiceForgotPasswordTest.php deleted file mode 100644 index 93cf51a8a..000000000 --- a/tests/Feature/Authentication/AuthServiceForgotPasswordTest.php +++ /dev/null @@ -1,183 +0,0 @@ - - */ -class AuthServiceForgotPasswordTest extends IntegrationTest -{ - use MailTracking; - use InteractsWithContainer; - - /** - * @var AuthService - */ - protected $authService; - - protected function setUp(): void - { - parent::setUp(); - $this->setUpMailTracking(); - RegisterService::$registerFormRequest = null; - $this->authService = resolve(AuthService::class); - $this->app['config']->set('restify.auth.provider', 'sanctum'); - $this->app['config']->set('restify.auth.frontend_app_url', 'https://laravel-restify.dev'); - $this->app['config']->set('restify.auth.password_reset_url', 'https://laravel-restify.dev/password/reset?token={token}&email={email}'); - } - - public function test_email_was_sent_and_contain_token() - { - Notification::fake(); - - $user = $this->register(); - $request = new Request([], []); - $request->merge(['email' => $user->email]); - - $this->authService->forgotPassword($request); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) { - $this->assertNotEmpty($notification->token); - - return true; - }); - } - - public function test_email_was_sent_and_has_default_or_custom_url_callback() - { - Notification::fake(); - - $user = $this->register(); - $request = new Request([], []); - $request->merge(['email' => $user->email]); - - $this->authService->forgotPassword($request); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $this->assertEquals( - "https://laravel-restify.dev/password/reset?token={$notification->token}&email={$user->email}", - call_user_func($notification::$createUrlCallback, $user, $notification->token), - ); - - return true; - }); - - $this->authService->forgotPassword( - $request, - 'https://subdomain.domain.test/password/reset?token={token}&email={email}', - ); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $this->assertEquals( - "https://subdomain.domain.test/password/reset?token={$notification->token}&email={$user->email}", - call_user_func($notification::$createUrlCallback, $user, $notification->token), - ); - - return true; - }); - } - - public function test_reset_password_invalid_payload() - { - $this->expectException(ValidationException::class); - $request = new Request([], []); - $request->merge([ - 'email' => null, - 'password' => 'password', - 'password_confirmation' => 'password', - 'token' => 'secret', - ]); - $this->authService->resetPassword($request); - } - - public function test_reset_password_successfully() - { - Notification::fake(); - $user = $this->register(); - - $request = new Request([], []); - $request->merge(['email' => $user->email]); - $this->authService->verify($request, $user->id, sha1($user->email)); - - $this->authService->forgotPassword($request); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $token = $notification->token; - $password = Str::random(10); - - $request = new Request([], []); - $request->merge([ - 'email' => $user->email, - 'password' => $password, - 'password_confirmation' => $password, - 'token' => $token, - ]); - - $this->authService->resetPassword($request); - - Event::assertDispatched(PasswordReset::class, function ($e) use ($user) { - $this->assertEquals($e->user->email, $user->email); - - return $e->user instanceof User; - }); - - $request = new Request([], []); - $request->merge([ - 'email' => $user->email, - 'password' => $password, - ]); - - $this->authService->login($request); - - Event::assertDispatched(UserLoggedIn::class, function ($e) use ($user) { - $this->assertEquals($e->user->email, $user->email); - - return $e->user instanceof User; - }); - - return true; - }); - } - - public function register() - { - Event::fake([ - Registered::class, - PasswordReset::class, - UserLoggedIn::class, - ]); - - $this->app->instance(User::class, new User); - - $request = new Request([], []); - - $user = [ - 'name' => 'Eduard Lupacescu', - 'email' => 'eduard.lupacescu@binarcode.com', - 'password' => 'secret!', - 'remember_token' => Str::random(10), - ]; - - $request->merge($user); - - $this->authService->register($request); - - return User::query()->get()->last(); - } -} diff --git a/tests/Feature/Authentication/AuthServiceRegisterTest.php b/tests/Feature/Authentication/AuthServiceRegisterTest.php deleted file mode 100644 index 3496cc284..000000000 --- a/tests/Feature/Authentication/AuthServiceRegisterTest.php +++ /dev/null @@ -1,143 +0,0 @@ - - */ -class AuthServiceRegisterTest extends IntegrationTest -{ - use InteractsWithContainer; - /** - * @var AuthService - */ - protected $authService; - - protected function setUp(): void - { - parent::setUp(); - $this->authService = resolve(AuthService::class); - } - - public function test_user_query_throw_container_does_not_have_model_reflection_exception() - { - $this->app['config']->set('auth.providers.users.model', null); - $this->expectException(EntityNotFoundException::class); - $this->authService->userQuery(); - } - - public function test_user_query_throw_container_cannot_instantiate_abstract_model() - { - $this->app['config']->set('auth.providers.users.model', LaravelRestifyModel::class); - $this->expectException(EntityNotFoundException::class); - $this->authService->userQuery(); - } - - public function test_register_successfully() - { - Event::fake([ - Registered::class, - ]); - - $this->app->instance(User::class, new User); - - $user = [ - 'name' => 'Eduard Lupacescu', - 'email' => 'eduard.lupacescu@binarcode.com', - 'password' => 'password', - 'password_confirmation' => 'password', - 'remember_token' => Str::random(10), - ]; - - $request = new Request([], []); - - $request->merge($user); - - $this->authService->register($request); - - Event::assertDispatched(Registered::class, function ($e) use ($user) { - $this->assertEquals($e->user->email, $user['email']); - - return $e->user instanceof \Binaryk\LaravelRestify\Tests\Fixtures\User\User; - }); - - $lastUser = User::query()->get()->last(); - - $this->assertEquals($lastUser->email, $user['email']); - } - - public function test_verify_user_throw_hash_not_match() - { - Event::fake([ - Registered::class, - ]); - - $this->app->instance(User::class, new User); - - $user = [ - 'name' => 'Eduard Lupacescu', - 'email' => 'eduard.lupacescu@binarcode.com', - 'password' => 'password', - 'password_confirmation' => 'password', - 'remember_token' => Str::random(10), - ]; - - $request = new Request([], []); - - $request->merge($user); - - $this->authService->register($request); - $lastUser = User::query()->get()->last(); - - $this->expectException(AuthorizationException::class); - $this->authService->verify($request, $lastUser->id, sha1('random@email.com')); - } - - public function test_verify_user_successfully() - { - Event::fake([ - Verified::class, - Registered::class, - ]); - - $this->app->instance(User::class, new User); - - $user = [ - 'name' => 'Eduard Lupacescu', - 'email' => 'eduard.lupacescu@binarcode.com', - 'password' => 'password', - 'password_confirmation' => 'password', - 'remember_token' => Str::random(10), - ]; - - $request = new Request([], []); - - $request->merge($user); - - $this->authService->register($request); - $lastUser = User::query()->get()->last(); - - $this->assertNull($lastUser->email_verified_at); - $this->authService->verify($request, $lastUser->id, sha1('eduard.lupacescu@binarcode.com')); - $lastUser->refresh(); - $this->assertNotNull($lastUser->email_verified_at); - Event::assertDispatched(Verified::class, function ($e) use ($user) { - $this->assertEquals($e->user->email, $user['email']); - - return $e->user instanceof \Binaryk\LaravelRestify\Tests\Fixtures\User\User; - }); - } -} diff --git a/tests/Feature/Authentication/ResetPasswordRequestTest.php b/tests/Feature/Authentication/ResetPasswordRequestTest.php deleted file mode 100644 index 97552355c..000000000 --- a/tests/Feature/Authentication/ResetPasswordRequestTest.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ -class ResetPasswordRequestTest extends IntegrationTest -{ - /** @var ResetPasswordRequest */ - private $subject; - - protected function setUp(): void - { - parent::setUp(); - - $this->subject = new ResetPasswordRequest; - } - - public function testRules() - { - $this->assertEquals( - [ - 'token' => 'required', - 'email' => 'required|email', - 'password' => 'required|confirmed|min:8', - ], - $this->subject->rules() - ); - } - - public function testAuthorize() - { - $this->assertTrue($this->subject->authorize()); - } -} diff --git a/tests/Feature/Authentication/RestifyLoginRequestTest.php b/tests/Feature/Authentication/RestifyLoginRequestTest.php deleted file mode 100644 index 1d609a851..000000000 --- a/tests/Feature/Authentication/RestifyLoginRequestTest.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ -class RestifyLoginRequestTest extends IntegrationTest -{ - /** @var RestifyLoginRequest */ - private $subject; - - protected function setUp(): void - { - parent::setUp(); - - $this->subject = new RestifyLoginRequest; - } - - public function testRules() - { - $this->assertEquals( - [ - 'email' => 'required|email', - 'password' => 'required|min:6', - ], - $this->subject->rules() - ); - } - - public function testAuthorize() - { - $this->assertTrue($this->subject->authorize()); - } -} diff --git a/tests/Feature/Authentication/RestifyPasswordEmailRequestTest.php b/tests/Feature/Authentication/RestifyPasswordEmailRequestTest.php deleted file mode 100644 index 600767d16..000000000 --- a/tests/Feature/Authentication/RestifyPasswordEmailRequestTest.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ -class RestifyPasswordEmailRequestTest extends IntegrationTest -{ - /** @var RestifyPasswordEmailRequest */ - private $subject; - - protected function setUp(): void - { - parent::setUp(); - - $this->subject = new RestifyPasswordEmailRequest; - } - - public function testRules() - { - $this->assertEquals( - [ - 'email' => 'required|email', - ], - $this->subject->rules() - ); - } - - public function testAuthorize() - { - $this->assertTrue($this->subject->authorize()); - } -} From 2b5cc8a7ffa03402d83e53903ac6765930a6f44a Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Thu, 18 Nov 2021 12:26:51 +0200 Subject: [PATCH 5/5] fix: wip --- docs-v2/content/en/api/actions.md | 456 ++++++++++++------ docs-v2/content/en/api/fields.md | 86 +++- docs-v2/content/en/search/advanced-filters.md | 2 + src/Actions/Action.php | 11 + src/Fields/Field.php | 4 - src/helpers.php | 2 +- tests/Actions/PerformActionControllerTest.php | 2 +- tests/Fixtures/Post/PostRepository.php | 18 +- tests/Fixtures/Post/PublishPostAction.php | 1 + 9 files changed, 407 insertions(+), 175 deletions(-) diff --git a/docs-v2/content/en/api/actions.md b/docs-v2/content/en/api/actions.md index 1bad1f733..caf560899 100644 --- a/docs-v2/content/en/api/actions.md +++ b/docs-v2/content/en/api/actions.md @@ -5,38 +5,38 @@ category: API position: 9 --- -Restify allow you to define extra actions for your repositories. Let's say you have a list of posts, and you have to -publish them. Usually for this kind of operations, you have to define a custom route like: +## Motivation + +Besides, built in CRUD operations and filtering, Restify allows you to define extra actions for your repositories. + +Let's say you have a list of posts, and you have to publish them. Usually for this kind of operations, you have to define a custom route like: ```php -// PostRepository +$router->post('posts/publish', PublishPostsController::class); -public static function routes(Router $router, $attributes, $wrap = true) -{ - $router->post('/publish', [static::class, 'publishMultiple']); -} +// PublishPostsController.php -public function publishMultiple(RestifyRequest $request) +public function __invoke(RestifyRequest $request) { ... } ``` -There is nothing bad with this approach, but when project growth, you will notice that the routes / repository can -easily become a mess. +The `classic` approach is good, however, it has a few limitations. Firstly, you have to manually take care of the route `middleware`, the testability for these endpoints should be done separately which is hard to maintain. And finally, the endpoint is disconnected from the repository, which makes it feel out of context so has a bad readability. -More than that, you have to guess a route name or handler, should it be a controller, a callback or a method right in -the repository class? Well, for this kind of operations, actions is what we need. +So, code readability, testability and maintainability become hard. -# Defining Actions +## Action definition -The action could be generated by using the command: +The action is nothing more than a class, that extends the `Binaryk\LaravelRestify\Actions\Action` abstract class. + +It could be generated by using the command: ```bash -artisan restify:action PublishPostsAction +php artisan restify:action PublishPostsAction ``` -This will generate for the handler class: +This will generate the action class: ```php namespace App\Restify\Actions; @@ -50,265 +50,376 @@ class PublishPostAction extends Action { public function handle(ActionRequest $request, Collection $models): JsonResponse { - return $this->response()->respond(); + return response()->json(); } } ``` -The `$models` represents a collection with all of the models for this query. +The `$models` argument represents a collection of all the models for this query. -## Available actions +### Register action -The frontend which consume your API could check available actions by using exposed endpoint: +Then add the action instance to the repository `actions` method: + +```php +// PostRepository.php + +public function actions(RestifyRequest $request): array +{ + return [ + PublishPostAction::new() + ]; +} +``` + +### Authorize action + +You can authorize certain actions to be active for specific users: + +```php +public function actions(RestifyRequest $request): array +{ + return [ + PublishPostAction::new()->canSee(function (Request $request) { + return $request->user()->can('publishAnyPost', Post::class), + }), + ]; +} +``` + +### Call actions + +To call an action, you simply access: ```http request -GET: api/api/restify/posts/actions +POST: api/restify/posts/actions?action=publish-posts-action ``` -This will answer with a json like: +The `action` query param value is the `ke-bab` form of the filter class name by default, or a custom `$uriKey` [defined in the action](#custom-uri-key) + + +The payload could be any type of json data, however, if you're using an [index-action](#index-actions), you are required to pass the `repositories` key, which represents the list of model keys we apply the action: ```json { - "data": { - "name": "Publish Posts Action", - "destructive": false, - "uriKey": "publish-posts-action", - "payload": [] - } + "repositories": [1, 2] } ``` -`name` - humanized name of the action +### Handle action -`destructive` - you may extend the `Binaryk\LaravelRestify\Actions\DestructiveAction` to indicate to the frontend than -this action is destructive (could be used for deletions) +As soon the action is called, the handled method will be invoked with the `$request` and list of models matching the keys passed via `repositories`: -`uriKey` - is the key of the action, will be used to perform the action +```php +public function handle(ActionRequest $request, Collection $models) +{ + $models->each->publish(); -`payload` - a key / value object indicating required payload + return ok(); +} +``` -# Registering Actions +## Action customizations -Once you have defined the action, you can register it for many resources. +Actions could be easily customized. + +### Action index query + +Similarly to repository [index query](/repositories-advanced#index-query), we can do the same by adding the `indexQuery` method on the action: ```php -public function actions(RestifyRequest $request) +class PublishPostAction extends Action { - return [ - PublishPostAction::new(), - ]; + public static function indexQuery(RestifyRequest $request, $query) + { + $query->whereNotNull('published_at'); + } + + ... } ``` -You can pass anything to the action constructor: +This method will be called right before items are retrieved from the database, so you can filter out or eager load using your custom statements. + +### Custom uri key + +Since your class names could change along the way, you can define a `$uriKey` property to your actions, so the frontend will use always the same `action` query when applying an action: ```php -public function actions(RestifyRequest $request) +class PublishPostAction extends Action +{ + public static $uriKey = 'publish-posts'; + + //... + +}; +``` + +### Rules + +Similarly to [advanced filters rules](/search/advanced-filters#advanced-filter-rules), you could define rules for the action so the payload will get validated before the handle method is fired. + +```php +public function rules(): array { return [ - PublishPostAction::new("Publish articles.", app(Validator::class)), + 'active' => ['required', 'bool'], ]; } ``` - - -Repository model You may consider that you have access to the `$this->resource` in the `actions` method ( -since it's not static). However, in this method the `resource` is not available, since the request is for a new model. - + +Restify doesn't validate the payload automatically as it does for filters, you're free to validate the payload in the handle method. -### Unauthorized +Always validate the payload as early as possible in the `handle` method: -Actions could be authorized: ```php -public function actions(RestifyRequest $request) +public function handle(ActionRequest $request, Collection $models) { - return [ - PublishPostAction::new()->canSee(function (Request $request) { - return $request->user()->can('pubishAnyPost', Post::class), - }), - ]; + $request->validate($this->rules()); + + ... } ``` -### Authorizing Actions Per-Model +## Actions scope -As you saw, we don't have access to the repository model in the `actions` method. However, we do have access to models -in the handle method. You're free to use Laravel Policies there. +By default, any action could be used on [index](#index-actions) as well as on [show](#show-actions). However, you can choose to instruct your action to be displayed to a specific scope. -# Use actions +## Show actions -The usage of an action, means the `handle` method implementation. The first argument is the `RestifyRequest`, and the -second one is a Collection of models, matching the `repositories` payload. +Show actions are used when you have to apply it for a single item. -```http request -POST: api/api/restify/posts/actions?action=publish-posts-action -``` +### Show action definition -Payload: +The show action definition is different in the way it receives arguments for the `handle` method. -```json +Restify automatically resolves Eloquent models defined in the route id and passes it to the action's handle method: + +```php +// PublishPostAction.php + +public function handle(ActionRequest $request, Post $post): JsonResponse { - "repositories": [ - 1, - 2 - ] + } + ``` +### Show action registration + +To register a show action, we have to use the `->onlyOnShow()` accessor: + ```php -public function handle(ActionRequest $request, Collection $models): JsonResponse +public function actions(RestifyRequest $request) { - // $models contains 2 posts (under ids 1 and 2) - $models->each->publish(); - - return $this->response()->respond(); + return [ + PublishPostAction::new()->onlyOnShow(), + ]; } ``` -## Filters +### Show action call -You can apply any filter or eager loadings as for an usual request: +The post URL should include the key of the model we want Restify to resolve: ```http request -POST: api/api/restify/posts/actions?action=publish-posts-action&id=1&filters= +POST: api/restfiy/posts/1/actions?action=publish-post-action ``` -This will apply the match for the `id = 1` and `filter` along with the match for the `repositories` payload you're -sending. - -### Modify query - -Similar with the way we can modify the query applied to the repository, we can do the same by adding the `indexQuery` -method on the action: +The payload could be empty: -```php -class PublishPostAction extends Action -{ - public static function indexQuery(RestifyRequest $request, $query) - { - $query->whereNotNull('published_at'); - } - - //... -} +```json +{} ``` -## All +### List show actions -Sometimes you may need to apply an action for all models. For this you can send: +To get the list of available actions only for a specific model key: ```http request -{ repositories: "all" } +GET: api/api/restify/posts/1/actions ``` -Under the hood Restify will take by 200 chunks entries from the database and the handle method for these in a DB -transaction. You are free to modify this default number of chunks: +See [get available actions](#get-available-actions) for more details. + +## Index actions + +Index actions are used when you have to apply it for a many items. + +### Index action definition + +The index action definition is different in the way it receives arguments for the `handle` method. + +Restify automatically resolves Eloquent models sent via the `repositories` key sent into the call payload and passes it to the action's handle method as a collection of items: ```php -public static int $chunkCount = 150; +// PublishPostAction.php +use Illuminate\Support\Collection; + +public function handle(ActionRequest $request, Collection $posts): JsonResponse +{ + // +} + ``` -## Action visibility +### Index action registration -Usually you need an action only for a single model. Then you can use: +To register an index action, we have to use the `->onlyOnIndex()` accessor: ```php +// PostRepository.php + public function actions(RestifyRequest $request) { return [ - PublishPostAction::new()->onlyOnShow(), + PublishPostsAction::new()->onlyOnIndex(), ]; } ``` -And available actions only for a specific repository id could be listed like: +### Index action call + +The post URL: ```http request -GET: api/api/restify/posts/1/actions +POST: api/restfiy/posts/actions?action=publish-posts-action ``` -Having this in place, you now have access to the current repository in the `actions` method: +The payload should always include a key called `repositories`, which is an array of model keys or the `all` keyword if you want to get all: -```php -public function actions(RestifyRequest $request) +```json { - return [ - PublishPostAction::new()->onlyOnShow()->canSee(function(ActionRequest $request) { - return $request->user()->ownsPost($request->findModelOrFail()); - }) - ]; + "repositories": [1, 2, 3] +} +``` + +So Restify will resolve posts with ids in the list of `[1, 2, 3]`. + +### Apply index action for all + +You can apply the index action for all models from the database if you send the payload: + +```json +{ + "repositories": "all" } ``` -Performing this action, you can only for a single repository: +Restify will get chunks of 200 and send them into the `Collection` argument for the `handle` method. + +You can customize the chunk number by customizing the `chunkCount` action property: + +```php +// PublishPostAction.php + +public static int $chunkCount = 500; +``` + +### List index actions + +To get the list of available actions: ```http request -POST: api/api/restify/posts/1/actions?action=publish-posts-action +GET: api/api/restify/posts/actions ``` -And you don't have to pass the `repositories` array in that case, since it's present in the query. +See [get available actions](#get-available-actions) for more details. + +## Standalone actions -Because it will be right in your handle method: +Sometimes you don't need to have an action with models. Let's say for example the authenticated user wants to disable +his account. + +### Standalone action definition: + +The index action definition is different in the way it doesn't require the second argument for the `handle`. ```php -public function handle(ActionRequest $request, Post $post): JsonResponse +// DisableProfileAction.php + +public function handle(ActionRequest $request): JsonResponse { // } + ``` -## Standalone actions +### Standalone action registration -Sometimes you don't need to have an action with models. Let's say for example the authenticated user wants to disable -his account. For this we have `standalone` actions: +There are two ways to register the standalone action: ```php // UserRepository - public function actions(RestifyRequest $request) - { - return [ - DisableProfileAction::new()->standalone(), - ]; - } +public function actions(RestifyRequest $request) +{ + return [ + DisableProfileAction::new()->standalone(), + ]; +} ``` -Just mark it as standalone with `->standalone` or override the property directly into the action: +Using the `->standalone()` mutator or by overriding the `$standalone` action property directly into the action: ```php class DisableProfileAction extends Action { - public $standalone = true; + public bool $standalone = true; //... } ``` -## URI Key +### Standalone action call -Usually the URL for the action is make based on the action name. You can use your own URI key if you want: +To call a standalone action you're using a similar URL as for the [index action](#index-action-call) -```php -class DisableProfileAction extends Action -{ - public static $uriKey = 'disable_profile'; +```http request +POST: api/restfiy/users/actions?action=disable-profile-action +``` - //... -} +However, you are not required to pass the `repositories` payload key. + +### List standalone actions + +Standalone actions will be displayed on both [listing show actions](#list-show-actions) or [listing index actions](#list-index-actions). + +## Filters + +You can apply any search, match, filter or eager loadings as for a usual request: + +```http request +POST: api/api/restify/posts/actions?action=publish-posts-action&id=1&filters= ``` +This will apply the match for the `id = 1` and `filter` along with the match for the `repositories` payload you're +sending. + ## Action Log It is often useful to view a log of the actions that have been run against a model, or seeing when the model was -updated, deleted or created (and by whom). Thankfully, Restify makes it a breeze to add an action log to a model by -attaching the `Binaryk\LaravelRestify\Models\Concerns\HasActionLogs` trait to the repository's corresponding Eloquent -model. +updated, deleted or created (and by whom). + +Thankfully, Restify makes it a breeze to add an action log to a model by attaching the `Binaryk\LaravelRestify\Models\Concerns\HasActionLogs` trait to the repository's corresponding Eloquent model. + +### Activate logs + +Simply adding the `HasActionLogs` trait to your model, it will log all actions and CRUD operations into the database into the `action_logs` table: + +```php +// Post.php + +class Post extends Model +{ + use \Binaryk\LaravelRestify\Models\Concerns\HasActionLogs; +} +``` -Having `HasActionLogs` trait attached to your model, all of the actions and CRUD operations will be logged into the -database into the `action_logs` table. +### Display logs You can display them by attaching to the repository related for example: @@ -320,7 +431,7 @@ use Binaryk\LaravelRestify\Repositories\ActionLogRepository; public static function related(): array { return [ - 'logs' => MorphToMany::make('actionLogs', 'actionLogs', ActionLogRepository::class), + 'logs' => MorphToMany::make('actionLogs', ActionLogRepository::class), ]; } ``` @@ -334,30 +445,57 @@ performed for posts: "id": "1", "type": "action_logs", "attributes": { - "batch_id": "048686bb-cd22-41a7-a6db-3eba29678d74", "user_id": "1", "name": "Stored", "actionable_type": "App\\Models\\Post", "actionable_id": "1", - "target_type": "App\\Models\\Post", - "target_id": "1", - "model_type": "App\\Models\\Post", - "model_id": "1", - "fields": "", "status": "finished", - "original": "", + "original": [], "changes": [], "exception": "" - }, - "meta": { - "authorizedToShow": true, - "authorizedToStore": true, - "authorizedToUpdate": true, - "authorizedToDelete": true } } ] ``` -Definitely you can use your own `ActionLogRepository` to represent the data returned, maybe you prefer to represent the -user details or something else. +### Custom logs repository + +Definitely you can use your own `ActionLogRepository`. Just ensure you define it into the config: + +```php +// config/restify.php +... +'logs' => [ + 'repository' => MyCustomLogsRepository::class, +], +``` + +## Get available actions + +The frontend which consume your API could check available actions by using exposed endpoint: + +```http request +GET: api/api/restify/posts/actions +``` + +This will answer with a json like: + +```json +{ + "data": { + "name": "Publish Posts Action", + "destructive": false, + "uriKey": "publish-posts-action", + "payload": [] + } +} +``` + +`name` - humanized name of the action + +`destructive` - you may extend the `Binaryk\LaravelRestify\Actions\DestructiveAction` to indicate to the frontend than +this action is destructive (could be used for deletions) + +`uriKey` - is the key of the action, will be used to perform the action + +`payload` - a key / value object indicating required payload defined in the `rules` Action class diff --git a/docs-v2/content/en/api/fields.md b/docs-v2/content/en/api/fields.md index d0e4e33b2..3b149991c 100644 --- a/docs-v2/content/en/api/fields.md +++ b/docs-v2/content/en/api/fields.md @@ -13,7 +13,9 @@ Each Field generally extends the `Binaryk\LaravelRestify\Fields\Field` class fro a fluent API for a variety of mutators, interceptors and validators. 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 `new` or `make` method. These methods accept the underlying database column as argument: +using their static `new` or `make` method. + +The first argument is always the attribute name, and usually matches the database `column`. ```php @@ -45,6 +47,39 @@ field('email') +### Computed field + +The second optional argument is a callback or invokable, and it represents the displayable value of the field either in `show` or `index` requests. + +```php +field('name', fn() => 'John Doe') +``` + +The field above will always return the `name` value as `John Doe`. The field is still writeable, so you can update or create an entity using it. + +### Readonly field + +If you don't want a field to be writeable you can mark it readonly: + +```php +field('title')->readonly() +``` + +The `readonly` accepts a request as well as you can use: + +```php +field('title')->readonly(fn($request) => $request->user()->isGuest()) +``` + +### Virtual field + +A virtual field, is a field that's [computed](#computed-field) and [readonly](#readonly-field). + +```php +field('name', fn() => "$this->first_name $this->last_name")->readonly() +``` + + ## Authorization The `Field` class provides few methods to authorize certain actions. Each authorization method accept a `Closure` that @@ -240,6 +275,55 @@ Field::new('password')->showRequest(function ($value) { return Hash::make($value); }); ``` + +### Fields actionable + +Sometime storing attributes might require the stored model before saving it. + +For example, say the Post model uses the [media library](https://spatie.be/docs/laravel-medialibrary/v9/introduction) and has the `media` relationship, that's a list of Media files: + +```php +// PostRepository + +public function fields(RestifyRequest $request): array +{ + return [ + field('title'), + + field('files', + fn () => $this->model()->media()->pluck('file_name') + ) + ->action(new AttachPostFileRestifyAction), + ]; +} +``` + +So we have a virtual `files` field (it's not an actual database column) that uses a [computed field](#computed-field) to display the list of Post's files names. The `->action()` call, accept an instance of a class that extends `Binaryk\LaravelRestify\Actions\Action`: + +```php +class AttachPostFileRestifyAction extends Action +{ + public function handle(RestifyRequest $request, Post $post): void + { + $post->addMediaFromRequest('file'); + } +} +``` + +The action gets the `$request` and the current `$post` model. Say the frontend has to create a post with a file: + +```javascript +const data = new FormData; +data.append('file', blobFile); +data.append('title', 'Post title'); + +axios.post(`api/restify/posts`, data); +``` + +In a single request we're able to create the post and attach file using media library, otherwise it would involve 2 separate requests (post creation and file attaching). + +Actionable fields handle [store](/repositories#store-request), put, [bulk store](/repositories#store-bulk-flow) and bulk update requests. + ## Fallbacks ### Default Stored Value diff --git a/docs-v2/content/en/search/advanced-filters.md b/docs-v2/content/en/search/advanced-filters.md index d37fc9889..b80f061c9 100644 --- a/docs-v2/content/en/search/advanced-filters.md +++ b/docs-v2/content/en/search/advanced-filters.md @@ -38,6 +38,8 @@ class ReadyPostsFilter extends AdvancedFilter }; ``` +### Register filter + Then add the filter to the repository `filters` method: ```php diff --git a/src/Actions/Action.php b/src/Actions/Action.php index f551d559d..0c455f4f1 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -107,9 +107,20 @@ public function canRun(Closure $callback) /** * Get the payload available on the action. * + * @deprecated Use rules instead * @return array */ public function payload(): array + { + return $this->rules(); + } + + /** + * Validation rules to be applied before the action is called. + * + * @return array + */ + public function rules(): array { return []; } diff --git a/src/Fields/Field.php b/src/Fields/Field.php index 98c55dc8e..efd7ce825 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -148,10 +148,6 @@ public function __construct($attribute, callable|Closure $resolveCallback = null } else { $this->attribute = $attribute ?? str_replace(' ', '_', Str::lower($attribute)); } - - if (is_callable($resolveCallback)) { - $this->readonly(); - } } public function indexCallback(callable|Closure $callback) diff --git a/src/helpers.php b/src/helpers.php index 7d64b3c7c..4fafa4032 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -30,7 +30,7 @@ function data(mixed $data = [], int $status = 200, array $headers = [], $options if (! function_exists('ok')) { function ok() { - return response('', 204); + return response('', 204)->json([], 204); } } diff --git a/tests/Actions/PerformActionControllerTest.php b/tests/Actions/PerformActionControllerTest.php index aabd9cc7a..bfdb1720b 100644 --- a/tests/Actions/PerformActionControllerTest.php +++ b/tests/Actions/PerformActionControllerTest.php @@ -86,7 +86,7 @@ public function test_show_action_not_need_repositories() $this->assertEquals(1, ActivateAction::$applied[0]->id); } - public function test_could_perform_standalone_action() + public function test_could_perform_standalone_action(): void { $this->postJson('users/action?action='.(new DisableProfileAction())->uriKey()) ->assertSuccessful() diff --git a/tests/Fixtures/Post/PostRepository.php b/tests/Fixtures/Post/PostRepository.php index 684b94d64..766fe91eb 100644 --- a/tests/Fixtures/Post/PostRepository.php +++ b/tests/Fixtures/Post/PostRepository.php @@ -36,13 +36,13 @@ public static function indexQuery(RestifyRequest $request, $query) public function fields(RestifyRequest $request): array { return [ - Field::new('user_id'), + field('user_id'), - Field::new('title')->storingRules('required')->messages([ + field('title')->storingRules('required')->messages([ 'required' => 'This field is required', ]), - Field::new('description')->storingRules('required')->messages([ + field('description')->storingRules('required')->messages([ 'required' => 'Description field is required', ]), ]; @@ -51,9 +51,9 @@ public function fields(RestifyRequest $request): array public function fieldsForStore(RestifyRequest $request): array { return [ - Field::new('user_id'), + field('user_id'), - Field::new('title')->storingRules('required')->messages([ + field('title')->storingRules('required')->messages([ 'required' => 'This field is required', ]), ]; @@ -62,22 +62,22 @@ public function fieldsForStore(RestifyRequest $request): array public function fieldsForStoreBulk(RestifyRequest $request) { return [ - Field::new('title')->storeBulkRules('required')->messages([ + field('title')->storeBulkRules('required')->messages([ 'required' => 'This field is required', ]), - Field::new('user_id'), + field('user_id'), ]; } public function fieldsForUpdateBulk(RestifyRequest $request) { return [ - Field::new('title')->updateBulkRules('required')->messages([ + field('title')->updateBulkRules('required')->messages([ 'required' => 'This field is required', ]), - Field::new('user_id'), + field('user_id'), ]; } diff --git a/tests/Fixtures/Post/PublishPostAction.php b/tests/Fixtures/Post/PublishPostAction.php index 6b335a48e..160b20d76 100644 --- a/tests/Fixtures/Post/PublishPostAction.php +++ b/tests/Fixtures/Post/PublishPostAction.php @@ -17,6 +17,7 @@ public static function indexQuery(RestifyRequest $request, $query) $query->whereNotNull('published_at'); } + public function handle(ActionRequest $request, Collection $models): JsonResponse { static::$applied[] = $models;