From 784bb28794e6947cd2deec1e1eff36fb38399977 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 30 Apr 2026 14:09:53 +0400 Subject: [PATCH 1/8] Fix: swagger response --- src/Messaging/Controller/BounceController.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Messaging/Controller/BounceController.php b/src/Messaging/Controller/BounceController.php index 3ab0794..30587aa 100644 --- a/src/Messaging/Controller/BounceController.php +++ b/src/Messaging/Controller/BounceController.php @@ -81,8 +81,15 @@ public function __construct( response: 200, description: 'Success', content: new OA\JsonContent( - type: 'array', - items: new OA\Items(ref: '#/components/schemas/BounceView') + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/BounceView') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' ) ), new OA\Response( From addb913dae4f2f90721bd8110752bb0426d118b2 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 18 May 2026 13:04:53 +0400 Subject: [PATCH 2/8] Add workflow for updating OpenAPI specs in web frontend and limit client-docs to specific branches --- .github/workflows/client-docs.yml | 5 +- .github/workflows/front-docs.yml | 120 ++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/front-docs.yml diff --git a/.github/workflows/client-docs.yml b/.github/workflows/client-docs.yml index b961c1e..33eee07 100644 --- a/.github/workflows/client-docs.yml +++ b/.github/workflows/client-docs.yml @@ -3,8 +3,11 @@ name: Update phplist-api-client OpenAPI on: push: branches: - - '**' + - dev + - main pull_request: + branches: + - main jobs: generate-openapi: diff --git a/.github/workflows/front-docs.yml b/.github/workflows/front-docs.yml new file mode 100644 index 0000000..7a6d845 --- /dev/null +++ b/.github/workflows/front-docs.yml @@ -0,0 +1,120 @@ +name: Update phplist-web-frontend OpenAPI + +on: + push: + branches: + - dev + - main + pull_request: + branches: + - main +jobs: + generate-openapi: + runs-on: ubuntu-22.04 + outputs: + source_branch: ${{ steps.branch.outputs.source_branch }} + steps: + - name: Determine source branch + id: branch + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "source_branch=${{ github.head_ref }}" >> "$GITHUB_OUTPUT" + else + echo "source_branch=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout Source Repository + uses: actions/checkout@v3 + + - name: Setup PHP with Composer and Extensions + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + extensions: mbstring, dom, fileinfo, mysql + + - name: Cache Composer Dependencies + uses: actions/cache@v3 + with: + path: ~/.composer/cache + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install Composer Dependencies + run: composer install --no-interaction --prefer-dist + + - name: Generate OpenAPI Specification JSON + run: vendor/bin/openapi -o docs/latest-restapi.json --format json src + + - name: Upload OpenAPI Artifact + uses: actions/upload-artifact@v4 + with: + name: openapi-json + path: docs/latest-restapi.json + + update-web-frontend: + runs-on: ubuntu-22.04 + needs: generate-openapi + env: + TARGET_BRANCH: ${{ needs.generate-openapi.outputs.source_branch }} + steps: + - name: Checkout phplist-web-frontend Repository + uses: actions/checkout@v3 + with: + repository: phplist/phplist-web-frontend + token: ${{ secrets.PUSH_WEB_FRONTEND }} + fetch-depth: 0 + + - name: Prepare target branch + run: | + git fetch origin + + if git ls-remote --exit-code --heads origin "$TARGET_BRANCH" >/dev/null 2>&1; then + git checkout "$TARGET_BRANCH" + git pull --rebase origin "$TARGET_BRANCH" + else + git checkout -b "$TARGET_BRANCH" + fi + + - name: Download Generated OpenAPI JSON + uses: actions/download-artifact@v4 + with: + name: openapi-json + path: ./new-openapi + + - name: Compare and Check for Differences + id: diff + run: | + # Compare the openapi files if old exists, else always deploy + if [ -f openapi.json ]; then + diff openapi.json new-openapi/latest-restapi.json > openapi-diff.txt || true + if [ -s openapi-diff.txt ]; then + echo "diff=true" >> "$GITHUB_OUTPUT" + else + echo "diff=false" >> "$GITHUB_OUTPUT" + fi + else + echo "No previous openapi.json, will add." + echo "diff=true" >> "$GITHUB_OUTPUT" + fi + + - name: Update and Commit OpenAPI File + if: steps.diff.outputs.diff == 'true' + run: | + set -euo pipefail + cp new-openapi/latest-restapi.json openapi.json + git config user.name "github-actions" + git config user.email "github-actions@web-frontend.workflow" + git add openapi.json + if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi + git commit -m "Update openapi.json from web frontend workflow $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + git fetch origin "$TARGET_BRANCH" + git rebase "origin/$TARGET_BRANCH" + git push origin HEAD:"$TARGET_BRANCH" + + - name: Skip Commit if No Changes + if: steps.diff.outputs.diff == 'false' + run: echo "No changes to openapi.json, skipping commit." From a92815449dcffe5ed350b02dbd8749e185706214 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 20 May 2026 14:17:18 +0400 Subject: [PATCH 3/8] Add endpoint to retrieve all subscribe pages --- .github/workflows/front-docs.yml | 4 +- composer.json | 2 +- .../Controller/SubscribePageController.php | 78 +++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/.github/workflows/front-docs.yml b/.github/workflows/front-docs.yml index 7a6d845..46d544d 100644 --- a/.github/workflows/front-docs.yml +++ b/.github/workflows/front-docs.yml @@ -58,10 +58,10 @@ jobs: env: TARGET_BRANCH: ${{ needs.generate-openapi.outputs.source_branch }} steps: - - name: Checkout phplist-web-frontend Repository + - name: Checkout phpList-web-frontend Repository uses: actions/checkout@v3 with: - repository: phplist/phplist-web-frontend + repository: phpList/web-frontend token: ${{ secrets.PUSH_WEB_FRONTEND }} fetch-depth: 0 diff --git a/composer.json b/composer.json index 92598e8..477d302 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-main", + "phplist/core": "dev-dev", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index ef7a59c..1959dd8 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -6,11 +6,13 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Common\Model\Filter\PaginatedFilter; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Subscription\Model\SubscribePage; use PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager; 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\SubscribePageDataRequest; use PhpList\RestBundle\Subscription\Request\SubscribePageRequest; @@ -30,10 +32,86 @@ public function __construct( private readonly SubscribePageManager $subscribePageManager, private readonly SubscribePageNormalizer $normalizer, private readonly EntityManagerInterface $entityManager, + private readonly PaginatedDataProvider $paginatedProvider, ) { parent::__construct($authentication, $validator); } + #[Route('/', name: 'get_all', methods: ['GET'])] + #[OA\Get( + path: '/api/v2/subscribe-pages', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Get subscribe pages list', + tags: ['subscriptions'], + 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: 'after_id', + description: 'Last id (starting from 0)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) + ), + new OA\Parameter( + name: 'limit', + description: 'Number of results per page', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1) + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/SubscribePage') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Failure', + 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 getPages(Request $request): JsonResponse + { + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { + throw $this->createAccessDeniedException('You are not allowed to view subscribe pages.'); + } + + return $this->json( + $this->paginatedProvider->getPaginatedList( + request: $request, + normalizer: $this->normalizer, + className: SubscribePage::class, + filter: new PaginatedFilter(), + ), + Response::HTTP_OK + ); + } + #[Route('/{id}', name: 'get', requirements: ['id' => '\\d+'], methods: ['GET'])] #[OA\Get( path: '/api/v2/subscribe-pages/{id}', From 112abce81e51fe17fb0744511684e2d3cd603410 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 23 May 2026 16:36:07 +0400 Subject: [PATCH 4/8] Developer --- src/PhpListRestBundle.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhpListRestBundle.php b/src/PhpListRestBundle.php index a856c86..dd20cbf 100644 --- a/src/PhpListRestBundle.php +++ b/src/PhpListRestBundle.php @@ -18,7 +18,7 @@ description: 'This is the OpenAPI documentation for phpList API.', title: 'phpList API Documentation', contact: new OA\Contact( - email: 'support@phplist.com' + email: 'tatevik@phplist.com' ), license: new OA\License( name: 'AGPL-3.0-or-later', From f03fe5578be020c4633dd9d3b2559d32b9eeb082 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 25 May 2026 15:50:22 +0400 Subject: [PATCH 5/8] Add support for subscribe page data management and validation --- .../Controller/SubscribePageController.php | 204 ++++-------------- .../Request/SubscribePageRequest.php | 55 +++++ .../SubscribePageDataNormalizer.php | 42 ++++ .../Serializer/SubscribePageNormalizer.php | 9 + .../SubscribePageControllerTest.php | 135 +++--------- .../SubscribePageNormalizerTest.php | 12 +- 6 files changed, 181 insertions(+), 276 deletions(-) create mode 100644 src/Subscription/Serializer/SubscribePageDataNormalizer.php diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index 1959dd8..61ce70a 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -14,7 +14,6 @@ use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Common\Validator\RequestValidator; -use PhpList\RestBundle\Subscription\Request\SubscribePageDataRequest; use PhpList\RestBundle\Subscription\Request\SubscribePageRequest; use PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer; use Symfony\Bridge\Doctrine\Attribute\MapEntity; @@ -152,16 +151,12 @@ className: SubscribePage::class, ), ] )] - public function getPage( - Request $request, - #[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null - ): JsonResponse { - $admin = $this->requireAuthentication($request); - if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { - throw $this->createAccessDeniedException('You are not allowed to view subscribe pages.'); - } + public function getPage(Request $request): JsonResponse + { + $admin = $this->authentication->authenticateByApiKey($request); + $page = $this->subscribePageManager->findPage(id: (int) $request->get('id')); - if (!$page) { + if (!$page || ($page->isActive() === false && $admin === null)) { throw $this->createNotFoundException('Subscribe page not found'); } @@ -179,6 +174,18 @@ public function getPage( properties: [ new OA\Property(property: 'title', type: 'string'), new OA\Property(property: 'active', type: 'boolean', nullable: true), + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'key', type: 'string'), + new OA\Property(property: 'value', type: 'string'), + ], + type: 'object' + ), + nullable: true + ), ] ) ), @@ -221,6 +228,10 @@ public function createPage(Request $request): JsonResponse $createRequest = $this->validator->validate($request, SubscribePageRequest::class); $page = $this->subscribePageManager->createPage($createRequest->title, $createRequest->active, $admin); + if ($createRequest->hasData()) { + $this->entityManager->flush(); + $this->subscribePageManager->syncPageData($createRequest->getDataMap(), $page); + } $this->entityManager->flush(); return $this->json($this->normalizer->normalize($page), Response::HTTP_CREATED); @@ -237,6 +248,18 @@ public function createPage(Request $request): JsonResponse properties: [ new OA\Property(property: 'title', type: 'string', nullable: true), new OA\Property(property: 'active', type: 'boolean', nullable: true), + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'key', type: 'string'), + new OA\Property(property: 'value', type: 'string'), + ], + type: 'object' + ), + nullable: true + ), ] ) ), @@ -297,6 +320,9 @@ public function updatePage( active: $updateRequest->active, owner: $admin, ); + if ($updateRequest->hasData()) { + $this->subscribePageManager->syncPageData(data: $updateRequest->getDataMap(), page: $page); + } $this->entityManager->flush(); return $this->json($this->normalizer->normalize($updated), Response::HTTP_OK); @@ -356,162 +382,4 @@ public function deletePage( return $this->json(null, Response::HTTP_NO_CONTENT); } - - #[Route('/{id}/data', name: 'get_data', requirements: ['id' => '\\d+'], methods: ['GET'])] - #[OA\Get( - path: '/api/v2/subscribe-pages/{id}/data', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', - summary: 'Get subscribe page data', - tags: ['subscriptions'], - 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: 'id', - description: 'Subscribe page ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'integer') - ) - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent( - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'id', type: 'integer'), - new OA\Property(property: 'name', type: 'string'), - new OA\Property(property: 'data', type: 'string', nullable: true), - ], - type: 'object' - ) - ) - ), - new OA\Response( - response: 403, - description: 'Failure', - 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 getPageData( - Request $request, - #[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null - ): JsonResponse { - $admin = $this->requireAuthentication($request); - if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { - throw $this->createAccessDeniedException('You are not allowed to view subscribe page data.'); - } - - if (!$page) { - throw $this->createNotFoundException('Subscribe page not found'); - } - - $data = $this->subscribePageManager->getPageData($page); - - $json = array_map(static function ($item) { - return [ - 'id' => $item->getId(), - 'name' => $item->getName(), - 'data' => $item->getData(), - ]; - }, $data); - - return $this->json($json, Response::HTTP_OK); - } - - #[Route('/{id}/data', name: 'set_data', requirements: ['id' => '\\d+'], methods: ['PUT'])] - #[OA\Put( - path: '/api/v2/subscribe-pages/{id}/data', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', - summary: 'Set subscribe page data item', - requestBody: new OA\RequestBody( - required: true, - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'name', type: 'string'), - new OA\Property(property: 'value', type: 'string', nullable: true), - ] - ) - ), - tags: ['subscriptions'], - 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: 'id', - description: 'Subscribe page ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'integer') - ) - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'id', type: 'integer'), - new OA\Property(property: 'name', type: 'string'), - new OA\Property(property: 'data', type: 'string', nullable: true), - ], - type: 'object' - ) - ), - new OA\Response( - response: 403, - description: 'Failure', - 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 setPageData( - Request $request, - #[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null - ): JsonResponse { - $admin = $this->requireAuthentication($request); - if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { - throw $this->createAccessDeniedException('You are not allowed to update subscribe page data.'); - } - - if (!$page) { - throw $this->createNotFoundException('Subscribe page not found'); - } - - /** @var SubscribePageDataRequest $createRequest */ - $createRequest = $this->validator->validate($request, SubscribePageDataRequest::class); - - $item = $this->subscribePageManager->setPageData($page, $createRequest->name, $createRequest->value); - $this->entityManager->flush(); - - return $this->json([ - 'id' => $item->getId(), - 'name' => $item->getName(), - 'data' => $item->getData(), - ], Response::HTTP_OK); - } } diff --git a/src/Subscription/Request/SubscribePageRequest.php b/src/Subscription/Request/SubscribePageRequest.php index 16f3eee..31447f1 100644 --- a/src/Subscription/Request/SubscribePageRequest.php +++ b/src/Subscription/Request/SubscribePageRequest.php @@ -16,6 +16,61 @@ class SubscribePageRequest implements RequestInterface #[Assert\Type(type: 'bool')] public bool $active = false; + /** + * @var array|null + */ + #[Assert\Type(type: 'array')] + #[Assert\All(constraints: [ + new Assert\Collection( + fields: [ + 'key' => new Assert\Required([ + new Assert\NotBlank(), + new Assert\Type(type: 'string'), + ]), + 'value' => new Assert\Required([ + new Assert\Type(type: 'string'), + ]), + ], + allowExtraFields: false, + allowMissingFields: false + ), + ])] + private ?array $data = null; + + private bool $dataProvided = false; + + public function setData(?array $data): void + { + $this->data = $data; + $this->dataProvided = true; + } + + public function hasData(): bool + { + return $this->dataProvided; + } + + /** @return array|null */ + public function getData(): ?array + { + return $this->data; + } + + /** @return array */ + public function getDataMap(): array + { + if ($this->data === null) { + return []; + } + + $result = []; + foreach ($this->data as $item) { + $result[$item['key']] = $item['value']; + } + + return $result; + } + public function getDto(): SubscribePageRequest { return $this; diff --git a/src/Subscription/Serializer/SubscribePageDataNormalizer.php b/src/Subscription/Serializer/SubscribePageDataNormalizer.php new file mode 100644 index 0000000..c22e6f5 --- /dev/null +++ b/src/Subscription/Serializer/SubscribePageDataNormalizer.php @@ -0,0 +1,42 @@ + $object->getName(), + 'value' => $object->getData(), + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof SubscribePageData; + } +} diff --git a/src/Subscription/Serializer/SubscribePageNormalizer.php b/src/Subscription/Serializer/SubscribePageNormalizer.php index d58a663..702b648 100644 --- a/src/Subscription/Serializer/SubscribePageNormalizer.php +++ b/src/Subscription/Serializer/SubscribePageNormalizer.php @@ -16,12 +16,18 @@ new OA\Property(property: 'title', type: 'string', example: 'Subscribe to our newsletter'), new OA\Property(property: 'active', type: 'boolean', example: true), new OA\Property(property: 'owner', ref: '#/components/schemas/Administrator'), + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/SubscribePageData') + ), ], )] class SubscribePageNormalizer implements NormalizerInterface { public function __construct( private readonly AdministratorNormalizer $adminNormalizer, + private readonly SubscribePageDataNormalizer $dataNormalizer, ) { } @@ -39,6 +45,9 @@ public function normalize($object, string $format = null, array $context = []): 'title' => $object->getTitle(), 'active' => $object->isActive(), 'owner' => $this->adminNormalizer->normalize($object->getOwner()), + 'data' => array_map(function ($data) { + return $this->dataNormalizer->normalize($data); + }, $object->getData()) ]; } diff --git a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php index fa2d541..9f43254 100644 --- a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php @@ -70,6 +70,9 @@ public function testCreateSubscribePageWithoutSessionReturnsForbidden(): void $payload = json_encode([ 'title' => 'new-page@example.org', 'active' => true, + 'data' => [ + ['key' => 'intro_text', 'value' => 'Welcome'], + ], ], JSON_THROW_ON_ERROR); $this->jsonRequest('POST', '/api/v2/subscribe-pages', content: $payload); @@ -83,6 +86,9 @@ public function testCreateSubscribePageWithSessionCreatesPage(): void $payload = json_encode([ 'title' => 'new-page@example.org', 'active' => true, + 'data' => [ + ['key' => 'intro_text', 'value' => 'Welcome'], + ], ], JSON_THROW_ON_ERROR); $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages', content: $payload); @@ -108,12 +114,34 @@ public function testUpdateSubscribePageWithoutSessionReturnsForbidden(): void $payload = json_encode([ 'title' => 'updated-page@example.org', 'active' => false, + 'data' => [ + ['key' => 'intro_text', 'value' => 'Updated text'], + ], ], JSON_THROW_ON_ERROR); $this->jsonRequest('PUT', '/api/v2/subscribe-pages/1', content: $payload); $this->assertHttpForbidden(); } + public function testCreateSubscribePageWithDataMissingValueReturnsUnprocessableEntity(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + $payload = json_encode([ + 'title' => 'new-page@example.org', + 'active' => true, + 'data' => [ + ['key' => 'intro_text'], + ], + ], JSON_THROW_ON_ERROR); + + $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages', content: $payload); + $this->assertHttpUnprocessableEntity(); + } + public function testUpdateSubscribePageWithSessionReturnsOk(): void { $this->loadFixtures([ @@ -124,6 +152,9 @@ public function testUpdateSubscribePageWithSessionReturnsOk(): void $payload = json_encode([ 'title' => 'updated-page@example.org', 'active' => false, + 'data' => [ + ['key' => 'intro_text', 'value' => 'Updated text'], + ], ], JSON_THROW_ON_ERROR); $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/1', content: $payload); @@ -185,108 +216,4 @@ public function testDeleteSubscribePageWithSessionNotFound(): void $this->authenticatedJsonRequest('DELETE', '/api/v2/subscribe-pages/9999'); $this->assertHttpNotFound(); } - - public function testGetSubscribePageDataWithoutSessionReturnsForbidden(): void - { - $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); - $this->jsonRequest('GET', '/api/v2/subscribe-pages/1/data'); - $this->assertHttpForbidden(); - } - - public function testGetSubscribePageDataWithSessionReturnsArray(): void - { - $this->loadFixtures([ - AdministratorFixture::class, - AdministratorTokenFixture::class, - SubscribePageFixture::class, - ]); - - $this->authenticatedJsonRequest('GET', '/api/v2/subscribe-pages/1/data'); - $this->assertHttpOkay(); - $data = $this->getDecodedJsonResponseContent(); - self::assertIsArray($data); - - if (!empty($data)) { - self::assertArrayHasKey('id', $data[0]); - self::assertArrayHasKey('name', $data[0]); - self::assertArrayHasKey('data', $data[0]); - } - } - - public function testGetSubscribePageDataWithSessionNotFound(): void - { - $this->loadFixtures([ - AdministratorFixture::class, - AdministratorTokenFixture::class, - SubscribePageFixture::class, - ]); - - $this->authenticatedJsonRequest('GET', '/api/v2/subscribe-pages/9999/data'); - $this->assertHttpNotFound(); - } - - public function testSetSubscribePageDataWithoutSessionReturnsForbidden(): void - { - $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); - $payload = json_encode([ - 'name' => 'intro_text', - 'value' => 'Hello world', - ], JSON_THROW_ON_ERROR); - - $this->jsonRequest('PUT', '/api/v2/subscribe-pages/1/data', content: $payload); - $this->assertHttpForbidden(); - } - - public function testSetSubscribePageDataWithMissingNameReturnsUnprocessableEntity(): void - { - $this->loadFixtures([ - AdministratorFixture::class, - AdministratorTokenFixture::class, - SubscribePageFixture::class, - ]); - $payload = json_encode([ - 'value' => 'Hello world', - ], JSON_THROW_ON_ERROR); - - $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/1/data', content: $payload); - $this->assertHttpUnprocessableEntity(); - } - - public function testSetSubscribePageDataWithSessionReturnsOk(): void - { - $this->loadFixtures([ - AdministratorFixture::class, - AdministratorTokenFixture::class, - SubscribePageFixture::class, - ]); - $payload = json_encode([ - 'name' => 'intro_text', - 'value' => 'Hello world', - ], JSON_THROW_ON_ERROR); - - $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/1/data', content: $payload); - $this->assertHttpOkay(); - $data = $this->getDecodedJsonResponseContent(); - self::assertArrayHasKey('id', $data); - self::assertArrayHasKey('name', $data); - self::assertArrayHasKey('data', $data); - self::assertSame('intro_text', $data['name']); - self::assertSame('Hello world', $data['data']); - } - - public function testSetSubscribePageDataWithSessionNotFound(): void - { - $this->loadFixtures([ - AdministratorFixture::class, - AdministratorTokenFixture::class, - SubscribePageFixture::class, - ]); - $payload = json_encode([ - 'name' => 'intro_text', - 'value' => 'Hello world', - ], JSON_THROW_ON_ERROR); - - $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/9999/data', content: $payload); - $this->assertHttpNotFound(); - } } diff --git a/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php b/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php index 523e590..f579b62 100644 --- a/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php +++ b/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php @@ -7,6 +7,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Subscription\Model\SubscribePage; use PhpList\RestBundle\Identity\Serializer\AdministratorNormalizer; +use PhpList\RestBundle\Subscription\Serializer\SubscribePageDataNormalizer; use PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer; use PHPUnit\Framework\TestCase; use stdClass; @@ -16,7 +17,8 @@ class SubscribePageNormalizerTest extends TestCase public function testSupportsNormalization(): void { $adminNormalizer = $this->createMock(AdministratorNormalizer::class); - $normalizer = new SubscribePageNormalizer($adminNormalizer); + $subscribePageDataNormalizer = $this->createMock(SubscribePageDataNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer, $subscribePageDataNormalizer); $page = $this->createMock(SubscribePage::class); @@ -49,13 +51,15 @@ public function testNormalizeReturnsExpectedArray(): void $adminNormalizer = $this->createMock(AdministratorNormalizer::class); $adminNormalizer->method('normalize')->with($owner)->willReturn($adminData); - $normalizer = new SubscribePageNormalizer($adminNormalizer); + $subscribePageDataNormalizer = $this->createMock(SubscribePageDataNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer, $subscribePageDataNormalizer); $expected = [ 'id' => 42, 'title' => 'welcome@example.org', 'active' => true, 'owner' => $adminData, + 'data' => [], ]; $this->assertSame($expected, $normalizer->normalize($page)); @@ -64,8 +68,8 @@ public function testNormalizeReturnsExpectedArray(): void public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void { $adminNormalizer = $this->createMock(AdministratorNormalizer::class); - $normalizer = new SubscribePageNormalizer($adminNormalizer); - + $subscribePageDataNormalizer = $this->createMock(SubscribePageDataNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer, $subscribePageDataNormalizer); $this->assertSame([], $normalizer->normalize(new stdClass())); } } From 4a4ddfdd40da7882b629a64b22728912f4397798 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 26 May 2026 12:24:16 +0400 Subject: [PATCH 6/8] Add public page to return list data --- .../Controller/SubscribePageController.php | 2 +- .../Controller/SubscriberListController.php | 62 +++++++++++++++++++ .../SubscribePageControllerTest.php | 4 +- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index 61ce70a..08ca702 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -122,7 +122,7 @@ className: SubscribePage::class, name: 'php-auth-pw', description: 'Session key obtained from login', in: 'header', - required: true, + required: false, schema: new OA\Schema(type: 'string') ), new OA\Parameter( diff --git a/src/Subscription/Controller/SubscriberListController.php b/src/Subscription/Controller/SubscriberListController.php index 44b6e5c..99996c0 100644 --- a/src/Subscription/Controller/SubscriberListController.php +++ b/src/Subscription/Controller/SubscriberListController.php @@ -181,6 +181,68 @@ public function getList( return $this->json($this->normalizer->normalize($list), Response::HTTP_OK); } + #[Route('/{listId}/public', name: 'get_one_public', requirements: ['listId' => '\d+'], methods: ['GET'])] + #[OA\Get( + path: '/api/v2/lists/{listId}/public', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns a single subscriber list with specified ID.', + summary: 'Gets a subscriber list.', + tags: ['lists'], + parameters: [ + new OA\Parameter( + name: 'listId', + description: 'List ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'name', type: 'string', example: 'Newsletter subscribers'), + new OA\Property( + property: 'description', + type: 'string', + example: 'Main public list', + nullable: true + ) + ] + ) + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'message', + type: 'string', + example: 'There is no list with that ID.' + ) + ], + type: 'object' + ) + ), + ] + )] + public function getPublicList(#[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null): JsonResponse + { + if (!$list) { + throw $this->createNotFoundException('Subscriber list not found.'); + } + + return $this->json([ + 'id' => $list->getId(), + 'name' => $list->getName(), + 'description' => $list->getDescription(), + ], Response::HTTP_OK); + } + #[Route('/{listId}', name: 'delete', requirements: ['listId' => '\d+'], methods: ['DELETE'])] #[OA\Delete( path: '/api/v2/lists/{listId}', diff --git a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php index 9f43254..cf0c740 100644 --- a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php @@ -20,12 +20,12 @@ public function testControllerIsAvailableViaContainer(): void ); } - public function testGetSubscribePageWithoutSessionReturnsForbidden(): void + public function testGetSubscribePageWithoutSessionReturnsPageIfItIsActive(): void { $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); self::getClient()->request('GET', '/api/v2/subscribe-pages/1'); - $this->assertHttpForbidden(); + $this->assertHttpOkay(); } public function testGetSubscribePageWithSessionReturnsPage(): void From c3bde584e2233aab6f5634cfe3c178337dbc4e1c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 27 May 2026 12:20:53 +0400 Subject: [PATCH 7/8] After review 0 --- .github/workflows/front-docs.yml | 19 +++++++++++++++---- .../Controller/SubscriberListController.php | 2 ++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/front-docs.yml b/.github/workflows/front-docs.yml index 46d544d..855f2f5 100644 --- a/.github/workflows/front-docs.yml +++ b/.github/workflows/front-docs.yml @@ -1,5 +1,9 @@ name: Update phplist-web-frontend OpenAPI +permissions: + contents: write # Required to push to web-frontend repo + actions: read # Required to download artifacts + on: push: branches: @@ -15,16 +19,22 @@ jobs: source_branch: ${{ steps.branch.outputs.source_branch }} steps: - name: Determine source branch + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_REF: ${{ github.head_ref }} + REF_NAME: ${{ github.ref_name }} id: branch run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "source_branch=${{ github.head_ref }}" >> "$GITHUB_OUTPUT" + if [ "$EVENT_NAME" = "pull_request" ]; then + echo "source_branch=$HEAD_REF" >> "$GITHUB_OUTPUT" else - echo "source_branch=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + echo "source_branch=$REF_NAME" >> "$GITHUB_OUTPUT" fi - name: Checkout Source Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup PHP with Composer and Extensions uses: shivammathur/setup-php@v2 @@ -64,6 +74,7 @@ jobs: repository: phpList/web-frontend token: ${{ secrets.PUSH_WEB_FRONTEND }} fetch-depth: 0 + persist-credentials: false - name: Prepare target branch run: | diff --git a/src/Subscription/Controller/SubscriberListController.php b/src/Subscription/Controller/SubscriberListController.php index 99996c0..e151dd6 100644 --- a/src/Subscription/Controller/SubscriberListController.php +++ b/src/Subscription/Controller/SubscriberListController.php @@ -205,6 +205,7 @@ public function getList( properties: [ new OA\Property(property: 'id', type: 'integer', example: 1), new OA\Property(property: 'name', type: 'string', example: 'Newsletter subscribers'), + new OA\Property(property: 'list_position', type: 'integer', example: 1), new OA\Property( property: 'description', type: 'string', @@ -240,6 +241,7 @@ public function getPublicList(#[MapEntity(mapping: ['listId' => 'id'])] ?Subscri 'id' => $list->getId(), 'name' => $list->getName(), 'description' => $list->getDescription(), + 'list_position' => $list->getListPosition(), ], Response::HTTP_OK); } From 6454deace40ea6cd3b0cc7b3fcf41196f4feedf9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 27 May 2026 17:42:51 +0400 Subject: [PATCH 8/8] Normalize attributes key in SubscribePageData --- src/Subscription/Serializer/SubscribePageDataNormalizer.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Subscription/Serializer/SubscribePageDataNormalizer.php b/src/Subscription/Serializer/SubscribePageDataNormalizer.php index c22e6f5..eeef9f8 100644 --- a/src/Subscription/Serializer/SubscribePageDataNormalizer.php +++ b/src/Subscription/Serializer/SubscribePageDataNormalizer.php @@ -26,6 +26,10 @@ public function normalize($object, string $format = null, array $context = []): return []; } + if ($object->getName() === 'attributes') { + $object->setData(trim(str_replace('+', ',', $object->getData()), ',')); + } + return [ 'key' => $object->getName(), 'value' => $object->getData(),