From 7b530195bfdcea5058ddfbf05b3e1c3ff3990660 Mon Sep 17 00:00:00 2001 From: Dylan Corroyer Date: Fri, 20 Dec 2024 23:34:36 +0100 Subject: [PATCH] Refactor backend for feature mt-47 --- .../Account/CreateAccountController.php | 27 +- .../Account/DeleteAccountController.php | 32 +-- .../Account/GetAccountController.php | 24 +- .../Account/ListAccountController.php | 24 +- .../Account/UpdateAccountController.php | 25 +- .../Authentication/RegisterController.php | 29 +- .../GetMonthlyBalanceHistoryController.php | 22 +- .../Budget/CreateBudgetController.php | 29 +- .../Budget/DeleteBudgetController.php | 31 +- .../Budget/DuplicateBudgetController.php | 24 +- .../Controller/Budget/GetBudgetController.php | 26 +- .../Budget/ListBudgetController.php | 30 +- .../Budget/UpdateBudgetController.php | 26 +- .../CreateTransactionController.php | 27 +- .../DeleteTransactionController.php | 32 +-- .../Transaction/GetTransactionController.php | 20 +- .../Transaction/ListTransactionController.php | 26 +- .../UpdateTransactionController.php | 25 +- app/src/Controller/User/GetUserController.php | 26 +- app/src/Repository/AccountRepository.php | 2 +- .../Repository/BalanceHistoryRepository.php | 2 +- app/src/Repository/BudgetRepository.php | 2 +- app/src/Repository/ExpenseRepository.php | 2 +- app/src/Repository/IncomeRepository.php | 2 +- app/src/Repository/TransactionRepository.php | 2 +- app/src/Repository/UserRepository.php | 3 +- app/src/Shared/Api/AbstractApiController.php | 61 ++++ .../FilterQueryDefinitionInterface.php | 12 + ...MQueryBuilderFilterQueryAwareInterface.php | 12 + .../Api/Doctrine/Filter/FilterDefinition.php | 33 +++ .../Doctrine/Filter/FilterDefinitionBag.php | 62 ++++ .../Shared/Api/Doctrine/Filter/FilterJoin.php | 31 ++ .../ComparisonOperator/EqualOperator.php | 36 +++ .../ComparisonOperator/NotEqualOperator.php | 36 +++ .../Filter/Operator/OperatorInterface.php | 15 + .../Trait/QueryBuilderParameterTrait.php | 28 ++ app/src/Shared/Api/Doctrine/Paginator.php | 83 ++++++ .../Api/Dto/Adapter/ApiMetaInterface.php | 21 ++ app/src/Shared/Api/Dto/Dto/ApiError.php | 25 ++ app/src/Shared/Api/Dto/Dto/FieldApiError.php | 16 ++ .../Shared/Api/Dto/Meta/PaginationMeta.php | 48 ++++ .../Api/Dto/Response/AbstractApiResponse.php | 9 + .../Shared/Api/Dto/Response/ErrorResponse.php | 26 ++ .../Api/Dto/Response/SuccessResponse.php | 28 ++ app/src/Shared/Api/Mapper/ApiMapper.php | 79 +++++ .../Shared/Api/Mapper/MappingException.php | 13 + .../Api/Nelmio/Attribute/BooleanResponse.php | 56 ++++ .../Api/Nelmio/Attribute/ErrorResponse.php | 40 +++ .../Nelmio/Attribute/NoContentResponse.php | 20 ++ .../Api/Nelmio/Attribute/SuccessResponse.php | 65 +++++ .../Api/Security/Attribute/Sensitive.php | 21 ++ .../Resolver/PartialUpdateResolver.php | 271 ++++++++++++++++++ .../Validation/ValidationErrorCodeEnum.php | 116 ++++++++ .../Repository/AbstractEntityRepository.php | 100 +++++++ .../AbstractDomainModelNotFoundException.php | 25 ++ .../Account/CreateAccountControllerTest.php | 3 +- .../Account/GetAccountControllerTest.php | 3 +- .../Account/ListAccountControllerTest.php | 3 +- .../Account/UpdateAccountControllerTest.php | 3 +- .../Authentication/RegisterControllerTest.php | 3 +- ...GetMonthlyBalanceHistoryControllerTest.php | 30 +- .../Budget/CreateBudgetControllerTest.php | 5 +- .../Budget/DuplicateBudgetControllerTest.php | 3 +- .../Budget/GetBudgetControllerTest.php | 3 +- .../Budget/ListBudgetControllerTest.php | 3 +- .../Budget/UpdateBudgetControllerTest.php | 5 +- .../CreateTransactionControllerTest.php | 9 +- .../GetTransactionControllerTest.php | 5 +- .../ListTransactionControllerTest.php | 3 +- .../UpdateTransactionControllerTest.php | 7 +- .../Functional/User/GetUserControllerTest.php | 5 +- 71 files changed, 1571 insertions(+), 430 deletions(-) create mode 100644 app/src/Shared/Api/AbstractApiController.php create mode 100644 app/src/Shared/Api/Doctrine/Filter/Adapter/FilterQueryDefinitionInterface.php create mode 100644 app/src/Shared/Api/Doctrine/Filter/Adapter/ORMQueryBuilderFilterQueryAwareInterface.php create mode 100644 app/src/Shared/Api/Doctrine/Filter/FilterDefinition.php create mode 100644 app/src/Shared/Api/Doctrine/Filter/FilterDefinitionBag.php create mode 100644 app/src/Shared/Api/Doctrine/Filter/FilterJoin.php create mode 100644 app/src/Shared/Api/Doctrine/Filter/Operator/ComparisonOperator/EqualOperator.php create mode 100644 app/src/Shared/Api/Doctrine/Filter/Operator/ComparisonOperator/NotEqualOperator.php create mode 100644 app/src/Shared/Api/Doctrine/Filter/Operator/OperatorInterface.php create mode 100644 app/src/Shared/Api/Doctrine/Filter/Trait/QueryBuilderParameterTrait.php create mode 100644 app/src/Shared/Api/Doctrine/Paginator.php create mode 100644 app/src/Shared/Api/Dto/Adapter/ApiMetaInterface.php create mode 100644 app/src/Shared/Api/Dto/Dto/ApiError.php create mode 100644 app/src/Shared/Api/Dto/Dto/FieldApiError.php create mode 100644 app/src/Shared/Api/Dto/Meta/PaginationMeta.php create mode 100644 app/src/Shared/Api/Dto/Response/AbstractApiResponse.php create mode 100644 app/src/Shared/Api/Dto/Response/ErrorResponse.php create mode 100644 app/src/Shared/Api/Dto/Response/SuccessResponse.php create mode 100644 app/src/Shared/Api/Mapper/ApiMapper.php create mode 100644 app/src/Shared/Api/Mapper/MappingException.php create mode 100644 app/src/Shared/Api/Nelmio/Attribute/BooleanResponse.php create mode 100644 app/src/Shared/Api/Nelmio/Attribute/ErrorResponse.php create mode 100644 app/src/Shared/Api/Nelmio/Attribute/NoContentResponse.php create mode 100644 app/src/Shared/Api/Nelmio/Attribute/SuccessResponse.php create mode 100644 app/src/Shared/Api/Security/Attribute/Sensitive.php create mode 100644 app/src/Shared/Api/Symfony/Resolver/PartialUpdateResolver.php create mode 100644 app/src/Shared/Api/Symfony/Validation/ValidationErrorCodeEnum.php create mode 100644 app/src/Shared/Doctrine/Repository/AbstractEntityRepository.php create mode 100644 app/src/Shared/Exception/AbstractDomainModelNotFoundException.php diff --git a/app/src/Controller/Account/CreateAccountController.php b/app/src/Controller/Account/CreateAccountController.php index 509cd86..96d2fcc 100644 --- a/app/src/Controller/Account/CreateAccountController.php +++ b/app/src/Controller/Account/CreateAccountController.php @@ -7,10 +7,9 @@ use App\Dto\Account\Payload\AccountPayload; use App\Dto\Account\Response\AccountResponse; use App\Service\AccountService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -18,23 +17,11 @@ use Symfony\Component\Routing\Attribute\Route; #[Route('/accounts')] -#[OA\Tag(name: 'Accounts')] -class CreateAccountController extends BaseRestController +#[Tag(name: 'Accounts')] +class CreateAccountController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_POST, - operationId: 'post_account', - summary: 'post account', - responses: [ - new SuccessResponse( - responseClassFqcn: AccountResponse::class, - responseCode: Response::HTTP_CREATED, - description: 'Account creation', - ), - ], - requestBodyClassFqcn: AccountPayload::class - )] - #[Route('', name: 'api_accounts_create', methods: Request::METHOD_POST)] + #[SuccessResponse(dataFqcn: AccountResponse::class, description: 'Create an account')] + #[Route('', name: __METHOD__, methods: Request::METHOD_POST)] public function __invoke( AccountService $accountService, #[MapRequestPayload] AccountPayload $accountPayload diff --git a/app/src/Controller/Account/DeleteAccountController.php b/app/src/Controller/Account/DeleteAccountController.php index b63bd71..8131d83 100644 --- a/app/src/Controller/Account/DeleteAccountController.php +++ b/app/src/Controller/Account/DeleteAccountController.php @@ -4,41 +4,25 @@ namespace App\Controller\Account; -use App\Dto\Account\Response\AccountResponse; use App\Entity\Account; use App\Service\AccountService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\NotFoundResponse; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\NoContentResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('/accounts')] -#[OA\Tag(name: 'Accounts')] -class DeleteAccountController extends BaseRestController +#[Tag(name: 'Accounts')] +class DeleteAccountController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_DELETE, - operationId: 'delete_account', - summary: 'delete account', - responses: [ - new SuccessResponse( - responseClassFqcn: AccountResponse::class, - responseCode: Response::HTTP_NO_CONTENT, - description: 'Account deleted' - ), - new NotFoundResponse(description: 'Account not found'), - ], - )] - #[Route('/{id}', name: 'api_accounts_delete', methods: Request::METHOD_DELETE)] + #[NoContentResponse(description: 'Delete an account')] + #[Route('/{id}', name: __METHOD__, methods: Request::METHOD_DELETE)] public function __invoke(AccountService $accountService, Account $account): JsonResponse { $accountService->delete($account); - return $this->successResponse(data: [], status: Response::HTTP_NO_CONTENT); + return $this->noContentResponse(); } } diff --git a/app/src/Controller/Account/GetAccountController.php b/app/src/Controller/Account/GetAccountController.php index 2b72d59..77ba48f 100644 --- a/app/src/Controller/Account/GetAccountController.php +++ b/app/src/Controller/Account/GetAccountController.php @@ -6,29 +6,19 @@ use App\Dto\Account\Response\AccountResponse; use App\Service\AccountService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\NotFoundResponse; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; #[Route('/accounts')] -#[OA\Tag(name: 'Accounts')] -class GetAccountController extends BaseRestController +#[Tag(name: 'Accounts')] +class GetAccountController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_GET, - operationId: 'get_account', - summary: 'get account', - responses: [ - new SuccessResponse(responseClassFqcn: AccountResponse::class, description: 'Account get'), - new NotFoundResponse(description: 'Account not found'), - ], - )] - #[Route('/{id}', name: 'api_accounts_get', methods: Request::METHOD_GET)] + #[SuccessResponse(dataFqcn: AccountResponse::class, description: 'Get an account')] + #[Route('/{id}', name: __METHOD__, methods: Request::METHOD_GET)] public function __invoke(int $id, AccountService $accountService): JsonResponse { return $this->successResponse(data: $accountService->getExternal($id)); diff --git a/app/src/Controller/Account/ListAccountController.php b/app/src/Controller/Account/ListAccountController.php index e942360..22348f2 100644 --- a/app/src/Controller/Account/ListAccountController.php +++ b/app/src/Controller/Account/ListAccountController.php @@ -6,29 +6,19 @@ use App\Dto\Account\Response\AccountResponse; use App\Service\AccountService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use My\RestBundle\Dto\PaginationQueryParams; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; #[Route('/accounts')] -#[OA\Tag(name: 'Accounts')] -class ListAccountController extends BaseRestController +#[Tag(name: 'Accounts')] +class ListAccountController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_GET, - operationId: 'list_account', - summary: 'list account', - responses: [ - new SuccessResponse(responseClassFqcn: AccountResponse::class, description: 'Return the list of accounts'), - ], - queryParamsClassFqcn: [PaginationQueryParams::class], - )] - #[Route('', name: 'api_accounts_list', methods: Request::METHOD_GET)] + #[SuccessResponse(dataFqcn: AccountResponse::class, description: 'Get accounts list')] + #[Route('', name: __METHOD__, methods: Request::METHOD_GET)] public function __invoke(AccountService $accountService): JsonResponse { return $this->successResponse(data: $accountService->listExternal()); diff --git a/app/src/Controller/Account/UpdateAccountController.php b/app/src/Controller/Account/UpdateAccountController.php index 689c35a..c15a5f0 100644 --- a/app/src/Controller/Account/UpdateAccountController.php +++ b/app/src/Controller/Account/UpdateAccountController.php @@ -8,31 +8,20 @@ use App\Dto\Account\Response\AccountResponse; use App\Entity\Account; use App\Service\AccountService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\NotFoundResponse; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Routing\Attribute\Route; #[Route('/accounts')] -#[OA\Tag(name: 'Accounts')] -class UpdateAccountController extends BaseRestController +#[Tag(name: 'Accounts')] +class UpdateAccountController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_PATCH, - operationId: 'put_account', - summary: 'put account', - responses: [ - new SuccessResponse(responseClassFqcn: AccountResponse::class, description: 'Account updated'), - new NotFoundResponse(description: 'Account not found'), - ], - requestBodyClassFqcn: AccountPayload::class - )] - #[Route('/{id}', name: 'api_account_update', methods: Request::METHOD_PATCH)] + #[SuccessResponse(dataFqcn: AccountResponse::class, description: 'update an account')] + #[Route('/{id}', name: __METHOD__, methods: Request::METHOD_PATCH)] public function __invoke( AccountService $accountService, Account $account, diff --git a/app/src/Controller/Authentication/RegisterController.php b/app/src/Controller/Authentication/RegisterController.php index 00f06e9..3bd43a4 100644 --- a/app/src/Controller/Authentication/RegisterController.php +++ b/app/src/Controller/Authentication/RegisterController.php @@ -5,35 +5,22 @@ namespace App\Controller\Authentication; use App\Dto\User\Payload\RegisterPayload; -use App\Entity\User; +use App\Dto\User\Response\UserResponse; use App\Service\UserService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Routing\Attribute\Route; -#[OA\Tag(name: 'Authentication')] -class RegisterController extends BaseRestController +#[Tag(name: 'Authentication')] +class RegisterController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_POST, - operationId: 'post_register', - summary: 'post register', - responses: [ - new SuccessResponse( - responseClassFqcn: User::class, - responseCode: Response::HTTP_CREATED, - description: 'register user', - ), - ], - requestBodyClassFqcn: RegisterPayload::class - )] - #[Route('/register', name: 'api_register', methods: Request::METHOD_POST)] + #[SuccessResponse(dataFqcn: UserResponse::class, description: 'Create a user')] + #[Route('/register', name: __METHOD__, methods: Request::METHOD_POST)] public function __invoke(UserService $userService, #[MapRequestPayload] RegisterPayload $payload): JsonResponse { return $this->successResponse(data: $userService->create($payload), status: Response::HTTP_CREATED); diff --git a/app/src/Controller/BalanceHistory/GetMonthlyBalanceHistoryController.php b/app/src/Controller/BalanceHistory/GetMonthlyBalanceHistoryController.php index 4832243..e8c9c03 100644 --- a/app/src/Controller/BalanceHistory/GetMonthlyBalanceHistoryController.php +++ b/app/src/Controller/BalanceHistory/GetMonthlyBalanceHistoryController.php @@ -7,9 +7,8 @@ use App\Dto\BalanceHistory\Http\BalanceHistoryFilterQuery; use App\Dto\BalanceHistory\Response\BalanceHistoryResponse; use App\Service\BalanceHistoryService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -18,21 +17,10 @@ #[Route('/accounts/balance-history', priority: 10)] #[OA\Tag(name: 'Balance History')] -class GetMonthlyBalanceHistoryController extends BaseRestController +class GetMonthlyBalanceHistoryController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_GET, - operationId: 'get_balance_history', - summary: 'Get balance history', - responses: [ - new SuccessResponse( - responseClassFqcn: BalanceHistoryResponse::class, - description: 'Return the balance history', - ), - ], - queryParamsClassFqcn: [BalanceHistoryFilterQuery::class], - )] - #[Route('', name: 'api_balance_history', methods: Request::METHOD_GET)] + #[SuccessResponse(dataFqcn: BalanceHistoryResponse::class, description: 'Get monthly balance history')] + #[Route('', name: __METHOD__, methods: Request::METHOD_GET)] public function __invoke( BalanceHistoryService $balanceHistoryService, #[MapQueryString] ?BalanceHistoryFilterQuery $filter = null, diff --git a/app/src/Controller/Budget/CreateBudgetController.php b/app/src/Controller/Budget/CreateBudgetController.php index d2c3402..4c587ae 100644 --- a/app/src/Controller/Budget/CreateBudgetController.php +++ b/app/src/Controller/Budget/CreateBudgetController.php @@ -5,12 +5,11 @@ namespace App\Controller\Budget; use App\Dto\Budget\Payload\BudgetPayload; -use App\Entity\Budget; +use App\Dto\Budget\Response\BudgetResponse; use App\Service\BudgetService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -18,23 +17,11 @@ use Symfony\Component\Routing\Attribute\Route; #[Route('/budgets')] -#[OA\Tag(name: 'Budgets')] -class CreateBudgetController extends BaseRestController +#[Tag(name: 'Budgets')] +class CreateBudgetController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_POST, - operationId: 'post_budget', - summary: 'post budget', - responses: [ - new SuccessResponse( - responseClassFqcn: Budget::class, - responseCode: Response::HTTP_CREATED, - description: 'Budget creation', - ), - ], - requestBodyClassFqcn: BudgetPayload::class - )] - #[Route('', name: 'api_budgets_create', methods: Request::METHOD_POST)] + #[SuccessResponse(dataFqcn: BudgetResponse::class, description: 'Create a budget')] + #[Route('', name: __METHOD__, methods: Request::METHOD_POST)] public function __invoke( BudgetService $budgetService, #[MapRequestPayload] BudgetPayload $budgetPayload diff --git a/app/src/Controller/Budget/DeleteBudgetController.php b/app/src/Controller/Budget/DeleteBudgetController.php index 4bea3cc..b860623 100644 --- a/app/src/Controller/Budget/DeleteBudgetController.php +++ b/app/src/Controller/Budget/DeleteBudgetController.php @@ -6,38 +6,23 @@ use App\Entity\Budget; use App\Service\BudgetService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\NotFoundResponse; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\NoContentResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('/budgets')] -#[OA\Tag(name: 'Budgets')] -class DeleteBudgetController extends BaseRestController +#[Tag(name: 'Budgets')] +class DeleteBudgetController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_DELETE, - operationId: 'delete_budget', - summary: 'delete budget', - responses: [ - new SuccessResponse( - responseClassFqcn: Budget::class, - responseCode: Response::HTTP_NO_CONTENT, - description: 'Budget deleted' - ), - new NotFoundResponse(description: 'Budget not found'), - ], - )] - #[Route('/{id}', name: 'api_budgets_delete', methods: Request::METHOD_DELETE)] + #[NoContentResponse(description: 'Delete a budget')] + #[Route('/{id}', name: __METHOD__, methods: Request::METHOD_DELETE)] public function __invoke(BudgetService $budgetService, Budget $budget): JsonResponse { $budgetService->delete($budget); - return $this->successResponse(data: [], status: Response::HTTP_NO_CONTENT); + return $this->noContentResponse(); } } diff --git a/app/src/Controller/Budget/DuplicateBudgetController.php b/app/src/Controller/Budget/DuplicateBudgetController.php index 81ddff7..ce9cca6 100644 --- a/app/src/Controller/Budget/DuplicateBudgetController.php +++ b/app/src/Controller/Budget/DuplicateBudgetController.php @@ -4,11 +4,10 @@ namespace App\Controller\Budget; -use App\Entity\Budget; +use App\Dto\Budget\Response\BudgetResponse; use App\Service\BudgetService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -17,21 +16,10 @@ #[Route('/budgets')] #[OA\Tag(name: 'Budgets')] -class DuplicateBudgetController extends BaseRestController +class DuplicateBudgetController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_POST, - operationId: 'duplicate_budget', - summary: 'duplicate budget', - responses: [ - new SuccessResponse( - responseClassFqcn: Budget::class, - responseCode: Response::HTTP_CREATED, - description: 'Budget duplication', - ), - ], - )] - #[Route('/duplicate/{id}', name: 'api_budgets_duplicate', methods: Request::METHOD_POST)] + #[SuccessResponse(dataFqcn: BudgetResponse::class, description: 'Duplicate a budget')] + #[Route('/duplicate/{id}', name: __METHOD__, methods: Request::METHOD_POST)] public function __invoke(BudgetService $budgetService, ?int $id = null): JsonResponse { return $this->successResponse(data: $budgetService->duplicate($id), status: Response::HTTP_CREATED); diff --git a/app/src/Controller/Budget/GetBudgetController.php b/app/src/Controller/Budget/GetBudgetController.php index f31ae06..573517a 100644 --- a/app/src/Controller/Budget/GetBudgetController.php +++ b/app/src/Controller/Budget/GetBudgetController.php @@ -4,31 +4,21 @@ namespace App\Controller\Budget; -use App\Entity\Budget; +use App\Dto\Budget\Response\BudgetResponse; use App\Service\BudgetService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\NotFoundResponse; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; #[Route('/budgets')] -#[OA\Tag(name: 'Budgets')] -class GetBudgetController extends BaseRestController +#[Tag(name: 'Budgets')] +class GetBudgetController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_GET, - operationId: 'get_budget', - summary: 'get budget', - responses: [ - new SuccessResponse(responseClassFqcn: Budget::class, description: 'Budget get'), - new NotFoundResponse(description: 'Budget not found'), - ], - )] - #[Route('/{id}', name: 'api_budgets_get', methods: Request::METHOD_GET)] + #[SuccessResponse(dataFqcn: BudgetResponse::class, description: 'Get a budget')] + #[Route('/{id}', name: __METHOD__, methods: Request::METHOD_GET)] public function __invoke(int $id, BudgetService $budgetService): JsonResponse { return $this->successResponse(data: $budgetService->get($id)); diff --git a/app/src/Controller/Budget/ListBudgetController.php b/app/src/Controller/Budget/ListBudgetController.php index 03501df..d5358f6 100644 --- a/app/src/Controller/Budget/ListBudgetController.php +++ b/app/src/Controller/Budget/ListBudgetController.php @@ -5,40 +5,28 @@ namespace App\Controller\Budget; use App\Dto\Budget\Http\BudgetFilterQuery; -use App\Entity\Budget; +use App\Dto\Budget\Response\BudgetResponse; use App\Service\BudgetService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\PaginatedSuccessResponse; -use My\RestBundle\Controller\BaseRestController; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; use My\RestBundle\Dto\PaginationQueryParams; -use OpenApi\Attributes as OA; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\MapQueryString; use Symfony\Component\Routing\Attribute\Route; #[Route('/budgets')] -#[OA\Tag(name: 'Budgets')] -class ListBudgetController extends BaseRestController +#[Tag(name: 'Budgets')] +class ListBudgetController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_GET, - operationId: 'list_budget', - summary: 'list budget', - responses: [ - new PaginatedSuccessResponse( - responseClassFqcn: Budget::class, - description: 'Return the paginated list of budgets' - ), - ], - queryParamsClassFqcn: [BudgetFilterQuery::class, PaginationQueryParams::class], - )] - #[Route('', name: 'api_budgets_list', methods: Request::METHOD_GET)] + #[SuccessResponse(dataFqcn: BudgetResponse::class, description: 'Get the budgets list', paginated: true)] + #[Route('', name: __METHOD__, methods: Request::METHOD_GET)] public function __invoke( BudgetService $budgetService, #[MapQueryString] ?PaginationQueryParams $paginationQueryParams = null, #[MapQueryString] ?BudgetFilterQuery $filter = null, ): JsonResponse { - return $this->paginatedResponse($budgetService->paginate($paginationQueryParams, $filter)); + return $this->successResponse(data: $budgetService->paginate($paginationQueryParams, $filter)); } } diff --git a/app/src/Controller/Budget/UpdateBudgetController.php b/app/src/Controller/Budget/UpdateBudgetController.php index f147fc5..2a6f523 100644 --- a/app/src/Controller/Budget/UpdateBudgetController.php +++ b/app/src/Controller/Budget/UpdateBudgetController.php @@ -5,33 +5,23 @@ namespace App\Controller\Budget; use App\Dto\Budget\Payload\BudgetPayload; +use App\Dto\Budget\Response\BudgetResponse; use App\Entity\Budget; use App\Service\BudgetService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\NotFoundResponse; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Routing\Attribute\Route; #[Route('/budgets')] -#[OA\Tag(name: 'Budgets')] -class UpdateBudgetController extends BaseRestController +#[Tag(name: 'Budgets')] +class UpdateBudgetController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_PUT, - operationId: 'put_budget', - summary: 'put budget', - responses: [ - new SuccessResponse(responseClassFqcn: Budget::class, description: 'Budget updated'), - new NotFoundResponse(description: 'Budget not found'), - ], - requestBodyClassFqcn: BudgetPayload::class - )] - #[Route('/{id}', name: 'api_budgets_update', methods: Request::METHOD_PUT)] + #[SuccessResponse(dataFqcn: BudgetResponse::class, description: 'Update a budget')] + #[Route('/{id}', name: __METHOD__, methods: Request::METHOD_PUT)] public function __invoke( BudgetService $budgetService, Budget $budget, diff --git a/app/src/Controller/Transaction/CreateTransactionController.php b/app/src/Controller/Transaction/CreateTransactionController.php index 090f169..52905b3 100644 --- a/app/src/Controller/Transaction/CreateTransactionController.php +++ b/app/src/Controller/Transaction/CreateTransactionController.php @@ -7,10 +7,9 @@ use App\Dto\Transaction\Payload\TransactionPayload; use App\Dto\Transaction\Response\TransactionResponse; use App\Service\TransactionService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -18,23 +17,11 @@ use Symfony\Component\Routing\Attribute\Route; #[Route('/accounts/{accountId}/transactions')] -#[OA\Tag(name: 'Transactions')] -class CreateTransactionController extends BaseRestController +#[Tag(name: 'Transactions')] +class CreateTransactionController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_POST, - operationId: 'post_transaction', - summary: 'post transaction', - responses: [ - new SuccessResponse( - responseClassFqcn: TransactionResponse::class, - responseCode: Response::HTTP_CREATED, - description: 'Transaction creation', - ), - ], - requestBodyClassFqcn: TransactionPayload::class - )] - #[Route('', name: 'api_transactions_create', methods: Request::METHOD_POST)] + #[SuccessResponse(dataFqcn: TransactionResponse::class, description: 'Create a transaction')] + #[Route('', name: __METHOD__, methods: Request::METHOD_POST)] public function __invoke( int $accountId, TransactionService $transactionService, diff --git a/app/src/Controller/Transaction/DeleteTransactionController.php b/app/src/Controller/Transaction/DeleteTransactionController.php index 92d94c0..30290fc 100644 --- a/app/src/Controller/Transaction/DeleteTransactionController.php +++ b/app/src/Controller/Transaction/DeleteTransactionController.php @@ -4,37 +4,21 @@ namespace App\Controller\Transaction; -use App\Dto\Transaction\Response\TransactionResponse; use App\Entity\Transaction; use App\Service\TransactionService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\NotFoundResponse; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\NoContentResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('/accounts/{accountId}/transactions')] -#[OA\Tag(name: 'Transactions')] -class DeleteTransactionController extends BaseRestController +#[Tag(name: 'Transactions')] +class DeleteTransactionController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_DELETE, - operationId: 'delete_transaction', - summary: 'delete transaction', - responses: [ - new SuccessResponse( - responseClassFqcn: TransactionResponse::class, - responseCode: Response::HTTP_NO_CONTENT, - description: 'Transaction deleted' - ), - new NotFoundResponse(description: 'Transaction not found'), - ], - )] - #[Route('/{id}', name: 'api_transactions_delete', methods: Request::METHOD_DELETE)] + #[NoContentResponse(description: 'Delete a transaction')] + #[Route('/{id}', name: __METHOD__, methods: Request::METHOD_DELETE)] public function __invoke( TransactionService $transactionService, int $accountId, @@ -42,6 +26,6 @@ public function __invoke( ): JsonResponse { $transactionService->delete($accountId, $transaction); - return $this->successResponse(data: [], status: Response::HTTP_NO_CONTENT); + return $this->noContentResponse(); } } diff --git a/app/src/Controller/Transaction/GetTransactionController.php b/app/src/Controller/Transaction/GetTransactionController.php index 328b0a8..78f3319 100644 --- a/app/src/Controller/Transaction/GetTransactionController.php +++ b/app/src/Controller/Transaction/GetTransactionController.php @@ -6,10 +6,8 @@ use App\Dto\Transaction\Response\TransactionResponse; use App\Service\TransactionService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\NotFoundResponse; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -17,18 +15,10 @@ #[Route('/accounts/{accountId}/transactions')] #[OA\Tag(name: 'Transactions')] -class GetTransactionController extends BaseRestController +class GetTransactionController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_GET, - operationId: 'get_transaction', - summary: 'get transaction', - responses: [ - new SuccessResponse(responseClassFqcn: TransactionResponse::class, description: 'Transaction get'), - new NotFoundResponse(description: 'Transaction not found'), - ], - )] - #[Route('/{id}', name: 'api_transactions_get', methods: Request::METHOD_GET)] + #[SuccessResponse(dataFqcn: TransactionResponse::class, description: 'Get a transaction')] + #[Route('/{id}', name: __METHOD__, methods: Request::METHOD_GET)] public function __invoke(int $accountId, int $id, TransactionService $transactionService): JsonResponse { return $this->successResponse(data: $transactionService->get($accountId, $id)); diff --git a/app/src/Controller/Transaction/ListTransactionController.php b/app/src/Controller/Transaction/ListTransactionController.php index 15d1e4f..1a7af15 100644 --- a/app/src/Controller/Transaction/ListTransactionController.php +++ b/app/src/Controller/Transaction/ListTransactionController.php @@ -7,9 +7,8 @@ use App\Dto\Transaction\Http\TransactionFilterQuery; use App\Dto\Transaction\Response\TransactionResponse; use App\Service\TransactionService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\PaginatedSuccessResponse; -use My\RestBundle\Controller\BaseRestController; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; use My\RestBundle\Dto\PaginationQueryParams; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; @@ -19,28 +18,17 @@ #[Route('/accounts/transactions', priority: 10)] #[OA\Tag(name: 'Transactions')] -class ListTransactionController extends BaseRestController +class ListTransactionController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_GET, - operationId: 'list_transaction', - summary: 'list transactions', - responses: [ - new PaginatedSuccessResponse( - responseClassFqcn: TransactionResponse::class, - description: 'Return the list of transactions' - ), - ], - queryParamsClassFqcn: [PaginationQueryParams::class, TransactionFilterQuery::class], - )] - #[Route('', name: 'api_transactions_list', methods: Request::METHOD_GET)] + #[SuccessResponse(dataFqcn: TransactionResponse::class, description: 'Get transactions list', paginated: true)] + #[Route('', name: __METHOD__, methods: Request::METHOD_GET)] public function __invoke( TransactionService $transactionService, #[MapQueryString] ?PaginationQueryParams $paginationQueryParams = null, #[MapQueryString] ?TransactionFilterQuery $filter = null, ): JsonResponse { - return $this->paginatedResponse( - pagination: $transactionService->paginate($filter?->getAccountIds(), $paginationQueryParams) + return $this->successResponse( + data: $transactionService->paginate($filter?->getAccountIds(), $paginationQueryParams) ); } } diff --git a/app/src/Controller/Transaction/UpdateTransactionController.php b/app/src/Controller/Transaction/UpdateTransactionController.php index b2c4334..1a15d18 100644 --- a/app/src/Controller/Transaction/UpdateTransactionController.php +++ b/app/src/Controller/Transaction/UpdateTransactionController.php @@ -8,31 +8,20 @@ use App\Dto\Transaction\Response\TransactionResponse; use App\Entity\Transaction; use App\Service\TransactionService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\NotFoundResponse; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Routing\Attribute\Route; #[Route('/accounts/{accountId}/transactions')] -#[OA\Tag(name: 'Transactions')] -class UpdateTransactionController extends BaseRestController +#[Tag(name: 'Transactions')] +class UpdateTransactionController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_PUT, - operationId: 'patch_transaction', - summary: 'patch transaction', - responses: [ - new SuccessResponse(responseClassFqcn: TransactionResponse::class, description: 'Transaction updated'), - new NotFoundResponse(description: 'Transaction not found'), - ], - requestBodyClassFqcn: TransactionPayload::class - )] - #[Route('/{id}', name: 'api_transaction_update', methods: Request::METHOD_PUT)] + #[SuccessResponse(dataFqcn: TransactionResponse::class, description: 'Update a transaction')] + #[Route('/{id}', name: __METHOD__, methods: Request::METHOD_PUT)] public function __invoke( int $accountId, TransactionService $transactionService, diff --git a/app/src/Controller/User/GetUserController.php b/app/src/Controller/User/GetUserController.php index 15c3286..ca18644 100644 --- a/app/src/Controller/User/GetUserController.php +++ b/app/src/Controller/User/GetUserController.php @@ -4,13 +4,11 @@ namespace App\Controller\User; -use App\Entity\User; +use App\Dto\User\Response\UserResponse; use App\Service\UserService; -use My\RestBundle\Attribute\MyOpenApi\MyOpenApi; -use My\RestBundle\Attribute\MyOpenApi\Response\NotFoundResponse; -use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse; -use My\RestBundle\Controller\BaseRestController; -use OpenApi\Attributes as OA; +use App\Shared\Api\AbstractApiController; +use App\Shared\Api\Nelmio\Attribute\SuccessResponse; +use OpenApi\Attributes\Tag; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; @@ -18,19 +16,11 @@ use Symfony\Component\Security\Http\Attribute\CurrentUser; #[Route('/users')] -#[OA\Tag(name: 'Users')] -class GetUserController extends BaseRestController +#[Tag(name: 'Users')] +class GetUserController extends AbstractApiController { - #[MyOpenApi( - httpMethod: Request::METHOD_GET, - operationId: 'get_user', - summary: 'get user', - responses: [ - new SuccessResponse(responseClassFqcn: User::class, description: 'User get'), - new NotFoundResponse(description: 'User not found'), - ], - )] - #[Route('/me', name: 'api_users_get', methods: Request::METHOD_GET)] + #[SuccessResponse(dataFqcn: UserResponse::class, description: 'Get the current user')] + #[Route('/me', name: __METHOD__, methods: Request::METHOD_GET)] public function __invoke(UserService $userService, #[CurrentUser] UserInterface $tokenUser): JsonResponse { return $this->successResponse(data: $userService->get($tokenUser->getUserIdentifier())); diff --git a/app/src/Repository/AccountRepository.php b/app/src/Repository/AccountRepository.php index 064910c..e6e6843 100644 --- a/app/src/Repository/AccountRepository.php +++ b/app/src/Repository/AccountRepository.php @@ -5,7 +5,7 @@ namespace App\Repository; use App\Entity\Account; -use My\RestBundle\Repository\Common\AbstractEntityRepository; +use App\Shared\Doctrine\Repository\AbstractEntityRepository; /** * @extends AbstractEntityRepository diff --git a/app/src/Repository/BalanceHistoryRepository.php b/app/src/Repository/BalanceHistoryRepository.php index 707c129..35462dc 100644 --- a/app/src/Repository/BalanceHistoryRepository.php +++ b/app/src/Repository/BalanceHistoryRepository.php @@ -7,8 +7,8 @@ use App\Entity\Account; use App\Entity\BalanceHistory; use App\Enum\PeriodsEnum; +use App\Shared\Doctrine\Repository\AbstractEntityRepository; use Carbon\Carbon; -use My\RestBundle\Repository\Common\AbstractEntityRepository; /** * @extends AbstractEntityRepository diff --git a/app/src/Repository/BudgetRepository.php b/app/src/Repository/BudgetRepository.php index 77be28f..90d7c34 100644 --- a/app/src/Repository/BudgetRepository.php +++ b/app/src/Repository/BudgetRepository.php @@ -5,7 +5,7 @@ namespace App\Repository; use App\Entity\Budget; -use My\RestBundle\Repository\Common\AbstractEntityRepository; +use App\Shared\Doctrine\Repository\AbstractEntityRepository; use Symfony\Component\Security\Core\User\UserInterface; /** diff --git a/app/src/Repository/ExpenseRepository.php b/app/src/Repository/ExpenseRepository.php index 5b8213c..dc81c7f 100644 --- a/app/src/Repository/ExpenseRepository.php +++ b/app/src/Repository/ExpenseRepository.php @@ -5,7 +5,7 @@ namespace App\Repository; use App\Entity\Expense; -use My\RestBundle\Repository\Common\AbstractEntityRepository; +use App\Shared\Doctrine\Repository\AbstractEntityRepository; /** * @extends AbstractEntityRepository diff --git a/app/src/Repository/IncomeRepository.php b/app/src/Repository/IncomeRepository.php index 5868349..988c93b 100644 --- a/app/src/Repository/IncomeRepository.php +++ b/app/src/Repository/IncomeRepository.php @@ -5,7 +5,7 @@ namespace App\Repository; use App\Entity\Income; -use My\RestBundle\Repository\Common\AbstractEntityRepository; +use App\Shared\Doctrine\Repository\AbstractEntityRepository; /** * @extends AbstractEntityRepository diff --git a/app/src/Repository/TransactionRepository.php b/app/src/Repository/TransactionRepository.php index 4e04d4e..aecc4d7 100644 --- a/app/src/Repository/TransactionRepository.php +++ b/app/src/Repository/TransactionRepository.php @@ -6,7 +6,7 @@ use App\Entity\Account; use App\Entity\Transaction; -use My\RestBundle\Repository\Common\AbstractEntityRepository; +use App\Shared\Doctrine\Repository\AbstractEntityRepository; /** * @extends AbstractEntityRepository diff --git a/app/src/Repository/UserRepository.php b/app/src/Repository/UserRepository.php index 76de3a0..905c5d8 100644 --- a/app/src/Repository/UserRepository.php +++ b/app/src/Repository/UserRepository.php @@ -5,7 +5,7 @@ namespace App\Repository; use App\Entity\User; -use My\RestBundle\Repository\Common\AbstractEntityRepository; +use App\Shared\Doctrine\Repository\AbstractEntityRepository; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; @@ -23,7 +23,6 @@ public function getEntityClass(): string return User::class; } - // TODO: to test during improvement user creation task #[\Override] public function upgradePassword( PasswordAuthenticatedUserInterface $passwordAuthenticatedUser, diff --git a/app/src/Shared/Api/AbstractApiController.php b/app/src/Shared/Api/AbstractApiController.php new file mode 100644 index 0000000..f8c059b --- /dev/null +++ b/app/src/Shared/Api/AbstractApiController.php @@ -0,0 +1,61 @@ + + * @phpstan-type ArrayContext array + */ +class AbstractApiController extends AbstractController +{ + /** + * @param ArrayHeaders $headers + * @param ArrayContext $context + */ + private function jsonResponse( + mixed $data, + int $status = 200, + array $headers = [], + array $context = [], + ): JsonResponse { + return $this->json(data: $data, status: $status, headers: $headers, context: $context); + } + + /** + * @param ArrayHeaders $headers + * @param ArrayContext $context + */ + protected function successResponse( + mixed $data, + mixed $meta = null, + array $headers = [], + array $context = [], + int $status = Response::HTTP_OK + ): JsonResponse { + $response = [ + 'data' => $data, + ]; + + if ($meta !== null) { + $response['meta'] = $meta; + } + + return $this->jsonResponse( + data: $response['data'], + status: $status, + headers: $headers, + context: $context, + ); + } + + public function noContentResponse(): JsonResponse + { + return $this->json(data: null, status: 204); + } +} diff --git a/app/src/Shared/Api/Doctrine/Filter/Adapter/FilterQueryDefinitionInterface.php b/app/src/Shared/Api/Doctrine/Filter/Adapter/FilterQueryDefinitionInterface.php new file mode 100644 index 0000000..027740f --- /dev/null +++ b/app/src/Shared/Api/Doctrine/Filter/Adapter/FilterQueryDefinitionInterface.php @@ -0,0 +1,12 @@ +> $operators + */ + private function __construct( + public string $field, + public string $publicName, + public array $operators = [], + public ?FilterJoin $join = null, + ) { + } + + /** + * @param array> $operators + */ + public static function create( + string $field, + string $publicName, + array $operators = [], + ?FilterJoin $join = null, + ): self { + return new self(field: $field, publicName: $publicName, operators: $operators, join: $join); + } +} diff --git a/app/src/Shared/Api/Doctrine/Filter/FilterDefinitionBag.php b/app/src/Shared/Api/Doctrine/Filter/FilterDefinitionBag.php new file mode 100644 index 0000000..97e18e5 --- /dev/null +++ b/app/src/Shared/Api/Doctrine/Filter/FilterDefinitionBag.php @@ -0,0 +1,62 @@ + + */ +class FilterDefinitionBag extends \ArrayObject +{ + /** + * @param array $definitions + */ + public function __construct( + array $definitions = [], + ) { + parent::__construct($definitions); + } + + /** + * @param TValue $definition + */ + public function add(FilterDefinition $definition): self + { + $this->offsetSet($definition->field, $definition); + + return $this; + } + + /** + * @param TKey $key + */ + public function get(string $key): FilterDefinition + { + return $this->offsetGet($key); + } + + /** + * @param TKey $key + */ + public function has(string $key): bool + { + return $this->offsetExists($key); + } + + /** + * @param TKey $key + */ + public function remove(string $key): void + { + $this->offsetUnset($key); + } + + public function toArray(): array + { + return $this->getArrayCopy(); + } +} diff --git a/app/src/Shared/Api/Doctrine/Filter/FilterJoin.php b/app/src/Shared/Api/Doctrine/Filter/FilterJoin.php new file mode 100644 index 0000000..27bee07 --- /dev/null +++ b/app/src/Shared/Api/Doctrine/Filter/FilterJoin.php @@ -0,0 +1,31 @@ +generateRandomParameterName(); + + $qb->setParameter($parameterName, $value); + + $alias = $this->getAlias($qb, $definition); + + $comparison = new Comparison("{$alias}.{$definition->field}", Comparison::EQ, ":{$parameterName}"); + + $qb->andWhere($comparison); + } +} diff --git a/app/src/Shared/Api/Doctrine/Filter/Operator/ComparisonOperator/NotEqualOperator.php b/app/src/Shared/Api/Doctrine/Filter/Operator/ComparisonOperator/NotEqualOperator.php new file mode 100644 index 0000000..f14fd60 --- /dev/null +++ b/app/src/Shared/Api/Doctrine/Filter/Operator/ComparisonOperator/NotEqualOperator.php @@ -0,0 +1,36 @@ +generateRandomParameterName(); + + $qb->setParameter($parameterName, $value); + + $alias = $this->getAlias($qb, $definition); + + $comparison = new Comparison("{$alias}.{$definition->field}", Comparison::NEQ, ":{$parameterName}"); + + $qb->andWhere($comparison); + } +} diff --git a/app/src/Shared/Api/Doctrine/Filter/Operator/OperatorInterface.php b/app/src/Shared/Api/Doctrine/Filter/Operator/OperatorInterface.php new file mode 100644 index 0000000..664a401 --- /dev/null +++ b/app/src/Shared/Api/Doctrine/Filter/Operator/OperatorInterface.php @@ -0,0 +1,15 @@ +join?->alias; + + if ($alias === null) { + $rootAliases = $qb->getRootAliases(); + $alias = $rootAliases[0] ?? 'e'; + } + + return $alias; + } +} diff --git a/app/src/Shared/Api/Doctrine/Paginator.php b/app/src/Shared/Api/Doctrine/Paginator.php new file mode 100644 index 0000000..d22f1db --- /dev/null +++ b/app/src/Shared/Api/Doctrine/Paginator.php @@ -0,0 +1,83 @@ +request = $requestStack->getCurrentRequest(); + } + + public function paginate( + QueryBuilder $qb, + ORMQueryBuilderFilterQueryAwareInterface|FilterQueryDefinitionInterface|null $queryBuilderFilterQueryAware = null, + int $page = 1, + int $limit = 30, + ): array { + if ($queryBuilderFilterQueryAware instanceof ORMQueryBuilderFilterQueryAwareInterface) { + $queryBuilderFilterQueryAware->applyToORMQueryBuilder($qb); + } + + if ($queryBuilderFilterQueryAware instanceof FilterQueryDefinitionInterface) { + $this->handleFilterQueryDefinition($qb, $queryBuilderFilterQueryAware->definition()); + } + + /* + * @var array + * + * @phpstan-ignore-next-line doctrine.queryBuilderDynamicArgument (Allowed for this case) + */ + return $qb + ->setFirstResult(($page - 1) * $limit) + ->setMaxResults($limit) + ->getQuery() + ->getResult() + ; + } + + /** + * @param FilterDefinitionBag $definitionBag + */ + private function handleFilterQueryDefinition(QueryBuilder $qb, FilterDefinitionBag $definitionBag): void + { + $queryParameters = $this->request->query->all(); + $accessor = PropertyAccess::createPropertyAccessor(); + + foreach ($definitionBag as $definition) { + $publicName = $definition->publicName; + $operators = $definition->operators; + + foreach ($operators as $operator) { + /** @var OperatorInterface $operatorInstance */ + $operatorInstance = new $operator(); + $queryParameterName = \sprintf('[%s][%s]', $publicName, $operatorInstance->operator()); + $value = $accessor->getValue($queryParameters, $queryParameterName) ?? 'NOT_PRESENT'; + + if ($value === 'NOT_PRESENT') { + continue; + } + + $operatorInstance->apply($qb, $definition, $value); + } + } + } +} diff --git a/app/src/Shared/Api/Dto/Adapter/ApiMetaInterface.php b/app/src/Shared/Api/Dto/Adapter/ApiMetaInterface.php new file mode 100644 index 0000000..16553bd --- /dev/null +++ b/app/src/Shared/Api/Dto/Adapter/ApiMetaInterface.php @@ -0,0 +1,21 @@ + PaginationMeta::class, + ] +)] +interface ApiMetaInterface +{ +} diff --git a/app/src/Shared/Api/Dto/Dto/ApiError.php b/app/src/Shared/Api/Dto/Dto/ApiError.php new file mode 100644 index 0000000..00f1e89 --- /dev/null +++ b/app/src/Shared/Api/Dto/Dto/ApiError.php @@ -0,0 +1,25 @@ + FieldApiError::class, + ] +)] +class ApiError +{ + public function __construct( + public string $message, + public int $code, + ) { + } +} diff --git a/app/src/Shared/Api/Dto/Dto/FieldApiError.php b/app/src/Shared/Api/Dto/Dto/FieldApiError.php new file mode 100644 index 0000000..4b5d340 --- /dev/null +++ b/app/src/Shared/Api/Dto/Dto/FieldApiError.php @@ -0,0 +1,16 @@ + $pagination + * + * @phpstan-ignore shipmonk.deadMethod (not used yet) + */ + public static function fromPagination(PaginationInterface $pagination): self + { + $totalItems = $pagination->getTotalItemCount(); + $currentPage = $pagination->getCurrentPageNumber(); + $itemsPerPage = $pagination->getItemNumberPerPage(); + + if ($itemsPerPage === 0) { + $itemsPerPage = 1; + } + + $lastPage = (int) round($totalItems / $itemsPerPage); + if ($lastPage === 0) { + $lastPage = 1; + } + + return new self( + totalItems: $totalItems, + currentPage: $currentPage, + lastPage: $lastPage, + firstPage: 1, + maxPerPage: $itemsPerPage, + ); + } +} diff --git a/app/src/Shared/Api/Dto/Response/AbstractApiResponse.php b/app/src/Shared/Api/Dto/Response/AbstractApiResponse.php new file mode 100644 index 0000000..9a6a279 --- /dev/null +++ b/app/src/Shared/Api/Dto/Response/AbstractApiResponse.php @@ -0,0 +1,9 @@ + $errors + */ + private function __construct( + public string $message, + public int $code, + public array $errors = [], + ) { + } + + /** + * @param array $errors + */ + public static function create(string $message, int $code, array $errors = []): self + { + return new self(message: $message, code: $code, errors: $errors); + } +} diff --git a/app/src/Shared/Api/Dto/Response/SuccessResponse.php b/app/src/Shared/Api/Dto/Response/SuccessResponse.php new file mode 100644 index 0000000..d370dd3 --- /dev/null +++ b/app/src/Shared/Api/Dto/Response/SuccessResponse.php @@ -0,0 +1,28 @@ +|bool|null $data + */ + private function __construct( + public object|array|bool|null $data, + public ?ApiMetaInterface $meta = null, + public bool $success = true, + ) { + } + + /** + * @param object|array|bool|null $data + */ + public static function new(object|array|bool|null $data, ?ApiMetaInterface $meta = null, bool $success = true): self + { + return new self(data: $data, meta: $meta, success: $success); + } +} diff --git a/app/src/Shared/Api/Mapper/ApiMapper.php b/app/src/Shared/Api/Mapper/ApiMapper.php new file mode 100644 index 0000000..348e3d3 --- /dev/null +++ b/app/src/Shared/Api/Mapper/ApiMapper.php @@ -0,0 +1,79 @@ + $source + * @param class-string|array|Target $target + * + * @return ($target is class-string|Target ? Target|null : array|null) + */ + public function map(array|object $source, string|array|object $target): object|array|null + { + $target = $this->autoMapper->map($source, $target); + if ($target !== null) { + // $this->relationResolver->resolve($source, $target); + } + + return $target; + } + + /** + * @template Source of object + * @template Target of object + * + * @param Source|array $source + * @param class-string|array|Target $target + * + * @return ($target is class-string|Target ? Target|null : array|null) + */ + public function patch(array|object $source, string|array|object $target): array|object|null + { + if (\is_object($source) === false) { + return $this->map($source, $target); + } + + $reflectionClass = new \ReflectionClass($source); + $data = []; + foreach ($reflectionClass->getProperties() as $reflectionProperty) { + if ($reflectionProperty->isInitialized($source) === false) { + continue; + } + + if ( + $reflectionProperty->getValue($source) === null + // && $reflectionProperty->getType()?->getName() === Relation::class + ) { + continue; + } + + $data[$reflectionProperty->getName()] = $reflectionProperty->getValue($source); + } + + $target = $this->autoMapper->map($data, $target); + if ($target !== null) { + // $this->relationResolver->resolve($source, $target); + } + + return $target; + } +} diff --git a/app/src/Shared/Api/Mapper/MappingException.php b/app/src/Shared/Api/Mapper/MappingException.php new file mode 100644 index 0000000..f61c021 --- /dev/null +++ b/app/src/Shared/Api/Mapper/MappingException.php @@ -0,0 +1,13 @@ +type = 'array'; + $dataProperty->items = new Items(ref: new Model(type: $dataFqcn), description: $description); + } else { + $dataProperty->type = 'object'; + $dataProperty->ref = new Model(type: $dataFqcn); + } + + $properties[] = $dataProperty; + + if ($metaFqcn !== null && class_exists($metaFqcn) === false) { + throw new \InvalidArgumentException(\sprintf('Class %s does not exist', $metaFqcn)); + } + + if ($paginated && $metaFqcn === null) { + $metaFqcn = PaginationMeta::class; + } + + $properties[] = new Property( + property: 'meta', + ref: $metaFqcn !== null ? new Model(type: $metaFqcn) : null, + description: 'If example is null, it means that there is no meta data', + type: 'object', + example: $metaFqcn !== null ? Generator::UNDEFINED : null, + nullable: $metaFqcn !== null, + ); + + parent::__construct( + response: $statusCode, + description: $description, + content: new JsonContent(properties: $properties), + ); + } +} diff --git a/app/src/Shared/Api/Security/Attribute/Sensitive.php b/app/src/Shared/Api/Security/Attribute/Sensitive.php new file mode 100644 index 0000000..fa473fe --- /dev/null +++ b/app/src/Shared/Api/Security/Attribute/Sensitive.php @@ -0,0 +1,21 @@ + $roles + */ + public function __construct( + public array $roles = [], + ) { + } +} diff --git a/app/src/Shared/Api/Symfony/Resolver/PartialUpdateResolver.php b/app/src/Shared/Api/Symfony/Resolver/PartialUpdateResolver.php new file mode 100644 index 0000000..a11bc69 --- /dev/null +++ b/app/src/Shared/Api/Symfony/Resolver/PartialUpdateResolver.php @@ -0,0 +1,271 @@ + true, + 'collect_denormalization_errors' => true, + ]; + + /** + * @see DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS + */ + private const array CONTEXT_DESERIALIZE = [ + 'collect_denormalization_errors' => true, + ]; + + public function __construct( + private readonly SerializerInterface&DenormalizerInterface $serializer, + private readonly ?ValidatorInterface $validator = null, + private readonly ?TranslatorInterface $translator = null, + private readonly string $translationDomain = 'validators', + ) { + parent::__construct($serializer, $validator, $translator, $translationDomain); + } + + #[\Override] + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + $arguments = $event->getArguments(); + + foreach ($arguments as $i => $argument) { + $isPatch = $event->getRequest()->getMethod() === 'PATCH'; + if ($argument instanceof MapQueryString) { + $payloadMapper = $this->mapQueryString(...); + $validationFailedCode = $argument->validationFailedStatusCode; + } elseif ($argument instanceof MapRequestPayload) { + $payloadMapper = $this->mapRequestPayload(...); + $validationFailedCode = $argument->validationFailedStatusCode; + } elseif ($argument instanceof MapUploadedFile) { + $payloadMapper = $this->mapUploadedFile(...); + $validationFailedCode = $argument->validationFailedStatusCode; + } else { + continue; + } + + $request = $event->getRequest(); + + if (! $argument->metadata->getType()) { + throw new \LogicException(\sprintf( + 'Could not resolve the "$%s" controller argument: argument should be typed.', + $argument->metadata->getName() + )); + } + + if ($this->validator instanceof ValidatorInterface) { + $violations = new ConstraintViolationList(); + try { + $payload = $payloadMapper($request, $argument->metadata, $argument); + } catch (PartialDenormalizationException $e) { + $trans = $this->translator instanceof TranslatorInterface ? $this->translator->trans( + ... + ) : static fn ($m, $p): string => strtr($m, $p); + foreach ($e->getErrors() as $error) { + $parameters = []; + $template = 'This value was of an unexpected type.'; + if ($expectedTypes = $error->getExpectedTypes()) { + $template = 'This value should be of type {{ type }}.'; + $parameters['{{ type }}'] = implode('|', $expectedTypes); + } + + if ($error->canUseMessageForUser()) { + $parameters['hint'] = $error->getMessage(); + } + + $message = $trans($template, $parameters, $this->translationDomain); + $violations->add( + new ConstraintViolation($message, $template, $parameters, null, $error->getPath(), null), + ); + } + + $payload = $e->getData(); + } + + if ($payload !== null && \count($violations) === 0) { + $constraints = $argument->constraints ?? null; + if (\is_array($payload) && ! empty($constraints) && ! $constraints instanceof All) { + $constraints = new All($constraints); + } + + if ($isPatch) { + $reflectionClass = new \ReflectionClass($payload); + foreach ($reflectionClass->getProperties() as $property) { + if ($property->isInitialized($payload) === false) { + continue; + } + + $violations->addAll( + $this->validator->validateProperty( + $payload, + $property->getName(), + $argument->validationGroups ?? null, + ), + ); + } + } else { + $violations->addAll( + $this->validator->validate($payload, $constraints, $argument->validationGroups ?? null), + ); + } + } + + if (\count($violations) > 0) { + throw HttpException::fromStatusCode( + $validationFailedCode, + implode( + "\n", + array_map(static fn ($e): string|\Stringable => $e->getMessage(), iterator_to_array( + $violations + )) + ), + new ValidationFailedException($payload, $violations) + ); + } + } else { + try { + $payload = $payloadMapper($request, $argument->metadata, $argument); + } catch (PartialDenormalizationException $e) { + throw HttpException::fromStatusCode( + $validationFailedCode, + implode("\n", array_map(static fn ($e): string => $e->getMessage(), $e->getErrors())), + $e + ); + } + } + + if ($payload === null) { + $payload = match (true) { + $argument->metadata->hasDefaultValue() => $argument->metadata->getDefaultValue(), + $argument->metadata->isNullable() => null, + default => throw HttpException::fromStatusCode($validationFailedCode), + }; + } + + $arguments[$i] = $payload; + } + + $event->setArguments($arguments); + } + + #[\Override] + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments', + ]; + } + + private function mapQueryString(Request $request, ArgumentMetadata $argument, MapQueryString $attribute): ?object + { + if (($data = $request->query->all()) === [] && ($argument->isNullable() || $argument->hasDefaultValue())) { + return null; + } + + return $this->serializer->denormalize( + $data, + $argument->getType(), + null, + $attribute->serializationContext + self::CONTEXT_DENORMALIZE + [ + 'filter_bool' => true, + ], + ); + } + + private function mapRequestPayload( + Request $request, + ArgumentMetadata $argument, + MapRequestPayload $attribute, + ): object|array|null { + if (null === $format = $request->getContentTypeFormat()) { + throw new UnsupportedMediaTypeHttpException('Unsupported format.'); + } + + if ($attribute->acceptFormat && ! \in_array($format, (array) $attribute->acceptFormat, true)) { + throw new UnsupportedMediaTypeHttpException(\sprintf( + 'Unsupported format, expects "%s", but "%s" given.', + implode('", "', (array) $attribute->acceptFormat), + $format + )); + } + + $type = $argument->getType() === 'array' && $attribute->type !== null ? $attribute->type . '[]' : $argument->getType(); + + if (($data = $request->request->all()) !== []) { + return $this->serializer->denormalize( + $data, + $type, + null, + $attribute->serializationContext + self::CONTEXT_DENORMALIZE + ($format === 'form' ? [ + 'filter_bool' => true, + ] : []), + ); + } + + if ('' === ($data = $request->getContent()) && ($argument->isNullable() || $argument->hasDefaultValue())) { + return null; + } + + if ($format === 'form') { + throw new BadRequestHttpException('Request payload contains invalid "form" data.'); + } + + try { + return $this->serializer->deserialize( + $data, + $type, + $format, + self::CONTEXT_DESERIALIZE + $attribute->serializationContext, + ); + } catch (UnsupportedFormatException $e) { + throw new UnsupportedMediaTypeHttpException(\sprintf('Unsupported format: "%s".', $format), $e); + } catch (NotEncodableValueException $e) { + throw new BadRequestHttpException(\sprintf('Request payload contains invalid "%s" data.', $format), $e); + } catch (UnexpectedPropertyException $e) { + throw new BadRequestHttpException(\sprintf( + 'Request payload contains invalid "%s" property.', + $e->property + ), $e); + } + } + + private function mapUploadedFile( + Request $request, + ArgumentMetadata $argument, + MapUploadedFile $attribute, + ): UploadedFile|array|null { + return $request->files->get($attribute->name ?? $argument->getName(), []); + } +} diff --git a/app/src/Shared/Api/Symfony/Validation/ValidationErrorCodeEnum.php b/app/src/Shared/Api/Symfony/Validation/ValidationErrorCodeEnum.php new file mode 100644 index 0000000..4be4485 --- /dev/null +++ b/app/src/Shared/Api/Symfony/Validation/ValidationErrorCodeEnum.php @@ -0,0 +1,116 @@ + + */ +abstract class AbstractEntityRepository extends BaseRepository implements ServiceEntityRepositoryInterface +{ + public function __construct( + protected ManagerRegistry $managerRegistry, + private readonly PaginatorInterface $paginator, + ) { + parent::__construct($managerRegistry, $this->getEntityClass()); + } + + /** + * @return class-string The entity class name + */ + abstract public function getEntityClass(): string; + + public function save(object $entity, bool $flush = false): string|int|null + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + + return $entity->getId(); // @phpstan-ignore-line + } + + return null; + } + + public function delete(object $entity, bool $flush = false): string|int + { + $id = $entity->getId(); // @phpstan-ignore-line + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + + return $id; + } + + public function persist(object $entity): void + { + $this->getEntityManager()->persist($entity); + } + + public function flush(): void + { + $this->getEntityManager()->flush(); + } + + public function paginate( + ?PaginationQueryParams $paginationQueryParams = null, + ?ORMFilterInterface $filter = null, + ?Criteria $extraCriteria = null + ): SlidingPagination { + $query = $this->createQueryBuilder('e'); + $paginationQueryParams ??= new PaginationQueryParams(); + + if ($filter) { + $query->addCriteria($filter->getCriteria()); + } + + if ($extraCriteria) { + $query->addCriteria($extraCriteria); + } + + $pagination = $this->paginator->paginate( + $query, + $paginationQueryParams->getPage(), + $paginationQueryParams->getLimit() + ); + + if (! $pagination instanceof SlidingPagination) { + throw new \Exception( + 'Paginator must be an instance of Knp\Bundle\PaginatorBundle\Pagination\SlidingPagination' + ); + } + + return $pagination; + } +} diff --git a/app/src/Shared/Exception/AbstractDomainModelNotFoundException.php b/app/src/Shared/Exception/AbstractDomainModelNotFoundException.php new file mode 100644 index 0000000..1abc7f5 --- /dev/null +++ b/app/src/Shared/Exception/AbstractDomainModelNotFoundException.php @@ -0,0 +1,25 @@ +model(), $this->id], $message)); + } + + final public static function withId(string|int $id, string $message = '%model% with id "%id%" not found.'): static + { + return new static(id: $id, message: $message,); + } +} diff --git a/app/tests/Functional/Account/CreateAccountControllerTest.php b/app/tests/Functional/Account/CreateAccountControllerTest.php index 8acad0d..8aa6fd4 100644 --- a/app/tests/Functional/Account/CreateAccountControllerTest.php +++ b/app/tests/Functional/Account/CreateAccountControllerTest.php @@ -36,11 +36,10 @@ public function createAccountController_WhenDataOk_ReturnsAccount(): void // ACT $response = $this->clientRequest(Request::METHOD_POST, self::API_ENDPOINT, $accountPayload); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); self::assertResponseFormatSame('json'); - self::assertSame('Livret', $responseData['name']); + self::assertSame('Livret', $response['name']); } } diff --git a/app/tests/Functional/Account/GetAccountControllerTest.php b/app/tests/Functional/Account/GetAccountControllerTest.php index 1fa0344..55ee4f4 100644 --- a/app/tests/Functional/Account/GetAccountControllerTest.php +++ b/app/tests/Functional/Account/GetAccountControllerTest.php @@ -37,10 +37,9 @@ public function getAccountController_WhenDataOk_ReturnsAccount(): void // ACT $response = $this->clientRequest(Request::METHOD_GET, self::API_ENDPOINT . '/' . $account->getId()); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); - self::assertSame($account->getId(), $responseData['id']); + self::assertSame($account->getId(), $response['id']); } } diff --git a/app/tests/Functional/Account/ListAccountControllerTest.php b/app/tests/Functional/Account/ListAccountControllerTest.php index e0f18f4..75b2f1c 100644 --- a/app/tests/Functional/Account/ListAccountControllerTest.php +++ b/app/tests/Functional/Account/ListAccountControllerTest.php @@ -37,10 +37,9 @@ public function listAccountController_WhenDataOk_ReturnsAccountsList(): void // ACT $response = $this->clientRequest(Request::METHOD_GET, self::API_ENDPOINT); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); - self::assertCount(\count($accounts), $responseData); + self::assertCount(\count($accounts), $response); } } diff --git a/app/tests/Functional/Account/UpdateAccountControllerTest.php b/app/tests/Functional/Account/UpdateAccountControllerTest.php index 3c88fb5..931824f 100644 --- a/app/tests/Functional/Account/UpdateAccountControllerTest.php +++ b/app/tests/Functional/Account/UpdateAccountControllerTest.php @@ -45,11 +45,10 @@ public function updateAccountController_WhenDataOk_ReturnsAccount(): void self::API_ENDPOINT . '/' . $account->getId(), $accountPayload ); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); self::assertResponseFormatSame('json'); - self::assertSame('Livret', $responseData['name']); + self::assertSame('Livret', $response['name']); } } diff --git a/app/tests/Functional/Authentication/RegisterControllerTest.php b/app/tests/Functional/Authentication/RegisterControllerTest.php index 38d718e..03b8f30 100644 --- a/app/tests/Functional/Authentication/RegisterControllerTest.php +++ b/app/tests/Functional/Authentication/RegisterControllerTest.php @@ -35,11 +35,10 @@ public function createRegisterController_WhenDataOk_ReturnsUser(): void // ACT $response = $this->clientRequest(Request::METHOD_POST, self::API_ENDPOINT, $payload); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); self::assertResponseFormatSame('json'); - self::assertSame($payload['email'], $responseData['email']); + self::assertSame($payload['email'], $response['email']); } } diff --git a/app/tests/Functional/BalanceHistory/GetMonthlyBalanceHistoryControllerTest.php b/app/tests/Functional/BalanceHistory/GetMonthlyBalanceHistoryControllerTest.php index ab93b52..7475dca 100644 --- a/app/tests/Functional/BalanceHistory/GetMonthlyBalanceHistoryControllerTest.php +++ b/app/tests/Functional/BalanceHistory/GetMonthlyBalanceHistoryControllerTest.php @@ -72,27 +72,26 @@ public function getMonthlyBalanceHistory_WhenDataOk_ReturnsBalanceHistory(): voi // ACT $response = $this->clientRequest(Request::METHOD_GET, self::API_ENDPOINT); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); self::assertResponseFormatSame('json'); // Vérification des comptes - self::assertCount(1, $responseData['accounts']); - self::assertSame($account->getId(), $responseData['accounts'][0]['id']); - self::assertSame('Compte test', $responseData['accounts'][0]['name']); + self::assertCount(1, $response['accounts']); + self::assertSame($account->getId(), $response['accounts'][0]['id']); + self::assertSame('Compte test', $response['accounts'][0]['name']); // Vérification des balances - self::assertCount(2, $responseData['balances']); + self::assertCount(2, $response['balances']); // Vérification de la balance de janvier - self::assertSame('2024-01', $responseData['balances'][0]['date']); - self::assertSame(1000, $responseData['balances'][0]['balance']); + self::assertSame('2024-01', $response['balances'][0]['date']); + self::assertSame(1000, $response['balances'][0]['balance']); // Vérification de la balance de février - self::assertSame('2024-02', $responseData['balances'][1]['date']); - self::assertSame(1500, $responseData['balances'][1]['balance']); + self::assertSame('2024-02', $response['balances'][1]['date']); + self::assertSame(1500, $response['balances'][1]['balance']); } #[TestDox( @@ -153,20 +152,19 @@ public function getMonthlyBalanceHistory_WithAccountFilter_ReturnsFilteredBalanc Request::METHOD_GET, self::API_ENDPOINT . '?accountIds[]=' . $account1->getId() ); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); self::assertResponseFormatSame('json'); // Vérification des comptes (seulement compte 1) - self::assertCount(1, $responseData['accounts']); - self::assertSame($account1->getId(), $responseData['accounts'][0]['id']); - self::assertSame('Compte 1', $responseData['accounts'][0]['name']); + self::assertCount(1, $response['accounts']); + self::assertSame($account1->getId(), $response['accounts'][0]['id']); + self::assertSame('Compte 1', $response['accounts'][0]['name']); // Vérification des balances - self::assertCount(1, $responseData['balances']); - self::assertSame('2024-01', $responseData['balances'][0]['date']); - self::assertSame(1001.10, $responseData['balances'][0]['balance']); + self::assertCount(1, $response['balances']); + self::assertSame('2024-01', $response['balances'][0]['date']); + self::assertSame(1001.10, $response['balances'][0]['balance']); } } diff --git a/app/tests/Functional/Budget/CreateBudgetControllerTest.php b/app/tests/Functional/Budget/CreateBudgetControllerTest.php index da53b83..158ffcc 100644 --- a/app/tests/Functional/Budget/CreateBudgetControllerTest.php +++ b/app/tests/Functional/Budget/CreateBudgetControllerTest.php @@ -68,12 +68,11 @@ public function createBudgetController_WhenDataOk_ReturnsBudget(): void // ACT $response = $this->clientRequest(Request::METHOD_POST, self::API_ENDPOINT, $budgetPayload); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); self::assertResponseFormatSame('json'); - self::assertSame('Budget 2024-02', $responseData['name']); - self::assertSame(1680, $responseData['savingCapacity']); + self::assertSame('Budget 2024-02', $response['name']); + self::assertSame(1680, $response['savingCapacity']); } } diff --git a/app/tests/Functional/Budget/DuplicateBudgetControllerTest.php b/app/tests/Functional/Budget/DuplicateBudgetControllerTest.php index 9077a8e..05d41ca 100644 --- a/app/tests/Functional/Budget/DuplicateBudgetControllerTest.php +++ b/app/tests/Functional/Budget/DuplicateBudgetControllerTest.php @@ -43,11 +43,10 @@ public function duplicateBudgetController_WhenDataOk_ReturnsBudget(): void // ACT $response = $this->clientRequest(Request::METHOD_POST, self::API_ENDPOINT . '/' . $budget->getId()); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); self::assertResponseFormatSame('json'); - self::assertSame($expectedDate->format('Y-m'), $responseData['date']); + self::assertSame($expectedDate->format('Y-m'), $response['date']); } } diff --git a/app/tests/Functional/Budget/GetBudgetControllerTest.php b/app/tests/Functional/Budget/GetBudgetControllerTest.php index 92f32f7..e9597bf 100644 --- a/app/tests/Functional/Budget/GetBudgetControllerTest.php +++ b/app/tests/Functional/Budget/GetBudgetControllerTest.php @@ -37,10 +37,9 @@ public function getBudgetController_WhenDataOk_ReturnsBudget(): void // ACT $response = $this->clientRequest(Request::METHOD_GET, self::API_ENDPOINT . '/' . $budget->getId()); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); - self::assertSame($budget->getId(), $responseData['id']); + self::assertSame($budget->getId(), $response['id']); } } diff --git a/app/tests/Functional/Budget/ListBudgetControllerTest.php b/app/tests/Functional/Budget/ListBudgetControllerTest.php index 81035ab..91f2a5f 100644 --- a/app/tests/Functional/Budget/ListBudgetControllerTest.php +++ b/app/tests/Functional/Budget/ListBudgetControllerTest.php @@ -37,10 +37,9 @@ public function listBudgetController_WhenDataOk_ReturnsBudgetsList(): void // ACT $response = $this->clientRequest(Request::METHOD_GET, self::API_ENDPOINT); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); - self::assertCount(\count($budgets), $responseData); + self::assertCount(\count($budgets), $response['data']); } } diff --git a/app/tests/Functional/Budget/UpdateBudgetControllerTest.php b/app/tests/Functional/Budget/UpdateBudgetControllerTest.php index 271bdca..261a227 100644 --- a/app/tests/Functional/Budget/UpdateBudgetControllerTest.php +++ b/app/tests/Functional/Budget/UpdateBudgetControllerTest.php @@ -77,12 +77,11 @@ public function updateBudgetController_WhenDataOk_ReturnsBudget(): void self::API_ENDPOINT . '/' . $budget->getId(), $budgetPayload ); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); self::assertResponseFormatSame('json'); - self::assertSame('Budget 2024-02', $responseData['name']); - self::assertSame(1680, $responseData['savingCapacity']); + self::assertSame('Budget 2024-02', $response['name']); + self::assertSame(1680, $response['savingCapacity']); } } diff --git a/app/tests/Functional/Transaction/CreateTransactionControllerTest.php b/app/tests/Functional/Transaction/CreateTransactionControllerTest.php index 658dd3f..92efc25 100644 --- a/app/tests/Functional/Transaction/CreateTransactionControllerTest.php +++ b/app/tests/Functional/Transaction/CreateTransactionControllerTest.php @@ -48,13 +48,12 @@ public function createTransactionController_WhenDataOk_ReturnsTransaction(): voi self::API_BASE_ENDPOINT . $account->getId() . '/transactions', $transactionPayload ); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseStatusCodeSame(Response::HTTP_CREATED); - self::assertArrayHasKey('id', $responseData); - self::assertSame($transactionPayload['description'], $responseData['description']); - self::assertSame($transactionPayload['amount'], $responseData['amount']); - self::assertSame($transactionPayload['type'], $responseData['type']); + self::assertArrayHasKey('id', $response); + self::assertSame($transactionPayload['description'], $response['description']); + self::assertSame($transactionPayload['amount'], $response['amount']); + self::assertSame($transactionPayload['type'], $response['type']); } } diff --git a/app/tests/Functional/Transaction/GetTransactionControllerTest.php b/app/tests/Functional/Transaction/GetTransactionControllerTest.php index 2410f98..4f520ba 100644 --- a/app/tests/Functional/Transaction/GetTransactionControllerTest.php +++ b/app/tests/Functional/Transaction/GetTransactionControllerTest.php @@ -44,11 +44,10 @@ public function getTransactionController_WhenDataOk_ReturnsTransaction(): void Request::METHOD_GET, self::API_BASE_ENDPOINT . $account->getId() . '/transactions/' . $transaction->getId() ); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseStatusCodeSame(Response::HTTP_OK); - self::assertSame($transaction->getId(), $responseData['id']); - self::assertSame($transaction->getDescription(), $responseData['description']); + self::assertSame($transaction->getId(), $response['id']); + self::assertSame($transaction->getDescription(), $response['description']); } } diff --git a/app/tests/Functional/Transaction/ListTransactionControllerTest.php b/app/tests/Functional/Transaction/ListTransactionControllerTest.php index ecb21db..a154e2f 100644 --- a/app/tests/Functional/Transaction/ListTransactionControllerTest.php +++ b/app/tests/Functional/Transaction/ListTransactionControllerTest.php @@ -44,10 +44,9 @@ public function listTransactionsController_WhenDataOk_ReturnsTransactionsList(): Request::METHOD_GET, self::API_BASE_ENDPOINT . '/transactions?accountIds[]=' . $account->getId(), ); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseStatusCodeSame(Response::HTTP_OK); - self::assertCount(3, $responseData); + self::assertCount(3, $response['data']); } } diff --git a/app/tests/Functional/Transaction/UpdateTransactionControllerTest.php b/app/tests/Functional/Transaction/UpdateTransactionControllerTest.php index 55e7bf3..621e3eb 100644 --- a/app/tests/Functional/Transaction/UpdateTransactionControllerTest.php +++ b/app/tests/Functional/Transaction/UpdateTransactionControllerTest.php @@ -53,12 +53,11 @@ public function updateTransactionController_WhenDataOk_ReturnsUpdatedTransaction self::API_BASE_ENDPOINT . $account->getId() . '/transactions/' . $transaction->getId(), $updatePayload ); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseStatusCodeSame(Response::HTTP_OK); - self::assertSame($transaction->getId(), $responseData['id']); - self::assertSame($updatePayload['description'], $responseData['description']); - self::assertSame($updatePayload['amount'], $responseData['amount']); + self::assertSame($transaction->getId(), $response['id']); + self::assertSame($updatePayload['description'], $response['description']); + self::assertSame($updatePayload['amount'], $response['amount']); } } diff --git a/app/tests/Functional/User/GetUserControllerTest.php b/app/tests/Functional/User/GetUserControllerTest.php index f757f4c..d4fc3b0 100644 --- a/app/tests/Functional/User/GetUserControllerTest.php +++ b/app/tests/Functional/User/GetUserControllerTest.php @@ -33,13 +33,12 @@ public function getUserController_WhenDataOk_ReturnsUser(): void // ACT $response = $this->clientRequest(Request::METHOD_GET, self::API_ENDPOINT . '/me'); - $responseData = $response['data'] ?? []; // ASSERT self::assertResponseIsSuccessful(); self::assertResponseFormatSame('json'); - self::assertSame($user->getId(), $responseData['id']); - self::assertSame($user->getEmail(), $responseData['email']); + self::assertSame($user->getId(), $response['id']); + self::assertSame($user->getEmail(), $response['email']); } #[TestDox('When you call GET /api/users/me, it should return unauthorized')]