From 937c3cee1a125640f5d819a699070abc1f89ec16 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 24 Jul 2025 13:05:52 +0400 Subject: [PATCH 1/2] Subscriber history endpoint --- .../Provider/PaginatedDataProvider.php | 6 +- .../Controller/SubscriberController.php | 134 ++++++++++++++++++ .../OpenApi/SwaggerSchemasResponse.php | 17 +++ .../SubscriberHistoryNormalizer.php | 38 +++++ 4 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/Subscription/Serializer/SubscriberHistoryNormalizer.php diff --git a/src/Common/Service/Provider/PaginatedDataProvider.php b/src/Common/Service/Provider/PaginatedDataProvider.php index c5bf3a3..92ac53d 100644 --- a/src/Common/Service/Provider/PaginatedDataProvider.php +++ b/src/Common/Service/Provider/PaginatedDataProvider.php @@ -37,7 +37,11 @@ public function getPaginatedList( throw new RuntimeException('Repository not found'); } - $items = $repository->getFilteredAfterId($pagination->afterId, $pagination->limit, $filter); + $items = $repository->getFilteredAfterId( + lastId: $pagination->afterId, + limit: $pagination->limit, + filter: $filter, + ); $total = $repository->count(); $normalizedItems = array_map( diff --git a/src/Subscription/Controller/SubscriberController.php b/src/Subscription/Controller/SubscriberController.php index 9d3aecb..4159eba 100644 --- a/src/Subscription/Controller/SubscriberController.php +++ b/src/Subscription/Controller/SubscriberController.php @@ -4,12 +4,17 @@ namespace PhpList\RestBundle\Subscription\Controller; +use DateTimeImmutable; +use Exception; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; +use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberHistoryFilter; use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberHistory; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; +use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Subscription\Request\CreateSubscriberRequest; use PhpList\RestBundle\Subscription\Request\UpdateSubscriberRequest; @@ -20,6 +25,8 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Validator\Exception\ValidatorException; /** * This controller provides REST API access to subscribers. @@ -32,17 +39,23 @@ class SubscriberController extends BaseController { private SubscriberManager $subscriberManager; private SubscriberNormalizer $subscriberNormalizer; + private PaginatedDataProvider $paginatedDataProvider; + private NormalizerInterface $serializer; public function __construct( Authentication $authentication, RequestValidator $validator, SubscriberManager $subscriberManager, SubscriberNormalizer $subscriberNormalizer, + PaginatedDataProvider $paginatedDataProvider, + NormalizerInterface $serializer, ) { parent::__construct($authentication, $validator); $this->authentication = $authentication; $this->subscriberManager = $subscriberManager; $this->subscriberNormalizer = $subscriberNormalizer; + $this->paginatedDataProvider = $paginatedDataProvider; + $this->serializer = $serializer; } #[Route('', name: 'create', methods: ['POST'])] @@ -226,6 +239,127 @@ public function getSubscriber(Request $request, int $subscriberId): JsonResponse return $this->json($this->subscriberNormalizer->normalize($subscriber), Response::HTTP_OK); } + #[Route('/{subscriberId}/history', name: 'history', requirements: ['subscriberId' => '\d+'], methods: ['GET'])] + #[OA\Get( + path: '/api/v2/subscribers/{subscriberId}/history', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ', + summary: 'Get subscriber event history', + tags: ['subscribers'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'subscriberId', + description: 'Subscriber ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ), + new OA\Parameter( + name: 'after_id', + description: 'Page number (pagination)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 1) + ), + new OA\Parameter( + name: 'limit', + description: 'Max items per page', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 25) + ), + new OA\Parameter( + name: 'ip', + description: 'Filter by IP address', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'date_from', + description: 'Filter by date (format: Y-m-d)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', format: 'date') + ), + new OA\Parameter( + name: 'summery', + description: 'Filter by summary text', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of subscriber events', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/SubscriberHistory') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ) + ] + )] + public function getSubscriberHistory( + Request $request, + #[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null, + ): JsonResponse { + $this->requireAuthentication($request); + + if (!$subscriber) { + throw $this->createNotFoundException('Subscriber not found.'); + } + + try { + $dateFrom = $request->query->get('date_from'); + $dateFromFormated = $dateFrom ? new DateTimeImmutable($dateFrom) : null; + } catch (Exception $e) { + throw new ValidatorException('Invalid date format. Use format: Y-m-d'); + } + + $filter = new SubscriberHistoryFilter( + subscriber: $subscriber, + ip: $request->query->get('ip'), + dateFrom: $dateFromFormated, + summery: $request->query->get('summery'), + ); + + return $this->json( + data: $this->paginatedDataProvider->getPaginatedList( + request: $request, + normalizer: $this->serializer, + className: SubscriberHistory::class, + filter: $filter + ), + status: Response::HTTP_OK, + ); + } + + #[Route('/{subscriberId}', name: 'delete', requirements: ['subscriberId' => '\d+'], methods: ['DELETE'])] #[OA\Delete( path: '/api/v2/subscribers/{subscriberId}', diff --git a/src/Subscription/OpenApi/SwaggerSchemasResponse.php b/src/Subscription/OpenApi/SwaggerSchemasResponse.php index edc5ea1..e2f4c38 100644 --- a/src/Subscription/OpenApi/SwaggerSchemasResponse.php +++ b/src/Subscription/OpenApi/SwaggerSchemasResponse.php @@ -105,6 +105,23 @@ new OA\Property(property: 'value', type: 'string', example: 'United States'), ], )] +#[OA\Schema( + schema: 'SubscriberHistory', + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'ip', type: 'string', example: '127.0.0.1'), + new OA\Property( + property: 'created_at', + type: 'string', + format: 'date-time', + example: '2022-12-01T10:00:00Z' + ), + new OA\Property(property: 'summery', type: 'string', example: 'Added by admin'), + new OA\Property(property: 'detail', type: 'string', example: 'Added with add-email on test'), + new OA\Property(property: 'system_info', type: 'string', example: 'HTTP_USER_AGENT = Mozilla/5.0'), + ], + type: 'object' +)] class SwaggerSchemasResponse { } diff --git a/src/Subscription/Serializer/SubscriberHistoryNormalizer.php b/src/Subscription/Serializer/SubscriberHistoryNormalizer.php new file mode 100644 index 0000000..dc875c6 --- /dev/null +++ b/src/Subscription/Serializer/SubscriberHistoryNormalizer.php @@ -0,0 +1,38 @@ + $object->getId(), + 'ip' => $object->getIp(), + 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), + 'summery' => $object->getSummary(), + 'detail' => $object->getDetail(), + 'system_info' => $object->getSystemInfo(), + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof SubscriberHistory; + } +} From 504252670ddd69a15e204ea3f64b60d840262c68 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 27 Jul 2025 13:47:29 +0400 Subject: [PATCH 2/2] Refactor: Coupling between objects --- config/services/services.yml | 8 ++ .../Controller/SubscriberController.php | 74 ++++--------------- .../Service/SubscriberHistoryService.php | 53 +++++++++++++ .../Service/SubscriberService.php | 64 ++++++++++++++++ 4 files changed, 141 insertions(+), 58 deletions(-) create mode 100644 config/services/services.yml create mode 100644 src/Subscription/Service/SubscriberHistoryService.php create mode 100644 src/Subscription/Service/SubscriberService.php diff --git a/config/services/services.yml b/config/services/services.yml new file mode 100644 index 0000000..f087aa6 --- /dev/null +++ b/config/services/services.yml @@ -0,0 +1,8 @@ +services: + PhpList\RestBundle\Subscription\Service\SubscriberService: + autowire: true + autoconfigure: true + + PhpList\RestBundle\Subscription\Service\SubscriberHistoryService: + autowire: true + autoconfigure: true diff --git a/src/Subscription/Controller/SubscriberController.php b/src/Subscription/Controller/SubscriberController.php index 4159eba..d633d51 100644 --- a/src/Subscription/Controller/SubscriberController.php +++ b/src/Subscription/Controller/SubscriberController.php @@ -4,29 +4,20 @@ namespace PhpList\RestBundle\Subscription\Controller; -use DateTimeImmutable; -use Exception; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; -use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberHistoryFilter; use PhpList\Core\Domain\Subscription\Model\Subscriber; -use PhpList\Core\Domain\Subscription\Model\SubscriberHistory; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; -use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Subscription\Request\CreateSubscriberRequest; use PhpList\RestBundle\Subscription\Request\UpdateSubscriberRequest; -use PhpList\RestBundle\Subscription\Serializer\SubscriberNormalizer; +use PhpList\RestBundle\Subscription\Service\SubscriberService; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Validator\Exception\ValidatorException; /** * This controller provides REST API access to subscribers. @@ -37,25 +28,16 @@ #[Route('/subscribers', name: 'subscriber_')] class SubscriberController extends BaseController { - private SubscriberManager $subscriberManager; - private SubscriberNormalizer $subscriberNormalizer; - private PaginatedDataProvider $paginatedDataProvider; - private NormalizerInterface $serializer; + private SubscriberService $subscriberService; public function __construct( Authentication $authentication, RequestValidator $validator, - SubscriberManager $subscriberManager, - SubscriberNormalizer $subscriberNormalizer, - PaginatedDataProvider $paginatedDataProvider, - NormalizerInterface $serializer, + SubscriberService $subscriberService, ) { parent::__construct($authentication, $validator); $this->authentication = $authentication; - $this->subscriberManager = $subscriberManager; - $this->subscriberNormalizer = $subscriberNormalizer; - $this->paginatedDataProvider = $paginatedDataProvider; - $this->serializer = $serializer; + $this->subscriberService = $subscriberService; } #[Route('', name: 'create', methods: ['POST'])] @@ -111,12 +93,9 @@ public function createSubscriber(Request $request): JsonResponse /** @var CreateSubscriberRequest $subscriberRequest */ $subscriberRequest = $this->validator->validate($request, CreateSubscriberRequest::class); - $subscriber = $this->subscriberManager->createSubscriber($subscriberRequest->getDto()); + $subscriberData = $this->subscriberService->createSubscriber($subscriberRequest); - return $this->json( - $this->subscriberNormalizer->normalize($subscriber, 'json'), - Response::HTTP_CREATED - ); + return $this->json($subscriberData, Response::HTTP_CREATED); } #[Route('/{subscriberId}', name: 'update', requirements: ['subscriberId' => '\d+'], methods: ['PUT'])] @@ -184,9 +163,9 @@ public function updateSubscriber( } /** @var UpdateSubscriberRequest $updateSubscriberRequest */ $updateSubscriberRequest = $this->validator->validate($request, UpdateSubscriberRequest::class); - $subscriber = $this->subscriberManager->updateSubscriber($updateSubscriberRequest->getDto()); + $subscriberData = $this->subscriberService->updateSubscriber($updateSubscriberRequest); - return $this->json($this->subscriberNormalizer->normalize($subscriber, 'json'), Response::HTTP_OK); + return $this->json($subscriberData, Response::HTTP_OK); } #[Route('/{subscriberId}', name: 'get_one', requirements: ['subscriberId' => '\d+'], methods: ['GET'])] @@ -234,9 +213,9 @@ public function getSubscriber(Request $request, int $subscriberId): JsonResponse { $this->requireAuthentication($request); - $subscriber = $this->subscriberManager->getSubscriber($subscriberId); + $subscriberData = $this->subscriberService->getSubscriber($subscriberId); - return $this->json($this->subscriberNormalizer->normalize($subscriber), Response::HTTP_OK); + return $this->json($subscriberData, Response::HTTP_OK); } #[Route('/{subscriberId}/history', name: 'history', requirements: ['subscriberId' => '\d+'], methods: ['GET'])] @@ -330,31 +309,10 @@ public function getSubscriberHistory( ): JsonResponse { $this->requireAuthentication($request); - if (!$subscriber) { - throw $this->createNotFoundException('Subscriber not found.'); - } - - try { - $dateFrom = $request->query->get('date_from'); - $dateFromFormated = $dateFrom ? new DateTimeImmutable($dateFrom) : null; - } catch (Exception $e) { - throw new ValidatorException('Invalid date format. Use format: Y-m-d'); - } - - $filter = new SubscriberHistoryFilter( - subscriber: $subscriber, - ip: $request->query->get('ip'), - dateFrom: $dateFromFormated, - summery: $request->query->get('summery'), - ); + $historyData = $this->subscriberService->getSubscriberHistory($request, $subscriber); return $this->json( - data: $this->paginatedDataProvider->getPaginatedList( - request: $request, - normalizer: $this->serializer, - className: SubscriberHistory::class, - filter: $filter - ), + data: $historyData, status: Response::HTTP_OK, ); } @@ -412,7 +370,7 @@ public function deleteSubscriber( if (!$subscriber) { throw $this->createNotFoundException('Subscriber not found.'); } - $this->subscriberManager->deleteSubscriber($subscriber); + $this->subscriberService->deleteSubscriber($subscriber); return $this->json(null, Response::HTTP_NO_CONTENT); } @@ -457,9 +415,9 @@ public function setSubscriberAsConfirmed(Request $request): Response return new Response('

Missing confirmation code.

', 400); } - try { - $this->subscriberManager->markAsConfirmedByUniqueId($uniqueId); - } catch (NotFoundHttpException) { + $subscriber = $this->subscriberService->confirmSubscriber($uniqueId); + + if (!$subscriber) { return new Response('

Subscriber isn\'t found or already confirmed.

', 404); } diff --git a/src/Subscription/Service/SubscriberHistoryService.php b/src/Subscription/Service/SubscriberHistoryService.php new file mode 100644 index 0000000..c37d179 --- /dev/null +++ b/src/Subscription/Service/SubscriberHistoryService.php @@ -0,0 +1,53 @@ +query->get('date_from'); + $dateFromFormated = $dateFrom ? new DateTimeImmutable($dateFrom) : null; + } catch (Exception $e) { + throw new ValidatorException('Invalid date format. Use format: Y-m-d'); + } + + $filter = new SubscriberHistoryFilter( + subscriber: $subscriber, + ip: $request->query->get('ip'), + dateFrom: $dateFromFormated, + summery: $request->query->get('summery'), + ); + + return $this->paginatedDataProvider->getPaginatedList( + request: $request, + normalizer: $this->serializer, + className: SubscriberHistory::class, + filter: $filter + ); + } +} diff --git a/src/Subscription/Service/SubscriberService.php b/src/Subscription/Service/SubscriberService.php new file mode 100644 index 0000000..4a8fa97 --- /dev/null +++ b/src/Subscription/Service/SubscriberService.php @@ -0,0 +1,64 @@ +subscriberManager->createSubscriber($subscriberRequest->getDto()); + return $this->subscriberNormalizer->normalize($subscriber, 'json'); + } + + public function updateSubscriber(UpdateSubscriberRequest $updateSubscriberRequest): array + { + $subscriber = $this->subscriberManager->updateSubscriber($updateSubscriberRequest->getDto()); + return $this->subscriberNormalizer->normalize($subscriber, 'json'); + } + + public function getSubscriber(int $subscriberId): array + { + $subscriber = $this->subscriberManager->getSubscriber($subscriberId); + return $this->subscriberNormalizer->normalize($subscriber); + } + + public function getSubscriberHistory(Request $request, ?Subscriber $subscriber): array + { + return $this->subscriberHistoryService->getSubscriberHistory($request, $subscriber); + } + + public function deleteSubscriber(Subscriber $subscriber): void + { + $this->subscriberManager->deleteSubscriber($subscriber); + } + + public function confirmSubscriber(string $uniqueId): ?Subscriber + { + if (!$uniqueId) { + return null; + } + + try { + return $this->subscriberManager->markAsConfirmedByUniqueId($uniqueId); + } catch (NotFoundHttpException) { + return null; + } + } +}