diff --git a/README.md b/README.md index b7a24171..e6b706f8 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ which also has more detailed installation instructions in the README. ## API Documentation -Visit `/docs` endpoint to access the full interactive documentation for `phpList/rest-api`. +Visit `https://phplist.github.io/restapi-docs/` endpoint to access the full interactive documentation for `phpList/rest-api`. Look at the **"API Documentation with Swagger"** section in the [contribution guide](.github/CONTRIBUTING.md) for more information on API documenation. diff --git a/config/services/validators.yml b/config/services/validators.yml index 02a0b425..e7302414 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -36,3 +36,8 @@ services: autowire: true autoconfigure: true tags: [ 'validator.constraint_validator' ] + + PhpList\Core\Domain\Identity\Validator\AttributeTypeValidator: + autowire: true + autoconfigure: true + diff --git a/src/Common/EventListener/ExceptionListener.php b/src/Common/EventListener/ExceptionListener.php index 6db218f1..b898defa 100644 --- a/src/Common/EventListener/ExceptionListener.php +++ b/src/Common/EventListener/ExceptionListener.php @@ -6,6 +6,7 @@ use Exception; use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; +use PhpList\Core\Domain\Subscription\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Event\ExceptionEvent; @@ -42,6 +43,11 @@ public function onKernelException(ExceptionEvent $event): void 'message' => $exception->getMessage(), ], $exception->getStatusCode()); $event->setResponse($response); + } elseif ($exception instanceof AttributeDefinitionCreationException) { + $response = new JsonResponse([ + 'message' => $exception->getMessage(), + ], $exception->getStatusCode()); + $event->setResponse($response); } elseif ($exception instanceof ValidatorException) { $response = new JsonResponse([ 'message' => $exception->getMessage(), diff --git a/src/Identity/Controller/AdminAttributeDefinitionController.php b/src/Identity/Controller/AdminAttributeDefinitionController.php index ff4c8531..4bf38643 100644 --- a/src/Identity/Controller/AdminAttributeDefinitionController.php +++ b/src/Identity/Controller/AdminAttributeDefinitionController.php @@ -12,8 +12,7 @@ use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Common\Validator\RequestValidator; -use PhpList\RestBundle\Identity\Request\CreateAttributeDefinitionRequest; -use PhpList\RestBundle\Identity\Request\UpdateAttributeDefinitionRequest; +use PhpList\RestBundle\Identity\Request\AdminAttributeDefinitionRequest; use PhpList\RestBundle\Identity\Serializer\AdminAttributeDefinitionNormalizer; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; @@ -51,7 +50,7 @@ public function __construct( requestBody: new OA\RequestBody( description: 'Pass parameters to create admin attribute.', required: true, - content: new OA\JsonContent(ref: '#/components/schemas/CreateAdminAttributeDefinitionRequest') + content: new OA\JsonContent(ref: '#/components/schemas/AdminAttributeDefinitionRequest') ), tags: ['admin-attributes'], parameters: [ @@ -87,8 +86,8 @@ public function create(Request $request): JsonResponse { $this->requireAuthentication($request); - /** @var CreateAttributeDefinitionRequest $definitionRequest */ - $definitionRequest = $this->validator->validate($request, CreateAttributeDefinitionRequest::class); + /** @var AdminAttributeDefinitionRequest $definitionRequest */ + $definitionRequest = $this->validator->validate($request, AdminAttributeDefinitionRequest::class); $attributeDefinition = $this->definitionManager->create($definitionRequest->getDto()); $this->entityManager->flush(); @@ -107,7 +106,7 @@ public function create(Request $request): JsonResponse requestBody: new OA\RequestBody( description: 'Pass parameters to update admin attribute.', required: true, - content: new OA\JsonContent(ref: '#/components/schemas/CreateAdminAttributeDefinitionRequest') + content: new OA\JsonContent(ref: '#/components/schemas/AdminAttributeDefinitionRequest') ), tags: ['admin-attributes'], parameters: [ @@ -153,8 +152,8 @@ public function update( throw $this->createNotFoundException('Attribute definition not found.'); } - /** @var UpdateAttributeDefinitionRequest $definitionRequest */ - $definitionRequest = $this->validator->validate($request, UpdateAttributeDefinitionRequest::class); + /** @var AdminAttributeDefinitionRequest $definitionRequest */ + $definitionRequest = $this->validator->validate($request, AdminAttributeDefinitionRequest::class); $attributeDefinition = $this->definitionManager->update( attributeDefinition: $attributeDefinition, diff --git a/src/Identity/OpenApi/SwaggerSchemasRequest.php b/src/Identity/OpenApi/SwaggerSchemasRequest.php index d0421ecc..3b32ded2 100644 --- a/src/Identity/OpenApi/SwaggerSchemasRequest.php +++ b/src/Identity/OpenApi/SwaggerSchemasRequest.php @@ -5,6 +5,7 @@ namespace PhpList\RestBundle\Identity\OpenApi; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Common\Model\AttributeTypeEnum; #[OA\Schema( schema: 'CreateAdministratorRequest', @@ -96,15 +97,23 @@ type: 'object' )] #[OA\Schema( - schema: 'CreateAdminAttributeDefinitionRequest', + schema: 'AdminAttributeDefinitionRequest', required: ['name'], properties: [ new OA\Property(property: 'name', type: 'string', format: 'string', example: 'Country'), - new OA\Property(property: 'type', type: 'string', example: 'checkbox'), + new OA\Property( + property: 'type', + type: 'string', + enum: [ + AttributeTypeEnum::TextLine, + AttributeTypeEnum::Hidden, + ], + example: 'hidden', + nullable: true + ), new OA\Property(property: 'order', type: 'number', example: 12), new OA\Property(property: 'default_value', type: 'string', example: 'United States'), new OA\Property(property: 'required', type: 'boolean', example: true), - new OA\Property(property: 'table_name', type: 'string', example: 'list_attributes'), ], type: 'object' )] diff --git a/src/Identity/OpenApi/SwaggerSchemasResponse.php b/src/Identity/OpenApi/SwaggerSchemasResponse.php index 52b8837e..2d83cff0 100644 --- a/src/Identity/OpenApi/SwaggerSchemasResponse.php +++ b/src/Identity/OpenApi/SwaggerSchemasResponse.php @@ -27,11 +27,10 @@ properties: [ new OA\Property(property: 'id', type: 'integer', example: 1), new OA\Property(property: 'name', type: 'string', example: 'Country'), - new OA\Property(property: 'type', type: 'string', example: 'select'), + new OA\Property(property: 'type', type: 'string', example: 'hidden'), new OA\Property(property: 'list_order', type: 'integer', example: 12), new OA\Property(property: 'default_value', type: 'string', example: 'United States'), new OA\Property(property: 'required', type: 'boolean', example: true), - new OA\Property(property: 'table_name', type: 'string', example: 'ukcounties'), ], type: 'object' )] diff --git a/src/Identity/Request/AdminAttributeDefinitionRequest.php b/src/Identity/Request/AdminAttributeDefinitionRequest.php new file mode 100644 index 00000000..e31cf2f0 --- /dev/null +++ b/src/Identity/Request/AdminAttributeDefinitionRequest.php @@ -0,0 +1,57 @@ +name, + type: $this->type, + listOrder: $this->order, + defaultValue: $this->defaultValue, + required: $this->required, + ); + } + + public function validateType(ExecutionContextInterface $context): void + { + if ($this->type === null) { + return; + } + + $validator = new AttributeTypeValidator(new IdentityTranslator()); + + try { + $validator->validate($this->type); + } catch (ValidatorException $e) { + $context->buildViolation($e->getMessage()) + ->atPath('type') + ->addViolation(); + } + } +} diff --git a/src/Identity/Request/CreateAttributeDefinitionRequest.php b/src/Identity/Request/CreateAttributeDefinitionRequest.php deleted file mode 100644 index 794d086b..00000000 --- a/src/Identity/Request/CreateAttributeDefinitionRequest.php +++ /dev/null @@ -1,33 +0,0 @@ -name, - type: $this->type, - listOrder: $this->order, - defaultValue: $this->defaultValue, - required: $this->required, - tableName: $this->tableName, - ); - } -} diff --git a/src/Identity/Request/UpdateAttributeDefinitionRequest.php b/src/Identity/Request/UpdateAttributeDefinitionRequest.php deleted file mode 100644 index 8f387392..00000000 --- a/src/Identity/Request/UpdateAttributeDefinitionRequest.php +++ /dev/null @@ -1,33 +0,0 @@ -name, - type: $this->type, - listOrder: $this->order, - defaultValue: $this->defaultValue, - required: $this->required, - tableName: $this->tableName, - ); - } -} diff --git a/src/Subscription/Controller/SubscriberAttributeDefinitionController.php b/src/Subscription/Controller/SubscriberAttributeDefinitionController.php index e096552e..79c3b361 100644 --- a/src/Subscription/Controller/SubscriberAttributeDefinitionController.php +++ b/src/Subscription/Controller/SubscriberAttributeDefinitionController.php @@ -7,13 +7,13 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; +use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager; 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\CreateAttributeDefinitionRequest; -use PhpList\RestBundle\Subscription\Request\UpdateAttributeDefinitionRequest; +use PhpList\RestBundle\Subscription\Request\SubscriberAttributeDefinitionRequest; use PhpList\RestBundle\Subscription\Serializer\AttributeDefinitionNormalizer; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; @@ -21,7 +21,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route('/subscribers/attributes', name: 'subscriber_attribute_definition_')] +#[Route('/attributes', name: 'subscriber_attribute_definition_')] class SubscriberAttributeDefinitionController extends BaseController { private AttributeDefinitionManager $definitionManager; @@ -44,14 +44,14 @@ public function __construct( #[Route('', name: 'create', methods: ['POST'])] #[OA\Post( - path: '/api/v2/subscribers/attributes', + path: '/api/v2/attributes', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 'Returns created subscriber attribute definition.', summary: 'Create a subscriber attribute definition.', requestBody: new OA\RequestBody( description: 'Pass parameters to create subscriber attribute.', required: true, - content: new OA\JsonContent(ref: '#/components/schemas/CreateSubscriberAttributeDefinitionRequest') + content: new OA\JsonContent(ref: '#/components/schemas/SubscriberAttributeDefinitionRequest') ), tags: ['subscriber-attributes'], parameters: [ @@ -74,6 +74,11 @@ public function __construct( description: 'Failure', content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ), + new OA\Response( + response: 409, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/AlreadyExistsResponse') + ), new OA\Response( response: 422, description: 'Failure', @@ -85,8 +90,8 @@ public function create(Request $request): JsonResponse { $this->requireAuthentication($request); - /** @var CreateAttributeDefinitionRequest $definitionRequest */ - $definitionRequest = $this->validator->validate($request, CreateAttributeDefinitionRequest::class); + /** @var SubscriberAttributeDefinitionRequest $definitionRequest */ + $definitionRequest = $this->validator->validate($request, SubscriberAttributeDefinitionRequest::class); $attributeDefinition = $this->definitionManager->create($definitionRequest->getDto()); $this->entityManager->flush(); @@ -97,14 +102,14 @@ public function create(Request $request): JsonResponse #[Route('/{definitionId}', name: 'update', requirements: ['definitionId' => '\d+'], methods: ['PUT'])] #[OA\Put( - path: '/api/v2/subscribers/attributes/{definitionId}', + path: '/api/v2/attributes/{definitionId}', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 'Returns updated subscriber attribute definition.', summary: 'Update a subscriber attribute definition.', requestBody: new OA\RequestBody( description: 'Pass parameters to update subscriber attribute.', required: true, - content: new OA\JsonContent(ref: '#/components/schemas/CreateSubscriberAttributeDefinitionRequest') + content: new OA\JsonContent(ref: '#/components/schemas/SubscriberAttributeDefinitionRequest') ), tags: ['subscriber-attributes'], parameters: [ @@ -150,8 +155,8 @@ public function update( throw $this->createNotFoundException('Attribute definition not found.'); } - /** @var UpdateAttributeDefinitionRequest $definitionRequest */ - $definitionRequest = $this->validator->validate($request, UpdateAttributeDefinitionRequest::class); + /** @var SubscriberAttributeDefinitionRequest $definitionRequest */ + $definitionRequest = $this->validator->validate($request, SubscriberAttributeDefinitionRequest::class); $attributeDefinition = $this->definitionManager->update( attributeDefinition: $attributeDefinition, @@ -165,7 +170,7 @@ public function update( #[Route('/{definitionId}', name: 'delete', requirements: ['definitionId' => '\d+'], methods: ['DELETE'])] #[OA\Delete( - path: '/api/v2/subscribers/attributes/{definitionId}', + path: '/api/v2/attributes/{definitionId}', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 'Deletes a single subscriber attribute definition.', summary: 'Deletes an attribute definition.', @@ -220,7 +225,7 @@ public function delete( #[Route('', name: 'get_list', methods: ['GET'])] #[OA\Get( - path: '/api/v2/subscribers/attributes', + path: '/api/v2/attributes', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 'Returns a JSON list of all subscriber attribute definitions.', summary: 'Gets a list of all subscriber attribute definitions.', @@ -287,7 +292,7 @@ public function getPaginated(Request $request): JsonResponse #[Route('/{definitionId}', name: 'get_one', requirements: ['definitionId' => '\d+'], methods: ['GET'])] #[OA\Get( - path: '/api/v2/subscribers/attributes/{definitionId}', + path: '/api/v2/attributes/{definitionId}', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 'Returns a single attribute with specified ID.', summary: 'Gets attribute with specified ID.', @@ -344,6 +349,13 @@ public function getAttributeDefinition( throw $this->createNotFoundException('Attribute definition not found.'); } + /** @var SubscriberAttributeDefinitionRepository $repo */ + $repo = $this->entityManager->getRepository(SubscriberAttributeDefinition::class); + $hydrated = $repo->findOneByName($attributeDefinition->getName()); + if ($hydrated instanceof SubscriberAttributeDefinition) { + $attributeDefinition = $hydrated; + } + return $this->json( $this->normalizer->normalize($attributeDefinition), Response::HTTP_OK diff --git a/src/Subscription/Controller/SubscriberAttributeValueController.php b/src/Subscription/Controller/SubscriberAttributeValueController.php index 24b54209..433cb74c 100644 --- a/src/Subscription/Controller/SubscriberAttributeValueController.php +++ b/src/Subscription/Controller/SubscriberAttributeValueController.php @@ -351,8 +351,6 @@ public function getAttributeDefinition( throw $this->createNotFoundException('Subscriber attribute not found.'); } $attribute = $this->attributeManager->getSubscriberAttribute($subscriber->getId(), $definition->getId()); - $this->attributeManager->delete($attribute); - $this->entityManager->flush(); return $this->json( $this->normalizer->normalize($attribute), diff --git a/src/Subscription/OpenApi/SwaggerSchemasRequest.php b/src/Subscription/OpenApi/SwaggerSchemasRequest.php deleted file mode 100644 index ac7b12b7..00000000 --- a/src/Subscription/OpenApi/SwaggerSchemasRequest.php +++ /dev/null @@ -1,107 +0,0 @@ -name, - $this->type, - $this->order, - $this->defaultValue, - $this->required, - $this->tableName, - ); - } -} diff --git a/src/Subscription/Request/CreateSubscriberListRequest.php b/src/Subscription/Request/CreateSubscriberListRequest.php index 960bf546..1b40a260 100644 --- a/src/Subscription/Request/CreateSubscriberListRequest.php +++ b/src/Subscription/Request/CreateSubscriberListRequest.php @@ -4,10 +4,22 @@ namespace PhpList\RestBundle\Subscription\Request; +use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\Dto\CreateSubscriberListDto; use PhpList\RestBundle\Common\Request\RequestInterface; use Symfony\Component\Validator\Constraints as Assert; +#[OA\Schema( + schema: 'CreateSubscriberListRequest', + required: ['name'], + properties: [ + new OA\Property(property: 'name', type: 'string', format: 'string', example: 'News'), + new OA\Property(property: 'description', type: 'string', example: 'News (and some fun stuff)'), + new OA\Property(property: 'list_position', type: 'number', example: 12), + new OA\Property(property: 'public', type: 'boolean', example: true), + ], + type: 'object' +)] class CreateSubscriberListRequest implements RequestInterface { #[Assert\NotBlank] diff --git a/src/Subscription/Request/CreateSubscriberRequest.php b/src/Subscription/Request/CreateSubscriberRequest.php index c8b9432e..4ea91fca 100644 --- a/src/Subscription/Request/CreateSubscriberRequest.php +++ b/src/Subscription/Request/CreateSubscriberRequest.php @@ -4,12 +4,23 @@ namespace PhpList\RestBundle\Subscription\Request; +use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\Dto\CreateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\RestBundle\Common\Request\RequestInterface; use PhpList\RestBundle\Subscription\Validator\Constraint\UniqueEmail; use Symfony\Component\Validator\Constraints as Assert; +#[OA\Schema( + schema: 'CreateSubscriberRequest', + required: ['email'], + properties: [ + new OA\Property(property: 'email', type: 'string', format: 'string', example: 'admin@example.com'), + new OA\Property(property: 'request_confirmation', type: 'boolean', example: false), + new OA\Property(property: 'html_email', type: 'boolean', example: false), + ], + type: 'object' +)] class CreateSubscriberRequest implements RequestInterface { #[Assert\NotBlank] diff --git a/src/Subscription/Request/SubscribePageRequest.php b/src/Subscription/Request/SubscribePageRequest.php index cf22d20c..16f3eee5 100644 --- a/src/Subscription/Request/SubscribePageRequest.php +++ b/src/Subscription/Request/SubscribePageRequest.php @@ -14,7 +14,7 @@ class SubscribePageRequest implements RequestInterface public string $title; #[Assert\Type(type: 'bool')] - public ?bool $active = false; + public bool $active = false; public function getDto(): SubscribePageRequest { diff --git a/src/Subscription/Request/SubscriberAttributeDefinitionRequest.php b/src/Subscription/Request/SubscriberAttributeDefinitionRequest.php new file mode 100644 index 00000000..7e4fa02b --- /dev/null +++ b/src/Subscription/Request/SubscriberAttributeDefinitionRequest.php @@ -0,0 +1,115 @@ + [ + new Assert\Type(['type' => DynamicListAttrDto::class]), + ], + ])] + public ?array $options = null; + + public function getDto(): AttributeDefinitionDto + { + $type = null; + if ($this->type !== null) { + $type = AttributeTypeEnum::tryFrom($this->type); + } + return new AttributeDefinitionDto( + name: $this->name, + type: $type, + listOrder: $this->order, + defaultValue: $this->defaultValue, + required: $this->required, + options: $this->options ?? [], + ); + } + + public function validateType(ExecutionContextInterface $context): void + { + if ($this->type === null) { + return; + } + + $validator = new AttributeTypeValidator(new IdentityTranslator()); + + try { + $validator->validate($this->type); + } catch (ValidatorException $e) { + $context->buildViolation($e->getMessage()) + ->atPath('type') + ->addViolation(); + } + } +} diff --git a/src/Subscription/Request/SubscribersExportRequest.php b/src/Subscription/Request/SubscribersExportRequest.php index aad74647..38ccb0c3 100644 --- a/src/Subscription/Request/SubscribersExportRequest.php +++ b/src/Subscription/Request/SubscribersExportRequest.php @@ -5,11 +5,60 @@ namespace PhpList\RestBundle\Subscription\Request; use DateTimeImmutable; +use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberFilter; use PhpList\RestBundle\Common\Request\RequestInterface; use PhpList\RestBundle\Subscription\Validator\Constraint\ListExists; use Symfony\Component\Validator\Constraints as Assert; +#[OA\Schema( + schema: 'ExportSubscriberRequest', + properties: [ + new OA\Property( + property: 'date_type', + description: 'What date needs to be used for filtering (any, signup, changed, changelog, subscribed)', + default: 'any', + enum: ['any', 'signup', 'changed', 'changelog', 'subscribed'] + ), + new OA\Property( + property: 'list_id', + description: 'List ID from where to export', + type: 'integer' + ), + new OA\Property( + property: 'date_from', + description: 'Start date for filtering (format: Y-m-d)', + type: 'string', + format: 'date' + ), + new OA\Property( + property: 'date_to', + description: 'End date for filtering (format: Y-m-d)', + type: 'string', + format: 'date' + ), + new OA\Property( + property: 'columns', + description: 'Columns to include in the export', + type: 'array', + items: new OA\Items(type: 'string'), + default: [ + 'id', + 'email', + 'confirmed', + 'blacklisted', + 'bounceCount', + 'createdAt', + 'updatedAt', + 'uniqueId', + 'htmlEmail', + 'disabled', + 'extraData', + ], + ), + ], + type: 'object' +)] class SubscribersExportRequest implements RequestInterface { /** @@ -69,14 +118,14 @@ public function getDto(): SubscriberFilter [$subscribedFrom, $subscribedTo, $signupFrom, $signupTo, $changedFrom, $changedTo] = $this->resolveDates(); return new SubscriberFilter( - $this->listId ?? null, - $subscribedFrom, - $subscribedTo, - $signupFrom, - $signupTo, - $changedFrom, - $changedTo, - $this->columns + listId: $this->listId ?? null, + subscribedDateFrom: $subscribedFrom, + subscribedDateTo: $subscribedTo, + createdDateFrom: $signupFrom, + createdDateTo: $signupTo, + updatedDateFrom: $changedFrom, + updatedDateTo: $changedTo, + columns: $this->columns ); } } diff --git a/src/Subscription/Request/UpdateAttributeDefinitionRequest.php b/src/Subscription/Request/UpdateAttributeDefinitionRequest.php deleted file mode 100644 index ba4fa951..00000000 --- a/src/Subscription/Request/UpdateAttributeDefinitionRequest.php +++ /dev/null @@ -1,33 +0,0 @@ -name, - $this->type, - $this->order, - $this->defaultValue, - $this->required, - $this->tableName, - ); - } -} diff --git a/src/Subscription/Request/UpdateSubscriberRequest.php b/src/Subscription/Request/UpdateSubscriberRequest.php index 1bce0d7d..4628ccaa 100644 --- a/src/Subscription/Request/UpdateSubscriberRequest.php +++ b/src/Subscription/Request/UpdateSubscriberRequest.php @@ -4,12 +4,26 @@ namespace PhpList\RestBundle\Subscription\Request; +use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\Dto\UpdateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\RestBundle\Common\Request\RequestInterface; use PhpList\RestBundle\Identity\Validator\Constraint\UniqueEmail; use Symfony\Component\Validator\Constraints as Assert; +#[OA\Schema( + schema: 'UpdateSubscriberRequest', + required: ['email'], + properties: [ + new OA\Property(property: 'email', type: 'string', format: 'string', example: 'admin@example.com'), + new OA\Property(property: 'confirmed', type: 'boolean', example: false), + new OA\Property(property: 'blacklisted', type: 'boolean', example: false), + new OA\Property(property: 'html_email', type: 'boolean', example: false), + new OA\Property(property: 'disabled', type: 'boolean', example: false), + new OA\Property(property: 'additional_data', type: 'string', example: 'asdf'), + ], + type: 'object' +)] class UpdateSubscriberRequest implements RequestInterface { public int $subscriberId; diff --git a/src/Subscription/Serializer/AttributeDefinitionNormalizer.php b/src/Subscription/Serializer/AttributeDefinitionNormalizer.php index 598bfad4..73acfc59 100644 --- a/src/Subscription/Serializer/AttributeDefinitionNormalizer.php +++ b/src/Subscription/Serializer/AttributeDefinitionNormalizer.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Subscription\Serializer; +use PhpList\Core\Domain\Subscription\Model\Dto\DynamicListAttrDto; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -18,14 +19,25 @@ public function normalize($object, string $format = null, array $context = []): return []; } + $options = $object->getOptions(); + if (!empty($options)) { + $options = array_map(function ($option) { + return [ + 'id' => $option->id, + 'name' => $option->name, + 'list_order' => $option->listOrder, + ]; + }, $options); + } + return [ 'id' => $object->getId(), 'name' => $object->getName(), - 'type' => $object->getType(), + 'type' => $object->getType() ? $object->getType()->value : null, 'list_order' => $object->getListOrder(), 'default_value' => $object->getDefaultValue(), 'required' => $object->isRequired(), - 'table_name' => $object->getTableName(), + 'options' => $options, ]; } diff --git a/tests/Integration/Identity/Controller/AdminAttributeDefinitionControllerTest.php b/tests/Integration/Identity/Controller/AdminAttributeDefinitionControllerTest.php index dc3245d2..c6ff0acd 100644 --- a/tests/Integration/Identity/Controller/AdminAttributeDefinitionControllerTest.php +++ b/tests/Integration/Identity/Controller/AdminAttributeDefinitionControllerTest.php @@ -31,21 +31,19 @@ public function testCreateAttributeDefinitionWithValidDataReturnsCreated(): void { $this->authenticatedJsonRequest('post', '/api/v2/administrators/attributes', [], [], [], json_encode([ 'name' => 'Test Attribute', - 'type' => 'textarea', + 'type' => 'textline', 'order' => 1, 'defaultValue' => 'default', 'required' => true, - 'tableName' => 'test_table', ])); $this->assertHttpCreated(); $data = $this->getDecodedJsonResponseContent(); self::assertSame('Test Attribute', $data['name']); - self::assertSame('textarea', $data['type']); + self::assertSame('textline', $data['type']); self::assertSame(1, $data['list_order']); self::assertSame('default', $data['default_value']); self::assertTrue($data['required']); - self::assertSame('test_table', $data['table_name']); } public function testUpdateAttributeDefinitionReturnsOk(): void @@ -55,14 +53,14 @@ public function testUpdateAttributeDefinitionReturnsOk(): void $this->authenticatedJsonRequest('put', '/api/v2/administrators/attributes/' . $id, [], [], [], json_encode([ 'name' => 'Updated Attribute', - 'type' => 'checkbox', + 'type' => 'hidden', 'required' => true, ])); $this->assertHttpOkay(); $data = $this->getDecodedJsonResponseContent(); self::assertSame('Updated Attribute', $data['name']); - self::assertSame('checkbox', $data['type']); + self::assertSame('hidden', $data['type']); self::assertTrue($data['required']); } diff --git a/tests/Integration/Identity/Fixtures/AdminAttributeDefinitionFixture.php b/tests/Integration/Identity/Fixtures/AdminAttributeDefinitionFixture.php index f03bc3f4..f351d8dc 100644 --- a/tests/Integration/Identity/Fixtures/AdminAttributeDefinitionFixture.php +++ b/tests/Integration/Identity/Fixtures/AdminAttributeDefinitionFixture.php @@ -42,7 +42,6 @@ public function load(ObjectManager $manager): void $definition->setListOrder((int)$row['list_order']); $definition->setDefaultValue($row['default_value']); $definition->setRequired((bool)$row['required']); - $definition->setTableName($row['table_name']); $manager->persist($definition); } while (true); diff --git a/tests/Integration/Subscription/Controller/SubscriberAttributeDefinitionControllerTest.php b/tests/Integration/Subscription/Controller/SubscriberAttributeDefinitionControllerTest.php index 0768c813..5f8e8fa7 100644 --- a/tests/Integration/Subscription/Controller/SubscriberAttributeDefinitionControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscriberAttributeDefinitionControllerTest.php @@ -23,21 +23,21 @@ public function testControllerIsAvailableViaContainer() public function testGetAttributesWithoutSessionKeyReturnsForbidden() { - self::getClient()->request('GET', '/api/v2/subscribers/attributes'); + self::getClient()->request('GET', '/api/v2/attributes'); $this->assertHttpForbidden(); } public function testGetAttributesWithSessionKeyReturnsOk() { $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); - $this->authenticatedJsonRequest('GET', '/api/v2/subscribers/attributes'); + $this->authenticatedJsonRequest('GET', '/api/v2/attributes'); $this->assertHttpOkay(); } public function testGetAttributeWithInvalidIdReturnsNotFound() { $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); - $this->authenticatedJsonRequest('GET', '/api/v2/subscribers/attributes/999'); + $this->authenticatedJsonRequest('GET', '/api/v2/attributes/999'); $this->assertHttpNotFound(); } @@ -51,10 +51,9 @@ public function testCreateAttributeDefinition() 'order' => 12, 'default_value' => 'United States', 'required' => true, - 'table_name' => 'list_attributes', ]); - $this->authenticatedJsonRequest('POST', '/api/v2/subscribers/attributes', [], [], [], $payload); + $this->authenticatedJsonRequest('POST', '/api/v2/attributes', [], [], [], $payload); $this->assertHttpCreated(); @@ -76,10 +75,9 @@ public function testUpdateAttributeDefinition() 'order' => 10, 'default_value' => 'Canada', 'required' => false, - 'table_name' => 'list_attributes', ]); - $this->authenticatedJsonRequest('PUT', '/api/v2/subscribers/attributes/1', [], [], [], $payload); + $this->authenticatedJsonRequest('PUT', '/api/v2/attributes/1', [], [], [], $payload); $this->assertHttpOkay(); $response = $this->getDecodedJsonResponseContent(); self::assertSame('Updated Country', $response['name']); @@ -93,7 +91,7 @@ public function testDeleteAttributeDefinition() SubscriberAttributeDefinitionFixture::class, ]); - $this->authenticatedJsonRequest('DELETE', '/api/v2/subscribers/attributes/1'); + $this->authenticatedJsonRequest('DELETE', '/api/v2/attributes/1'); $this->assertHttpNoContent(); $repo = self::getContainer()->get(SubscriberAttributeDefinitionRepository::class); @@ -110,7 +108,7 @@ public function testCreateAttributeDefinitionMissingNameReturnsValidationError() 'required' => false ]); - $this->authenticatedJsonRequest('POST', '/api/v2/subscribers/attributes', [], [], [], $payload); + $this->authenticatedJsonRequest('POST', '/api/v2/attributes', [], [], [], $payload); $this->assertHttpUnprocessableEntity(); } } diff --git a/tests/Integration/Subscription/Controller/SubscriberImportControllerTest.php b/tests/Integration/Subscription/Controller/SubscriberImportControllerTest.php index e428df88..29c71115 100644 --- a/tests/Integration/Subscription/Controller/SubscriberImportControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscriberImportControllerTest.php @@ -238,7 +238,7 @@ public function testImportSubscribersWithSkipInvalidEmails(): void self::assertArrayHasKey('errors', $responseContent); self::assertEquals(0, $responseContent['imported']); self::assertEquals(1, $responseContent['skipped']); - self::assertEquals([], $responseContent['errors']); + self::assertEquals(['__Invalid email: invalid-email'], $responseContent['errors']); $this->authenticatedJsonRequest( 'POST', diff --git a/tests/Integration/Subscription/Fixtures/SubscriberAttributeDefinitionFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberAttributeDefinitionFixture.php index c3386b4b..3fbb79a2 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriberAttributeDefinitionFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriberAttributeDefinitionFixture.php @@ -7,6 +7,7 @@ use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Common\DataFixtures\FixtureInterface; use Doctrine\Persistence\ObjectManager; +use PhpList\Core\Domain\Common\Model\AttributeTypeEnum; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; class SubscriberAttributeDefinitionFixture extends Fixture implements FixtureInterface @@ -15,7 +16,7 @@ public function load(ObjectManager $manager): void { $definition = new SubscriberAttributeDefinition(); $definition->setName('Country'); - $definition->setType('checkbox'); + $definition->setType(AttributeTypeEnum::Checkbox); $definition->setListOrder(1); $definition->setDefaultValue('US'); $definition->setRequired(true); diff --git a/tests/Integration/Subscription/Fixtures/SubscriberAttributeValueFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberAttributeValueFixture.php index c373e5db..2219604a 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriberAttributeValueFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriberAttributeValueFixture.php @@ -7,6 +7,7 @@ use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Common\DataFixtures\FixtureInterface; use Doctrine\Persistence\ObjectManager; +use PhpList\Core\Domain\Common\Model\AttributeTypeEnum; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue; @@ -17,7 +18,7 @@ public function load(ObjectManager $manager): void { $definition = new SubscriberAttributeDefinition(); $definition->setName('Country'); - $definition->setType('checkbox'); + $definition->setType(AttributeTypeEnum::Checkbox); $definition->setListOrder(1); $definition->setDefaultValue('US'); $definition->setRequired(true); diff --git a/tests/Unit/Identity/Request/CreateAttributeDefinitionRequestTest.php b/tests/Unit/Identity/Request/AdminAttributeDefinitionRequestTest.php similarity index 76% rename from tests/Unit/Identity/Request/CreateAttributeDefinitionRequestTest.php rename to tests/Unit/Identity/Request/AdminAttributeDefinitionRequestTest.php index 82469388..d8d4accd 100644 --- a/tests/Unit/Identity/Request/CreateAttributeDefinitionRequestTest.php +++ b/tests/Unit/Identity/Request/AdminAttributeDefinitionRequestTest.php @@ -5,20 +5,19 @@ namespace PhpList\RestBundle\Tests\Unit\Identity\Request; use PhpList\Core\Domain\Identity\Model\Dto\AdminAttributeDefinitionDto; -use PhpList\RestBundle\Identity\Request\CreateAttributeDefinitionRequest; +use PhpList\RestBundle\Identity\Request\AdminAttributeDefinitionRequest; use PHPUnit\Framework\TestCase; -class CreateAttributeDefinitionRequestTest extends TestCase +class AdminAttributeDefinitionRequestTest extends TestCase { public function testGetDtoReturnsCorrectDto(): void { - $request = new CreateAttributeDefinitionRequest(); + $request = new AdminAttributeDefinitionRequest(); $request->name = 'Test Attribute'; $request->type = 'text'; $request->order = 5; $request->defaultValue = 'default'; $request->required = true; - $request->tableName = 'test_table'; $dto = $request->getDto(); @@ -28,12 +27,11 @@ public function testGetDtoReturnsCorrectDto(): void $this->assertEquals(5, $dto->listOrder); $this->assertEquals('default', $dto->defaultValue); $this->assertTrue($dto->required); - $this->assertEquals('test_table', $dto->tableName); } public function testGetDtoWithDefaultValues(): void { - $request = new CreateAttributeDefinitionRequest(); + $request = new AdminAttributeDefinitionRequest(); $request->name = 'Test Attribute'; $dto = $request->getDto(); @@ -44,6 +42,5 @@ public function testGetDtoWithDefaultValues(): void $this->assertNull($dto->listOrder); $this->assertNull($dto->defaultValue); $this->assertFalse($dto->required); - $this->assertNull($dto->tableName); } } diff --git a/tests/Unit/Identity/Request/UpdateAttributeDefinitionRequestTest.php b/tests/Unit/Identity/Request/UpdateAttributeDefinitionRequestTest.php deleted file mode 100644 index 41ea27eb..00000000 --- a/tests/Unit/Identity/Request/UpdateAttributeDefinitionRequestTest.php +++ /dev/null @@ -1,49 +0,0 @@ -name = 'Updated Attribute'; - $request->type = 'checkbox'; - $request->order = 10; - $request->defaultValue = 'updated_default'; - $request->required = true; - $request->tableName = 'updated_table'; - - $dto = $request->getDto(); - - $this->assertInstanceOf(AdminAttributeDefinitionDto::class, $dto); - $this->assertEquals('Updated Attribute', $dto->name); - $this->assertEquals('checkbox', $dto->type); - $this->assertEquals(10, $dto->listOrder); - $this->assertEquals('updated_default', $dto->defaultValue); - $this->assertTrue($dto->required); - $this->assertEquals('updated_table', $dto->tableName); - } - - public function testGetDtoWithDefaultValues(): void - { - $request = new UpdateAttributeDefinitionRequest(); - $request->name = 'Updated Attribute'; - - $dto = $request->getDto(); - - $this->assertInstanceOf(AdminAttributeDefinitionDto::class, $dto); - $this->assertEquals('Updated Attribute', $dto->name); - $this->assertNull($dto->type); - $this->assertNull($dto->listOrder); - $this->assertNull($dto->defaultValue); - $this->assertFalse($dto->required); - $this->assertNull($dto->tableName); - } -} diff --git a/tests/Unit/Subscription/Request/CreateAttributeDefinitionRequestTest.php b/tests/Unit/Subscription/Request/CreateAttributeDefinitionRequestTest.php deleted file mode 100644 index d55376b9..00000000 --- a/tests/Unit/Subscription/Request/CreateAttributeDefinitionRequestTest.php +++ /dev/null @@ -1,49 +0,0 @@ -name = 'Test Attribute'; - $request->type = 'text'; - $request->order = 5; - $request->defaultValue = 'default'; - $request->required = true; - $request->tableName = 'test_table'; - - $dto = $request->getDto(); - - $this->assertInstanceOf(AttributeDefinitionDto::class, $dto); - $this->assertEquals('Test Attribute', $dto->name); - $this->assertEquals('text', $dto->type); - $this->assertEquals(5, $dto->listOrder); - $this->assertEquals('default', $dto->defaultValue); - $this->assertTrue($dto->required); - $this->assertEquals('test_table', $dto->tableName); - } - - public function testGetDtoWithDefaultValues(): void - { - $request = new CreateAttributeDefinitionRequest(); - $request->name = 'Test Attribute'; - - $dto = $request->getDto(); - - $this->assertInstanceOf(AttributeDefinitionDto::class, $dto); - $this->assertEquals('Test Attribute', $dto->name); - $this->assertNull($dto->type); - $this->assertNull($dto->listOrder); - $this->assertNull($dto->defaultValue); - $this->assertFalse($dto->required); - $this->assertNull($dto->tableName); - } -} diff --git a/tests/Unit/Subscription/Request/SubscriberAttributeDefinitionRequestTest.php b/tests/Unit/Subscription/Request/SubscriberAttributeDefinitionRequestTest.php new file mode 100644 index 00000000..d96c0585 --- /dev/null +++ b/tests/Unit/Subscription/Request/SubscriberAttributeDefinitionRequestTest.php @@ -0,0 +1,102 @@ +name = 'Test Attribute'; + $request->type = 'textline'; + $request->order = 5; + $request->defaultValue = 'default'; + $request->required = true; + + $dto = $request->getDto(); + + $this->assertInstanceOf(AttributeDefinitionDto::class, $dto); + $this->assertEquals('Test Attribute', $dto->name); + $this->assertInstanceOf(AttributeTypeEnum::class, $dto->type); + $this->assertSame(AttributeTypeEnum::TextLine, $dto->type); + $this->assertEquals(5, $dto->listOrder); + $this->assertEquals('default', $dto->defaultValue); + $this->assertTrue($dto->required); + $this->assertIsArray($dto->options); + } + + public function testGetDtoWithDefaultValues(): void + { + $request = new SubscriberAttributeDefinitionRequest(); + $request->name = 'Test Attribute'; + + $dto = $request->getDto(); + + $this->assertInstanceOf(AttributeDefinitionDto::class, $dto); + $this->assertEquals('Test Attribute', $dto->name); + $this->assertNull($dto->type); + $this->assertNull($dto->listOrder); + $this->assertNull($dto->defaultValue); + $this->assertFalse($dto->required); + $this->assertIsArray($dto->options); + $this->assertSame([], $dto->options); + } + + public function testGetDtoWithOptions(): void + { + $request = new SubscriberAttributeDefinitionRequest(); + $request->name = 'With options'; + $request->type = 'select'; + $request->options = [ + new DynamicListAttrDto(null, 'Option A', 1), + new DynamicListAttrDto(5, 'Option B', 2), + ]; + + $dto = $request->getDto(); + + $this->assertInstanceOf(AttributeDefinitionDto::class, $dto); + $this->assertSame('With options', $dto->name); + $this->assertSame(AttributeTypeEnum::Select, $dto->type); + $this->assertCount(2, $dto->options); + $this->assertInstanceOf(DynamicListAttrDto::class, $dto->options[0]); + $this->assertInstanceOf(DynamicListAttrDto::class, $dto->options[1]); + $this->assertSame('Option A', $dto->options[0]->name); + $this->assertSame('Option B', $dto->options[1]->name); + } + + + public function testValidationFailsWhenOptionsContainNonDto(): void + { + $request = new SubscriberAttributeDefinitionRequest(); + $request->name = 'Mixed options'; + $request->options = ['foo']; + + $validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator(); + $violations = $validator->validate($request); + + $this->assertGreaterThan(0, $violations->count()); + $this->assertStringStartsWith('options[', $violations->get(0)->getPropertyPath()); + } + + public function testValidationFailsOnInvalidType(): void + { + $request = new SubscriberAttributeDefinitionRequest(); + $request->name = 'Invalid type'; + $request->type = 'not-a-valid-type'; + + $validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator(); + $violations = $validator->validate($request); + + $this->assertGreaterThan(0, $violations->count()); + $this->assertSame('type', $violations->get(0)->getPropertyPath()); + } +} diff --git a/tests/Unit/Subscription/Request/UpdateAttributeDefinitionRequestTest.php b/tests/Unit/Subscription/Request/UpdateAttributeDefinitionRequestTest.php deleted file mode 100644 index 512b5342..00000000 --- a/tests/Unit/Subscription/Request/UpdateAttributeDefinitionRequestTest.php +++ /dev/null @@ -1,49 +0,0 @@ -name = 'Test Attribute'; - $request->type = 'text'; - $request->order = 5; - $request->defaultValue = 'default'; - $request->required = true; - $request->tableName = 'test_table'; - - $dto = $request->getDto(); - - $this->assertInstanceOf(AttributeDefinitionDto::class, $dto); - $this->assertEquals('Test Attribute', $dto->name); - $this->assertEquals('text', $dto->type); - $this->assertEquals(5, $dto->listOrder); - $this->assertEquals('default', $dto->defaultValue); - $this->assertTrue($dto->required); - $this->assertEquals('test_table', $dto->tableName); - } - - public function testGetDtoWithDefaultValues(): void - { - $request = new UpdateAttributeDefinitionRequest(); - $request->name = 'Test Attribute'; - - $dto = $request->getDto(); - - $this->assertInstanceOf(AttributeDefinitionDto::class, $dto); - $this->assertEquals('Test Attribute', $dto->name); - $this->assertNull($dto->type); - $this->assertNull($dto->listOrder); - $this->assertNull($dto->defaultValue); - $this->assertFalse($dto->required); - $this->assertNull($dto->tableName); - } -} diff --git a/tests/Unit/Subscription/Serializer/AttributeDefinitionNormalizerTest.php b/tests/Unit/Subscription/Serializer/AttributeDefinitionNormalizerTest.php index c2b8dc0e..3821a8f0 100644 --- a/tests/Unit/Subscription/Serializer/AttributeDefinitionNormalizerTest.php +++ b/tests/Unit/Subscription/Serializer/AttributeDefinitionNormalizerTest.php @@ -4,6 +4,8 @@ namespace PhpList\RestBundle\Tests\Unit\Subscription\Serializer; +use PhpList\Core\Domain\Common\Model\AttributeTypeEnum; +use PhpList\Core\Domain\Subscription\Model\Dto\DynamicListAttrDto; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\RestBundle\Subscription\Serializer\AttributeDefinitionNormalizer; use PHPUnit\Framework\TestCase; @@ -26,11 +28,10 @@ public function testNormalize(): void $definition = $this->createMock(SubscriberAttributeDefinition::class); $definition->method('getId')->willReturn(1); $definition->method('getName')->willReturn('Country'); - $definition->method('getType')->willReturn('text'); + $definition->method('getType')->willReturn(AttributeTypeEnum::Text); $definition->method('getListOrder')->willReturn(12); $definition->method('getDefaultValue')->willReturn('US'); $definition->method('isRequired')->willReturn(true); - $definition->method('getTableName')->willReturn('user_attribute'); $normalizer = new AttributeDefinitionNormalizer(); $result = $normalizer->normalize($definition); @@ -43,7 +44,7 @@ public function testNormalize(): void 'list_order' => 12, 'default_value' => 'US', 'required' => true, - 'table_name' => 'user_attribute', + 'options' => [], ], $result); } @@ -54,4 +55,54 @@ public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void self::assertSame([], $result); } + + public function testNormalizeWithOptions(): void + { + $options = [ + new DynamicListAttrDto( + id: 10, + name: 'USA', + listOrder: 1 + ), + new DynamicListAttrDto( + id: 20, + name: 'Canada', + listOrder: 2 + ), + ]; + + $definition = $this->createMock(SubscriberAttributeDefinition::class); + $definition->method('getId')->willReturn(5); + $definition->method('getName')->willReturn('Country'); + $definition->method('getType')->willReturn(AttributeTypeEnum::Select); + $definition->method('getListOrder')->willReturn(3); + $definition->method('getDefaultValue')->willReturn(null); + $definition->method('isRequired')->willReturn(false); + $definition->method('getOptions')->willReturn($options); + + $normalizer = new AttributeDefinitionNormalizer(); + $result = $normalizer->normalize($definition); + + self::assertIsArray($result); + + self::assertSame([ + 'id' => 5, + 'name' => 'Country', + 'type' => 'select', + 'list_order' => 3, + 'default_value' => null, + 'required' => false, + 'options' => [ + [ + 'id' => 10, + 'name' => 'USA', + 'list_order' => 1, + ], [ + 'id' => 20, + 'name' => 'Canada', + 'list_order' => 2, + ], + ], + ], $result); + } }