From 196056e6db30e52ab07a90267ef9db8e13ab96c7 Mon Sep 17 00:00:00 2001 From: smanhoff Date: Wed, 24 Nov 2021 17:53:37 +0100 Subject: [PATCH] Improve API Resources + Validation --- README.md | 2 +- .../resources/authentication.yaml | 44 ++++++++++ .../api_platform/resources/registration.yaml | 24 ++++++ .../config/api_platform/resources/user.yaml | 42 ++++++++++ .../api_platform/resources/user_devices.yaml | 31 +++++++ symfony/config/packages/api_platform.yaml | 12 +-- .../src/ApiResource/Auth/Authentication.php | 55 ------------- symfony/src/ApiResource/Auth/Register.php | 40 ---------- symfony/src/ApiResource/User/User.php | 47 ----------- symfony/src/ApiResource/User/UserDevice.php | 39 +-------- symfony/src/Dto/Auth/AuthAccessDto.php | 1 + symfony/src/Dto/Auth/UserLoginDto.php | 80 +++++++++++++++++++ symfony/src/Dto/Auth/UserRegisterDto.php | 1 - symfony/src/Dto/User/UserDeviceDto.php | 2 - symfony/src/Dto/User/UserProfileDto.php | 2 - symfony/src/Entity/User.php | 2 +- symfony/src/Exception/ApiException.php | 13 --- symfony/src/Exception/ApiHttpException.php | 22 +++++ symfony/src/Security/UserProvider.php | 30 +++---- .../User/UserPasswordDataPersister.php | 12 ++- .../Service/Api/Swagger/SwaggerDecorator.php | 4 +- .../src/Utils/ConstraintViolationUtils.php | 44 ++++++++++ 22 files changed, 319 insertions(+), 230 deletions(-) create mode 100644 symfony/config/api_platform/resources/authentication.yaml create mode 100644 symfony/config/api_platform/resources/registration.yaml create mode 100644 symfony/config/api_platform/resources/user.yaml create mode 100644 symfony/config/api_platform/resources/user_devices.yaml create mode 100644 symfony/src/Dto/Auth/UserLoginDto.php delete mode 100644 symfony/src/Exception/ApiException.php create mode 100644 symfony/src/Exception/ApiHttpException.php create mode 100644 symfony/src/Utils/ConstraintViolationUtils.php diff --git a/README.md b/README.md index 3b4bc29..bb276b9 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ $tomorrow = Carbon::now()->addDay(); $lastWeek = Carbon::now()->subWeek(); ``` https://carbon.nesbot.com/docs/ -## icapps ❤️ PHP +## PHP Boilerplate For further questions, ask the icapps PHP team. diff --git a/symfony/config/api_platform/resources/authentication.yaml b/symfony/config/api_platform/resources/authentication.yaml new file mode 100644 index 0000000..d218ddd --- /dev/null +++ b/symfony/config/api_platform/resources/authentication.yaml @@ -0,0 +1,44 @@ +App\ApiResource\Auth\Authentication: + shortName: "Authentication" + attributes: + normalization_context: + groups: [ "api-get", "auth:api-get" ] + swagger_definition_name: "GET" + denormalization_context: + groups: [ "api-post", "auth:api-post" ] + swagger_definition_name: "POST" + + # itemOperations + itemOperations: [] + + # collectionOperations + collectionOperations: + # Refresh + post_refresh_api: + status: 200 + method: POST + path: /auth/refresh-token + input: App\Dto\Auth\UserRefreshDto + openapi_context: + summary: "Refresh user access token" + description: "Refresh user access token" + + # Logout + post_logout_api: + status: 200 + method: POST + path: /auth/logout + input: App\Dto\Auth\UserLogoutDto + openapi_context: + summary: "Logout user" + description: "Logout user, remove session and device" + + # Password reset init + post_password_reset_api: + status: 200 + method: POST + path: /auth/forgot-password/init + input: App\Dto\Auth\UserPasswordResetDto + openapi_context: + summary: "Initiate user forgot password flow" + description: "Initiate a user forgot password flow by sending an email to the user" \ No newline at end of file diff --git a/symfony/config/api_platform/resources/registration.yaml b/symfony/config/api_platform/resources/registration.yaml new file mode 100644 index 0000000..fcac564 --- /dev/null +++ b/symfony/config/api_platform/resources/registration.yaml @@ -0,0 +1,24 @@ +App\ApiResource\Auth\Register: + shortName: "Registration" + attributes: + normalization_context: + groups: [ "api-get", "register:api-get" ] + swagger_definition_name: "GET" + denormalization_context: + groups: [ "api-post", "register:api-post" ] + swagger_definition_name: "POST" + + # itemOperations + itemOperations: [] + + # collectionOperations + collectionOperations: + # Registration + post_register_api: + method: POST + path: /auth/register + input: App\Dto\Auth\UserRegisterDto + output: App\Dto\User\UserProfileDto + openapi_context: + summary: "Register a new user" + description: "Register a new user" \ No newline at end of file diff --git a/symfony/config/api_platform/resources/user.yaml b/symfony/config/api_platform/resources/user.yaml new file mode 100644 index 0000000..be81f97 --- /dev/null +++ b/symfony/config/api_platform/resources/user.yaml @@ -0,0 +1,42 @@ +App\ApiResource\User\User: + shortName: "User" + attributes: + normalization_context: + groups: [ "api-get", "profile:api-get" ] + swagger_definition_name: "GET" + denormalization_context: + groups: [ "api-post", "profile:api-post", "password:api-post" ] + swagger_definition_name: "PATCH" + + # itemOperations + itemOperations: + # Get user profile. + get: + path: /users/{userSid}/profile + output: App\Dto\User\UserProfileDto + openapi_context: + summary: "Get user profile" + description: "Get user profile" + + # Update user profile. + patch: + path: /users/{userSid}/profile + input: App\Dto\User\UserProfileDto + output: App\Dto\User\UserProfileDto + openapi_context: + summary: "Update user profile" + description: "Update user profile" + + # Update user password + password_update: + method: PATCH + status: 200 + path: /users/{userSid}/change-password + input: App\Dto\User\UserPasswordDto + output: App\Dto\General\StatusDto + openapi_context: + summary: "Update user password" + description: "Update user password" + + # collectionOperations + collectionOperations: [] \ No newline at end of file diff --git a/symfony/config/api_platform/resources/user_devices.yaml b/symfony/config/api_platform/resources/user_devices.yaml new file mode 100644 index 0000000..a420ce4 --- /dev/null +++ b/symfony/config/api_platform/resources/user_devices.yaml @@ -0,0 +1,31 @@ +App\ApiResource\User\UserDevice: + shortName: "UserDevices" + attributes: + normalization_context: + groups: [ "api-get", "device:api-get" ] + swagger_definition_name: "GET" + denormalization_context: + groups: [ "api-post", "device:api-post" ] + swagger_definition_name: "PATCH" + + # itemOperations + itemOperations: + # Get user device. + get: + path: /users/devices/{deviceSid} + output: App\Dto\User\UserDeviceDto + openapi_context: + summary: "Get user device" + description: "Get user device" + + # Update user device. + patch: + path: /users/devices/{deviceSid} + input: App\Dto\User\UserDeviceDto + output: App\Dto\User\UserDeviceDto + openapi_context: + summary: "Update user device token" + description: "Update user device token" + + # collectionOperations + collectionOperations: [] \ No newline at end of file diff --git a/symfony/config/packages/api_platform.yaml b/symfony/config/packages/api_platform.yaml index 65a0978..409039d 100644 --- a/symfony/config/packages/api_platform.yaml +++ b/symfony/config/packages/api_platform.yaml @@ -1,5 +1,5 @@ api_platform: - title: 'icapps ❤ PHP' + title: 'PHP Boilerplate' description: 'Template project using API platform' version: '1.0.0' @@ -110,7 +110,7 @@ api_platform: # Mapping entities. mapping: - paths: ['%kernel.project_dir%/src/Entity', '%kernel.project_dir%/src/ApiResource'] + paths: ['%kernel.project_dir%/config/api_platform/resources'] patch_formats: json: ['application/merge-patch+json'] @@ -127,8 +127,8 @@ api_platform: formats: json: mime_types: ['application/json'] -# jsonld: -# mime_types: ['application/ld+json'] + jsonld: + mime_types: ['application/ld+json'] html: mime_types: ['text/html'] @@ -136,10 +136,10 @@ api_platform: error_formats: json: mime_types: [ 'application/json' ] + jsonld: + mime_types: ['application/ld+json'] # jsonproblem: # mime_types: ['application/problem+json'] -# jsonld: -# mime_types: ['application/ld+json'] # Global resources defaults defaults: diff --git a/symfony/src/ApiResource/Auth/Authentication.php b/symfony/src/ApiResource/Auth/Authentication.php index 16c7db9..0f65b1e 100644 --- a/symfony/src/ApiResource/Auth/Authentication.php +++ b/symfony/src/ApiResource/Auth/Authentication.php @@ -2,61 +2,6 @@ namespace App\ApiResource\Auth; -use ApiPlatform\Core\Annotation\ApiResource; -use App\Dto\Auth\AuthAccessDto; -use App\Dto\General\StatusDto; -use App\Dto\Auth\UserLogoutDto; -use App\Dto\Auth\UserPasswordResetDto; -use App\Dto\Auth\UserRefreshDto; - -/** - * @ApiResource( - * routePrefix=AuthAccessDto::AUTH_ROUTE_PREFIX, - * collectionOperations={ - * "post_refresh_api"={ - * "status"=200, - * "path"="/refresh-token", - * "input"=UserRefreshDto::class, - * "method"="POST", - * "openapi_context"={ - * "summary"="Refresh user access token", - * "description"="Refresh user access token" - * } - * }, - * "post_logout_api"={ - * "status"=200, - * "path"="/logout", - * "input"=UserLogoutDto::class, - * "method"="POST", - * "openapi_context"={ - * "summary"="Logout user", - * "description"="Logout user, remove session and device" - * }, - * }, - * "post_password_reset_api"={ - * "status"=200, - * "path"="/forgot-password/init", - * "input"=UserPasswordResetDto::class, - * "method"="POST", - * "openapi_context"={ - * "summary"="Initiate user forgot password flow", - * "description"="Initiate a user forgot password flow by sending an email to the user" - * } - * } - * }, - * itemOperations={}, - * shortName="Authentication", - * normalizationContext={ - * "groups"={"auth:api-get", "api-get"}, - * "swagger_definition_name"="GET" - * }, - * denormalizationContext={ - * "groups"={"auth:api-post", "api-post"}, - * "swagger_definition_name"="POST" - * }, - * output=StatusDto::class - * ) - */ final class Authentication { public int $id; diff --git a/symfony/src/ApiResource/Auth/Register.php b/symfony/src/ApiResource/Auth/Register.php index 5fbd639..4456985 100644 --- a/symfony/src/ApiResource/Auth/Register.php +++ b/symfony/src/ApiResource/Auth/Register.php @@ -2,46 +2,6 @@ namespace App\ApiResource\Auth; -use ApiPlatform\Core\Annotation\ApiResource; -use App\Dto\Auth\AuthAccessDto; -use App\Dto\Auth\UserRegisterDto; -use App\Dto\User\UserProfileDto; - -/** - * @ApiResource( - * routePrefix=AuthAccessDto::AUTH_ROUTE_PREFIX, - * collectionOperations={ - * "post_register_api"={ - * "path"= "/register", - * "method"="POST", - * "openapi_context"={ - * "summary"="Register a new user", - * "description"="Register a new user" - * } - * } - * }, - * itemOperations={}, - * shortName="Register", - * normalizationContext={ - * "groups"={"api-get", "register:api-get"}, - * "swagger_definition_name"="GET", - * "openapi_context"={ - * "summary"="Register a new user", - * "description"="Register a new user" - * } - * }, - * denormalizationContext={ - * "groups"={"register:api-post"}, - * "swagger_definition_name"="POST", - * "openapi_context"={ - * "summary"="Register a new user", - * "description"="Register a new user" - * } - * }, - * input=UserRegisterDto::class, - * output=UserProfileDto::class - * ) - */ final class Register { public int $id; diff --git a/symfony/src/ApiResource/User/User.php b/symfony/src/ApiResource/User/User.php index 4288946..9b773fd 100644 --- a/symfony/src/ApiResource/User/User.php +++ b/symfony/src/ApiResource/User/User.php @@ -3,54 +3,7 @@ namespace App\ApiResource\User; use ApiPlatform\Core\Annotation\ApiProperty; -use ApiPlatform\Core\Annotation\ApiResource; -use App\Dto\General\StatusDto; -use App\Dto\User\UserProfileDto; -use App\Dto\User\UserPasswordDto; -/** - * @ApiResource( - * routePrefix=UserProfileDto::USER_ROUTE_PREFIX, - * collectionOperations={}, - * itemOperations={ - * "get"={ - * "path"= "/{userSid}/profile", - * "openapi_context"={ - * "summary"="Get active user profile", - * "description"="Get active user profile" - * } - * }, - * "patch"={ - * "path"= "/{userSid}/profile", - * "openapi_context"={ - * "summary"="Update user profile", - * "description"="Update user profile" - * } - * }, - * "password_update"={ - * "status"=200, - * "path"="/{userSid}/password", - * "input"=UserPasswordDto::class, - * "output"=StatusDto::class, - * "method"="PATCH", - * "openapi_context"={ - * "summary"="Update user password", - * "description"="Update user password" - * }, - * }, - * }, - * normalizationContext={ - * "groups"={"api-get", "profile:api-get"}, - * "swagger_definition_name"="GET" - * }, - * denormalizationContext={ - * "groups"={"api-post", "profile:api-post", "password:api-post"}, - * "swagger_definition_name"="PATCH" - * }, - * input=UserProfileDto::class, - * output=UserProfileDto::class - * ) - */ final class User { /** diff --git a/symfony/src/ApiResource/User/UserDevice.php b/symfony/src/ApiResource/User/UserDevice.php index 8afa2f0..c319dca 100644 --- a/symfony/src/ApiResource/User/UserDevice.php +++ b/symfony/src/ApiResource/User/UserDevice.php @@ -3,48 +3,13 @@ namespace App\ApiResource\User; use ApiPlatform\Core\Annotation\ApiProperty; -use ApiPlatform\Core\Annotation\ApiResource; -use App\Dto\User\UserDeviceDto; -/** - * @ApiResource( - * routePrefix=UserDeviceDto::USER_DEVICE_ROUTE_PREFIX, - * collectionOperations={}, - * itemOperations={ - * "get"={ - * "path"= "/{deviceSid}", - * "method"="GET", - * "openapi_context"={ - * "summary"="Get user device", - * "description"="Get user device" - * } - * }, - * "update"={ - * "path"= "/{deviceSid}", - * "method"="PATCH", - * "openapi_context"={ - * "summary"="Update user device token", - * "description"="Update user device token" - * } - * } - * }, - * normalizationContext={ - * "groups"={"api-get"}, - * "swagger_definition_name"="GET" - * }, - * denormalizationContext={ - * "groups"={"api-post"}, - * "swagger_definition_name"="PATCH" - * }, - * shortName="Devices", - * input=UserDeviceDto::class, - * output=UserDeviceDto::class - * ) - */ final class UserDevice { /** * @ApiProperty(identifier=true) + * + * The device string identifier. */ public string $deviceSid; } diff --git a/symfony/src/Dto/Auth/AuthAccessDto.php b/symfony/src/Dto/Auth/AuthAccessDto.php index 8e0e0b5..dfa1719 100644 --- a/symfony/src/Dto/Auth/AuthAccessDto.php +++ b/symfony/src/Dto/Auth/AuthAccessDto.php @@ -12,6 +12,7 @@ */ final class AuthAccessDto { + // Used in Swagger alterations. public const AUTH_ROUTE_PREFIX = '/auth'; public const AUTH_LOGIN_URL = '/api/auth/login'; public const AUTH_ME_URL = '/api/auth/me'; diff --git a/symfony/src/Dto/Auth/UserLoginDto.php b/symfony/src/Dto/Auth/UserLoginDto.php new file mode 100644 index 0000000..64ad3e4 --- /dev/null +++ b/symfony/src/Dto/Auth/UserLoginDto.php @@ -0,0 +1,80 @@ +email = $parameters['email'] ?? null; + $this->password = $parameters['password'] ?? null; + $this->deviceSid = $parameters['deviceSid'] ?? null; + $this->deviceToken = $parameters['deviceToken'] ?? null; + } +} diff --git a/symfony/src/Dto/Auth/UserRegisterDto.php b/symfony/src/Dto/Auth/UserRegisterDto.php index acb067d..5ccce88 100644 --- a/symfony/src/Dto/Auth/UserRegisterDto.php +++ b/symfony/src/Dto/Auth/UserRegisterDto.php @@ -77,7 +77,6 @@ final class UserRegisterDto */ public string $language; - /** * @var string * diff --git a/symfony/src/Dto/User/UserDeviceDto.php b/symfony/src/Dto/User/UserDeviceDto.php index 5345c32..cab1089 100644 --- a/symfony/src/Dto/User/UserDeviceDto.php +++ b/symfony/src/Dto/User/UserDeviceDto.php @@ -12,8 +12,6 @@ */ final class UserDeviceDto { - public const USER_DEVICE_ROUTE_PREFIX = '/users/devices'; - public const USER_DEVICE_BUNDLE_TAG = 'User'; /** * @var string diff --git a/symfony/src/Dto/User/UserProfileDto.php b/symfony/src/Dto/User/UserProfileDto.php index d938809..4d7832b 100644 --- a/symfony/src/Dto/User/UserProfileDto.php +++ b/symfony/src/Dto/User/UserProfileDto.php @@ -12,8 +12,6 @@ */ final class UserProfileDto { - public const USER_ROUTE_PREFIX = '/users'; - public const USER_BUNDLE_TAG = 'User'; /** * @var string diff --git a/symfony/src/Entity/User.php b/symfony/src/Entity/User.php index d3d3091..ed9cee8 100644 --- a/symfony/src/Entity/User.php +++ b/symfony/src/Entity/User.php @@ -27,7 +27,7 @@ * @UniqueEntity( * fields={"email"}, * message="icapps.registration.email.unique", - * groups={"orm-registration", "orm-user-update"} + * groups={"orm-registration", "orm-user-update"}, * ) * @ORM\Entity(repositoryClass=UserRepository::class) */ diff --git a/symfony/src/Exception/ApiException.php b/symfony/src/Exception/ApiException.php deleted file mode 100644 index 4625326..0000000 --- a/symfony/src/Exception/ApiException.php +++ /dev/null @@ -1,13 +0,0 @@ -requestStack->getCurrentRequest()) { - throw new ApiException( - Response::HTTP_UNPROCESSABLE_ENTITY, - 'Unable to process request.' - ); + throw new ApiHttpException('Unable to process request.', Response::HTTP_BAD_REQUEST); } // Get route. @@ -78,22 +76,12 @@ public function loadUserByPayload(string $email, array $payload): mixed $requestContent = $request->getContent(); $requestParams = $jsonEncoder->decode($requestContent, JsonEncoder::FORMAT); - // Validate email. - $errors = $this->validator->validate($email, new Assert\Email()); - if ($errors->count()) { - throw new ApiException( - Response::HTTP_UNPROCESSABLE_ENTITY, - sprintf('The provided email "%s" is invalid.', $email) - ); - } + // Validate input. + $input = new UserLoginDto($requestParams); - // Validate device. - // @TODO:: validate deviceSid and deviceToken? - if (!isset($requestParams['deviceSid']) || !isset($requestParams['deviceToken'])) { - throw new ApiException( - Response::HTTP_UNPROCESSABLE_ENTITY, - sprintf('Both "%s" and "%s" must be provided.', 'deviceSid', 'deviceToken') - ); + $violations = $this->validator->validate($input); + if ($violations->count()) { + throw new ValidationException($violations); } } diff --git a/symfony/src/Service/Api/DataPersister/User/UserPasswordDataPersister.php b/symfony/src/Service/Api/DataPersister/User/UserPasswordDataPersister.php index 9108d28..2313d0c 100644 --- a/symfony/src/Service/Api/DataPersister/User/UserPasswordDataPersister.php +++ b/symfony/src/Service/Api/DataPersister/User/UserPasswordDataPersister.php @@ -2,12 +2,13 @@ namespace App\Service\Api\DataPersister\User; +use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; use ApiPlatform\Core\DataPersister\DataPersisterInterface; use App\Dto\General\StatusDto; use App\Dto\User\UserPasswordDto; use App\Entity\User; -use App\Exception\ApiException; use App\Repository\UserRepository; +use App\Utils\ConstraintViolationUtils; use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\ORMException; use Symfony\Component\HttpFoundation\Response; @@ -56,8 +57,13 @@ public function persist($data) // Check password. /** @var UserPasswordDto $data */ if (!$this->passwordEncoder->isPasswordValid($user, $data->oldPassword)) { - $error = $this->translator->trans('icapps.registration.password.invalid', [], 'validators'); - throw new ApiException(Response::HTTP_UNPROCESSABLE_ENTITY, $error); + $violations = ConstraintViolationUtils::createViolationList( + $this->translator->trans('icapps.registration.password.invalid', [], 'validators'), + 'oldPassword', + $data->oldPassword + ); + + throw new ValidationException($violations); } // Update password. diff --git a/symfony/src/Service/Api/Swagger/SwaggerDecorator.php b/symfony/src/Service/Api/Swagger/SwaggerDecorator.php index 88c979a..1e07a85 100644 --- a/symfony/src/Service/Api/Swagger/SwaggerDecorator.php +++ b/symfony/src/Service/Api/Swagger/SwaggerDecorator.php @@ -71,6 +71,7 @@ public function __invoke(array $context = []): OpenApi ); // Include JWT DTOs. + // @TODO:: can't we include \DTO\Auth\AuthAccessDto()? $schemas['Authentication.UserLoginDto-GET'] = new \ArrayObject([ 'type' => 'object', 'required' => ['token', 'refreshToken'], @@ -84,6 +85,7 @@ public function __invoke(array $context = []): OpenApi ], ]); + // @TODO:: can't we include \DTO\Auth\UserLoginDto()? $schemas['Authentication.UserLoginDto-POST'] = new \ArrayObject([ 'type' => 'object', 'required' => ['email', 'password', 'deviceSid', 'deviceToken'], @@ -126,7 +128,7 @@ public function __invoke(array $context = []): OpenApi ], ], '400' => [ - 'description' => 'Validation error', + 'description' => 'Invalid input', ], '401' => [ 'description' => 'Authentication error', diff --git a/symfony/src/Utils/ConstraintViolationUtils.php b/symfony/src/Utils/ConstraintViolationUtils.php new file mode 100644 index 0000000..22274ec --- /dev/null +++ b/symfony/src/Utils/ConstraintViolationUtils.php @@ -0,0 +1,44 @@ + $invalidValue + ]; + + $violation = new ConstraintViolation( + $message, + $message, + $violationParameters, + $invalidValue, + $propertyPath, + $invalidValue + ); + + $violationList->add($violation); + + return $violationList; + } +}