diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b28199e..6f64880f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,79 +14,36 @@ env: COVERAGE: '0' EXT_PCOV_VERSION: '1.0.6' INFECTION_VERSION: '0.23.0' + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: php-cs-fixer: name: PHP-CS-Fixer (PHP ${{ matrix.php }}) runs-on: ubuntu-latest - container: - image: php:${{ matrix.php }}-alpine - options: >- - --tmpfs /tmp:exec - --tmpfs /var/tmp:exec strategy: matrix: php: - '8.1' fail-fast: false timeout-minutes: 5 - env: - PHP_CS_FIXER_FUTURE_MODE: '1' - PHP_CS_FIXER_VERSION: '^3.4' steps: - name: Checkout uses: actions/checkout@v1 - - name: Install system packages - run: | - apk add \ - unzip \ - - name: Disable PHP memory limit - run: echo 'memory_limit=-1' >> /usr/local/etc/php/php.ini - - name: Install Composer - run: wget -qO - https://raw.githubusercontent.com/composer/getcomposer.org/$COMPOSER_INSTALLER_COMMIT/web/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "::set-output name=dir::$(composer config cache-files-dir)" - - name: Restore composer cache - uses: actions/cache@v1 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: composer-cs-fixer-php${{ matrix.php }}-${{ hashFiles('**/composer.json') }} - restore-keys: | - composer-cs-fixer-php${{ matrix.php }}- - continue-on-error: true - - name: Install Symfony Flex - run: | - composer global config --no-plugins allow-plugins.symfony/flex true - composer global require --prefer-dist --no-progress --ansi \ - symfony/flex - - name: Install PHP-CS-Fixer - run: | - composer global require --prefer-dist --no-progress --ansi \ - friendsofphp/php-cs-fixer:"${PHP_CS_FIXER_VERSION}" - - name: Cache PHP-CS-Fixer results - uses: actions/cache@v1 + - name: Setup PHP + uses: shivammathur/setup-php@v2 with: - path: /var/cache/php-cs-fixer - key: php-cs-fixer-php${{ matrix.php }}-${{ github.sha }} - restore-keys: | - php-cs-fixer-php${{ matrix.php }}- - continue-on-error: true + php-version: ${{ matrix.php }} + extensions: intl, bcmath, curl, openssl, mbstring + ini-values: memory_limit=-1 + tools: pecl, composer, php-cs-fixer + coverage: none - name: Run PHP-CS-Fixer fix - run: | - export PATH="$PATH:$HOME/.composer/vendor/bin" - mkdir -p /var/cache/php-cs-fixer - php-cs-fixer fix --dry-run --diff --cache-file=/var/cache/php-cs-fixer/.php_cs.cache --ansi + run: php-cs-fixer fix --dry-run --diff --ansi phpunit: name: PHPUnit (Symfony ${{ matrix.symfony }}) (PHP ${{ matrix.php }}) runs-on: ubuntu-latest - container: - image: php:${{ matrix.php }}-alpine - options: >- - --tmpfs /tmp:exec - --tmpfs /var/tmp:exec continue-on-error: ${{ matrix.experimental }} strategy: matrix: @@ -106,78 +63,36 @@ jobs: steps: - name: Checkout uses: actions/checkout@v1 - - name: Install system packages - run: | - apk add \ - bash \ - unzip \ - libxslt-dev \ - libpng-dev \ - libjpeg-turbo-dev \ - freetype-dev \ - gnupg \ - git - - name: Install pcov PHP extension - if: matrix.coverage + - name: Setup PHP + uses: shivammathur/setup-php@v2 env: - BUILD_DIR: /var/tmp/build/ext-pcov-${{ env.EXT_PCOV_VERSION }} - SRC_DIR: /usr/src/php/ext/pcov - run: | - apk add \ - $PHPIZE_DEPS - mkdir -p "$SRC_DIR" "$BUILD_DIR" - cd "$SRC_DIR" - curl -fsSL "https://pecl.php.net/get/pcov-$EXT_PCOV_VERSION.tgz" | tar -zx --strip-components 1 - phpize - cd "$BUILD_DIR" - "$SRC_DIR"/configure --config-cache - make -j"$(nproc)" - make -j"$(nproc)" install - docker-php-ext-enable pcov - - name: Install exif PHP extension - run: | - docker-php-ext-install exif xsl - - name: Install php image extensions - run: | - docker-php-ext-configure gd --with-freetype --with-jpeg - docker-php-ext-install gd - - name: Disable PHP memory limit - run: echo 'memory_limit=-1' >> /usr/local/etc/php/php.ini - - name: Install Composer - run: wget -qO - https://raw.githubusercontent.com/composer/getcomposer.org/$COMPOSER_INSTALLER_COMMIT/web/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "::set-output name=dir::$(composer config cache-files-dir)" - - name: Restore composer cache - uses: actions/cache@v1 + GD_CONFIGURE_OPTS: --enable-gd=shared,/usr --with-external-gd --with-jpeg --with-freetype + GD_LIBS: libjpeg-dev libpng-dev + GD_PATH: ext/gd with: - path: ${{ steps.composer-cache.outputs.dir }} - key: composer-php${{ matrix.php }}-${{ hashFiles('**/composer.json') }} - restore-keys: | - composer-php${{ matrix.php }}- - continue-on-error: true - - name: Install Symfony Flex & Configure Symfony Version - run: | - composer global config --no-plugins allow-plugins.symfony/flex true - composer global require --prefer-dist --no-progress --ansi \ - symfony/flex - composer config extra.symfony.require "${{ matrix.symfony }}" - - name: Update project dependencies - env: - COMPOSER_AUTH: ${{secrets.COMPOSER_AUTH}} - run: | - mkdir -p /tmp/api-component/core/vendor - ln -s /tmp/api-component/core/vendor vendor - composer update --no-progress --ansi - - name: Clear test app cache - run: | - mkdir -p /tmp/api-component/core/var - ln -s /tmp/api-component/core/var tests/Functional/app/var - tests/Functional/app/bin/console cache:clear --ansi + php-version: ${{ matrix.php }} + tools: pecl, composer + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, gd-php/php-src@PHP-${{ matrix.php }}, exif, xsl + coverage: pcov + ini-values: memory_limit=-1 + - name: Get composer cache directory + id: composercache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- - name: Enable code coverage if: matrix.coverage run: echo "COVERAGE=1" >> $GITHUB_ENV + - name: Update project dependencies + run: composer update --no-progress --ansi + - name: Install PHPUnit + run: vendor/bin/simple-phpunit --version + - name: Clear test app cache + run: tests/Functional/app/bin/console cache:clear --ansi - name: Run PHPUnit tests run: | mkdir -p build/logs/phpunit @@ -201,15 +116,6 @@ jobs: flags: phpunit fail_ci_if_error: true continue-on-error: true -# - name: Upload coverage results to Codeclimate -# if: matrix.coverage -# uses: paambaati/codeclimate-action@v2.5.6 -# env: -# CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} -# with: -# coverageCommand: echo 'PHPUnit already run...' -# coverageLocations: build/logs/phpunit/clover.xml:clover -# continue-on-error: true - name: Run Infection Mutation Tests if: matrix.coverage env: # Or as an environment variable @@ -231,11 +137,6 @@ jobs: behat: name: Behat (Symfony ${{ matrix.symfony }}) (PHP ${{ matrix.php }}) runs-on: ubuntu-latest - container: - image: php:${{ matrix.php }}-alpine - options: >- - --tmpfs /tmp:exec - --tmpfs /var/tmp:exec continue-on-error: ${{ matrix.experimental }} strategy: matrix: @@ -255,44 +156,18 @@ jobs: steps: - name: Checkout uses: actions/checkout@v1 - - name: Install system packages - run: | - apk add \ - bash \ - unzip \ - libxslt-dev \ - libpng-dev \ - libjpeg-turbo-dev \ - freetype-dev \ - git - - name: Install pcov PHP extension - if: matrix.coverage + - name: Setup PHP + uses: shivammathur/setup-php@v2 env: - BUILD_DIR: /var/tmp/build/ext-pcov-${{ env.EXT_PCOV_VERSION }} - SRC_DIR: /usr/src/php/ext/pcov - run: | - apk add \ - $PHPIZE_DEPS - mkdir -p "$SRC_DIR" "$BUILD_DIR" - cd "$SRC_DIR" - curl -fsSL "https://pecl.php.net/get/pcov-$EXT_PCOV_VERSION.tgz" | tar -zx --strip-components 1 - phpize - cd "$BUILD_DIR" - "$SRC_DIR"/configure --config-cache - make -j"$(nproc)" - make -j"$(nproc)" install - docker-php-ext-enable pcov - - name: Install exif PHP extension - run: | - docker-php-ext-install exif xsl - - name: Install php image extensions - run: | - docker-php-ext-configure gd --with-freetype --with-jpeg - docker-php-ext-install gd - - name: Disable PHP memory limit - run: echo 'memory_limit=-1' >> /usr/local/etc/php/php.ini - - name: Install Composer - run: wget -qO - https://raw.githubusercontent.com/composer/getcomposer.org/$COMPOSER_INSTALLER_COMMIT/web/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet + GD_CONFIGURE_OPTS: --enable-gd=shared,/usr --with-external-gd --with-jpeg --with-freetype + GD_LIBS: libjpeg-dev libpng-dev + GD_PATH: ext/gd + with: + php-version: ${{ matrix.php }} + tools: pecl, composer + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, gd-php/php-src@PHP-${{ matrix.php }}, exif, xsl + coverage: pcov + ini-values: memory_limit=-1 - name: Get Composer Cache Directory id: composer-cache run: | @@ -305,24 +180,10 @@ jobs: restore-keys: | composer-php${{ matrix.php }}- continue-on-error: true - - name: Install Symfony Flex & Configure Symfony Version - run: | - composer global config --no-plugins allow-plugins.symfony/flex true - composer global require --prefer-dist --no-progress --ansi \ - symfony/flex - composer config extra.symfony.require "${{ matrix.symfony }}" - name: Update project dependencies - env: - COMPOSER_AUTH: ${{secrets.COMPOSER_AUTH}} - run: | - mkdir -p /tmp/api-component/core/vendor - ln -s /tmp/api-component/core/vendor vendor - composer update --no-progress --ansi + run: composer update --no-progress --ansi - name: Clear test app cache - run: | - mkdir -p /tmp/api-component/core/var - ln -s /tmp/api-component/core/var tests/Functional/app/var - tests/Functional/app/bin/console cache:clear --ansi + run: tests/Functional/app/bin/console cache:clear --ansi - name: Enable code coverage if: matrix.coverage run: echo "COVERAGE=1" >> $GITHUB_ENV @@ -356,24 +217,10 @@ jobs: flags: behat fail_ci_if_error: true continue-on-error: true -# - name: Upload coverage results to Codeclimate -# if: matrix.coverage -# uses: paambaati/codeclimate-action@v2.5.6 -# env: -# CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} -# with: -# coverageCommand: echo 'PHPUnit already run...' -# coverageLocations: build/logs/behat/clover.xml:clover -# continue-on-error: true phpunit-lowest-deps: name: PHPUnit (Symfony ${{ matrix.symfony }}) (PHP ${{ matrix.php }}) (lowest dependencies) runs-on: ubuntu-latest - container: - image: php:${{ matrix.php }}-alpine - options: >- - --tmpfs /tmp:exec - --tmpfs /var/tmp:exec strategy: matrix: php: @@ -385,26 +232,18 @@ jobs: steps: - name: Checkout uses: actions/checkout@v1 - - name: Install system packages - run: | - apk add \ - unzip \ - libxslt-dev \ - libpng-dev \ - libjpeg-turbo-dev \ - freetype-dev \ - git - - name: Install exif PHP extension - run: | - docker-php-ext-install exif xsl - - name: Install php image extensions - run: | - docker-php-ext-configure gd --with-freetype --with-jpeg - docker-php-ext-install gd - - name: Disable PHP memory limit - run: echo 'memory_limit=-1' >> /usr/local/etc/php/php.ini - - name: Install Composer - run: wget -qO - https://raw.githubusercontent.com/composer/getcomposer.org/$COMPOSER_INSTALLER_COMMIT/web/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet + - name: Setup PHP + uses: shivammathur/setup-php@v2 + env: + GD_CONFIGURE_OPTS: --enable-gd=shared,/usr --with-external-gd --with-jpeg --with-freetype + GD_LIBS: libjpeg-dev libpng-dev + GD_PATH: ext/gd + with: + php-version: ${{ matrix.php }} + tools: pecl, composer + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, gd-php/php-src@PHP-${{ matrix.php }}, exif, xsl + coverage: none + ini-values: memory_limit=-1 - name: Get Composer Cache Directory id: composer-cache run: | @@ -417,24 +256,10 @@ jobs: restore-keys: | composer-lowest-deps-php${{ matrix.php }}- continue-on-error: true - - name: Install Symfony Flex & Configure Symfony Version - run: | - composer global config --no-plugins allow-plugins.symfony/flex true - composer global require --prefer-dist --no-progress --ansi \ - symfony/flex - composer config extra.symfony.require "${{ matrix.symfony }}" - name: Update project dependencies - env: - COMPOSER_AUTH: ${{secrets.COMPOSER_AUTH}} - run: | - mkdir -p /tmp/api-component/core/vendor - ln -s /tmp/api-component/core/vendor vendor - composer update --no-progress --ansi --prefer-stable --prefer-lowest + run: composer update --no-progress --ansi --prefer-stable --prefer-lowest - name: Clear test app cache - run: | - mkdir -p /tmp/api-component/core/var - ln -s /tmp/api-component/core/var tests/Functional/app/var - tests/Functional/app/bin/console cache:clear --ansi + run: tests/Functional/app/bin/console cache:clear --ansi - name: Run PHPUnit tests run: | mkdir -p build/logs/phpunit @@ -450,11 +275,6 @@ jobs: behat-lowest-deps: name: Behat (Symfony ${{ matrix.symfony }}) (PHP ${{ matrix.php }}) (lowest dependencies) runs-on: ubuntu-latest - container: - image: php:${{ matrix.php }}-alpine - options: >- - --tmpfs /tmp:exec - --tmpfs /var/tmp:exec strategy: matrix: php: @@ -466,30 +286,22 @@ jobs: steps: - name: Checkout uses: actions/checkout@v1 - - name: Install system packages - run: | - apk add \ - unzip \ - libxslt-dev \ - libpng-dev \ - libjpeg-turbo-dev \ - freetype-dev \ - git - - name: Install exif PHP extension - run: | - docker-php-ext-install exif xsl - - name: Install php image extensions - run: | - docker-php-ext-configure gd --with-freetype --with-jpeg - docker-php-ext-install gd - - name: Disable PHP memory limit - run: echo 'memory_limit=-1' >> /usr/local/etc/php/php.ini - - name: Install Composer - run: wget -qO - https://raw.githubusercontent.com/composer/getcomposer.org/$COMPOSER_INSTALLER_COMMIT/web/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Setup PHP + uses: shivammathur/setup-php@v2 + env: + GD_CONFIGURE_OPTS: --enable-gd=shared,/usr --with-external-gd --with-jpeg --with-freetype + GD_LIBS: libjpeg-dev libpng-dev + GD_PATH: ext/gd + with: + php-version: ${{ matrix.php }} + tools: pecl, composer + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, gd-php/php-src@PHP-${{ matrix.php }}, exif, xsl + coverage: none + ini-values: memory_limit=-1 + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" - name: Restore composer cache uses: actions/cache@v1 with: @@ -498,24 +310,10 @@ jobs: restore-keys: | composer-lowest-deps-php${{ matrix.php }}- continue-on-error: true - - name: Install Symfony Flex & Configure Symfony Version - run: | - composer global config --no-plugins allow-plugins.symfony/flex true - composer global require --prefer-dist --no-progress --ansi \ - symfony/flex - composer config extra.symfony.require "${{ matrix.symfony }}" - name: Update project dependencies - env: - COMPOSER_AUTH: ${{secrets.COMPOSER_AUTH}} - run: | - mkdir -p /tmp/api-component/core/vendor - ln -s /tmp/api-component/core/vendor vendor - composer update --no-progress --ansi --prefer-stable --prefer-lowest + run: composer update --no-progress --ansi --prefer-stable --prefer-lowest - name: Clear test app cache - run: | - mkdir -p /tmp/api-component/core/var - ln -s /tmp/api-component/core/var tests/Functional/app/var - tests/Functional/app/bin/console cache:clear --ansi + run: tests/Functional/app/bin/console cache:clear --ansi - name: Run Behat tests run: | mkdir -p build/logs/behat @@ -528,15 +326,9 @@ jobs: path: build/logs/behat continue-on-error: true - phpunit-symfony-next: name: PHPUnit (Symfony NEXT ${{ matrix.symfony }}) (PHP ${{ matrix.php }}) runs-on: ubuntu-latest - container: - image: php:${{ matrix.php }}-alpine - options: >- - --tmpfs /tmp:exec - --tmpfs /var/tmp:exec strategy: matrix: php: @@ -546,100 +338,71 @@ jobs: fail-fast: false timeout-minutes: 20 steps: - - name: Checkout - uses: actions/checkout@v1 - - name: Install system packages - run: | - apk add \ - jq \ - moreutils \ - bash \ - unzip \ - libxslt-dev \ - libpng-dev \ - libjpeg-turbo-dev \ - freetype-dev \ - gnupg \ - git - - name: Install exif PHP extension - run: | - docker-php-ext-install exif xsl - - name: Install php image extensions - run: | - docker-php-ext-configure gd --with-freetype --with-jpeg - docker-php-ext-install gd - - name: Disable PHP memory limit - run: echo 'memory_limit=-1' >> /usr/local/etc/php/php.ini - - name: Install Composer - run: wget -qO - https://raw.githubusercontent.com/composer/getcomposer.org/$COMPOSER_INSTALLER_COMMIT/web/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "::set-output name=dir::$(composer config cache-files-dir)" - - name: Restore composer cache - uses: actions/cache@v1 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: composer-php${{ matrix.php }}-${{ hashFiles('**/composer.json') }} - restore-keys: | - composer-php${{ matrix.php }}-symfony${{ matrix.symfony }}- - composer-php${{ matrix.php }}- - composer- - continue-on-error: true - - name: Install Symfony Flex - run: | - composer global config --no-plugins allow-plugins.symfony/flex true - composer global require --prefer-dist --no-progress --ansi \ - symfony/flex - - name: Update project dependencies - env: - COMPOSER_AUTH: ${{secrets.COMPOSER_AUTH}} - run: | - mkdir -p /tmp/api-platform/core/vendor - ln -s /tmp/api-platform/core/vendor vendor - composer config minimum-stability dev - composer config prefer-stable false - composer update --no-progress --ansi - - name: Flag held back Symfony packages - env: - symfony_version: ${{ matrix.symfony }} - run: | - version_pattern=$symfony_version.x-dev - if [ "${symfony_version%.4}" != "$symfony_version" ]; then - current_major=${symfony_version%.4} - next_major=$((current_major + 1)) - version_pattern=$version_pattern'|'$next_major.0.x-dev'|'dev-master - fi - version_pattern=$(echo "$version_pattern" | sed -r 's/\./\\./g') - symfony_packages=$(composer show symfony/* | tr -s ' ' '\t' | cut -f1-2 | grep -vE 'polyfill|contracts|mercure|debug|maker-bundle|monolog-bundle') - ! echo "$symfony_packages" | grep -vE "$version_pattern" - continue-on-error: true - - name: Clear test app cache - run: | - mkdir -p /tmp/api-platform/core/var - ln -s /tmp/api-platform/core/var tests/Functional/app/var - tests/Functional/app/bin/console cache:clear --ansi - - name: Run PHPUnit tests - run: | - mkdir -p build/logs/phpunit - vendor/bin/simple-phpunit --log-junit build/logs/phpunit/junit.xml --colors=always - continue-on-error: true - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v1 - with: - name: phpunit-logs-php${{ matrix.php }}-symfony${{ matrix.symfony }} - path: build/logs/phpunit + - name: Checkout + uses: actions/checkout@v1 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + env: + GD_CONFIGURE_OPTS: --enable-gd=shared,/usr --with-external-gd --with-jpeg --with-freetype + GD_LIBS: libjpeg-dev libpng-dev + GD_PATH: ext/gd + with: + php-version: ${{ matrix.php }} + tools: pecl, composer + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, gd-php/php-src@PHP-${{ matrix.php }}, exif, xsl + coverage: none + ini-values: memory_limit=-1 + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Restore composer cache + uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-php${{ matrix.php }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + composer-php${{ matrix.php }}-symfony${{ matrix.symfony }}- + composer-php${{ matrix.php }}- + composer- continue-on-error: true + - name: Update project dependencies + run: | + composer config minimum-stability dev + composer config prefer-stable false + composer update --no-progress --ansi + - name: Flag held back Symfony packages + env: + symfony_version: ${{ matrix.symfony }} + run: | + version_pattern=$symfony_version.x-dev + if [ "${symfony_version%.4}" != "$symfony_version" ]; then + current_major=${symfony_version%.4} + next_major=$((current_major + 1)) + version_pattern=$version_pattern'|'$next_major.0.x-dev'|'dev-master + fi + version_pattern=$(echo "$version_pattern" | sed -r 's/\./\\./g') + symfony_packages=$(composer show symfony/* | tr -s ' ' '\t' | cut -f1-2 | grep -vE 'polyfill|contracts|mercure|debug|maker-bundle|monolog-bundle') + ! echo "$symfony_packages" | grep -vE "$version_pattern" + continue-on-error: true + - name: Clear test app cache + run: tests/Functional/app/bin/console cache:clear --ansi + - name: Run PHPUnit tests + run: | + mkdir -p build/logs/phpunit + vendor/bin/simple-phpunit --log-junit build/logs/phpunit/junit.xml --colors=always + continue-on-error: true + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v1 + with: + name: phpunit-logs-php${{ matrix.php }}-symfony${{ matrix.symfony }} + path: build/logs/phpunit + continue-on-error: true behat-symfony-next: name: Behat (Symfony NEXT ${{ matrix.symfony }}) (PHP ${{ matrix.php }}) runs-on: ubuntu-latest - container: - image: php:${{ matrix.php }}-alpine - options: >- - --tmpfs /tmp:exec - --tmpfs /var/tmp:exec strategy: matrix: php: @@ -649,73 +412,50 @@ jobs: fail-fast: false timeout-minutes: 20 steps: - - name: Checkout - uses: actions/checkout@v1 - - name: Install system packages - run: | - apk add \ - jq \ - moreutils \ - bash \ - unzip \ - libxslt-dev \ - libpng-dev \ - libjpeg-turbo-dev \ - freetype-dev \ - git - - name: Install exif PHP extension - run: | - docker-php-ext-install exif xsl - - name: Install php image extensions - run: | - docker-php-ext-configure gd --with-freetype --with-jpeg - docker-php-ext-install gd - - name: Disable PHP memory limit - run: echo 'memory_limit=-1' >> /usr/local/etc/php/php.ini - - name: Install Composer - run: wget -qO - https://raw.githubusercontent.com/composer/getcomposer.org/$COMPOSER_INSTALLER_COMMIT/web/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "::set-output name=dir::$(composer config cache-files-dir)" - - name: Restore composer cache - uses: actions/cache@v1 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: composer-php${{ matrix.php }}-symfony${{ matrix.symfony }}-${{ github.sha }} - restore-keys: | - composer-php${{ matrix.php }}-symfony${{ matrix.symfony }}- - composer-php${{ matrix.php }}- - composer- - continue-on-error: true - - name: Install Symfony Flex - run: | - composer global config --no-plugins allow-plugins.symfony/flex true - composer global require --prefer-dist --no-progress --ansi \ - symfony/flex - - name: Update project dependencies - env: - COMPOSER_AUTH: ${{secrets.COMPOSER_AUTH}} - run: | - mkdir -p /tmp/api-platform/core/vendor - ln -s /tmp/api-platform/core/vendor vendor - composer config minimum-stability dev - composer config prefer-stable false - composer update --no-progress --ansi - - name: Clear test app cache - run: | - mkdir -p /tmp/api-platform/core/var - ln -s /tmp/api-platform/core/var tests/Functional/app/var - tests/Functional/app/bin/console cache:clear --ansi - - name: Run Behat tests - run: | - mkdir -p build/logs/behat - php -d memory_limit=-1 -d error_reporting="E_ALL & ~E_NOTICE & ~E_DEPRECATED" vendor/bin/behat --format=progress --out=std --format=junit --out=build/logs/behat/junit --profile=default --no-interaction --colors --tags='~@wip' - continue-on-error: true - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v1 - with: - name: behat-logs-php${{ matrix.php }}-symfony${{ matrix.symfony }} - path: build/logs/behat - continue-on-error: true + - name: Checkout + uses: actions/checkout@v1 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + env: + GD_CONFIGURE_OPTS: --enable-gd=shared,/usr --with-external-gd --with-jpeg --with-freetype + GD_LIBS: libjpeg-dev libpng-dev + GD_PATH: ext/gd + with: + php-version: ${{ matrix.php }} + tools: pecl, composer + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, gd-php/php-src@PHP-${{ matrix.php }}, exif, xsl + coverage: none + ini-values: memory_limit=-1 + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Restore composer cache + uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-php${{ matrix.php }}-symfony${{ matrix.symfony }}-${{ github.sha }} + restore-keys: | + composer-php${{ matrix.php }}-symfony${{ matrix.symfony }}- + composer-php${{ matrix.php }}- + composer- + continue-on-error: true + - name: Update project dependencies + run: | + composer config minimum-stability dev + composer config prefer-stable false + composer update --no-progress --ansi + - name: Clear test app cache + run: tests/Functional/app/bin/console cache:clear --ansi + - name: Run Behat tests + run: | + mkdir -p build/logs/behat + php -d memory_limit=-1 -d error_reporting="E_ALL & ~E_NOTICE & ~E_DEPRECATED" vendor/bin/behat --format=progress --out=std --format=junit --out=build/logs/behat/junit --profile=default --no-interaction --colors --tags='~@wip' + continue-on-error: true + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v1 + with: + name: behat-logs-php${{ matrix.php }}-symfony${{ matrix.symfony }} + path: build/logs/behat + continue-on-error: true diff --git a/.symfony.insight.yaml b/.symfony.insight.yaml index ecf68e12..1bc31049 100644 --- a/.symfony.insight.yaml +++ b/.symfony.insight.yaml @@ -1,4 +1,4 @@ -php_version: 8.0 +php_version: 8.1 rules: doctrine.public_doctrine_property: enabled: false diff --git a/composer.json b/composer.json index 56a99ad9..83082158 100644 --- a/composer.json +++ b/composer.json @@ -3,10 +3,18 @@ "type": "symfony-bundle", "description": "Creates a flexible API for a website's structure, reusable components and common functionality.", "license": "MIT", + "homepage": "https://cwa.rocks", + "authors": [ + { + "name": "Daniel West", + "email": "daniel@silverback.is", + "homepage": "https://silverback.is" + } + ], "repositories": [ { - "type": "vcs", - "url": "https://github.com/silverbackdan/contexts" + "type": "git", + "url": "git@github.com:silverbackdan/contexts.git" } ], "require": { @@ -16,7 +24,7 @@ "ext-json": "*", "ext-pdo": "*", "ext-simplexml": "*", - "api-platform/core": "^3.0", + "api-platform/core": "^3.0.3", "cocur/slugify": "^4.1", "doctrine/annotations": "^1.7.0", "doctrine/dbal": "^3.4", @@ -80,6 +88,7 @@ "symfony/http-client": "^6.1", "symfony/maker-bundle": "^1.0", "symfony/mercure-bundle": "^0.3.4", + "symfony/messenger": "^6.1", "symfony/monolog-bundle": "^3.8", "symfony/phpunit-bridge": "^6.1.3", "symfony/stopwatch": "^6.1", @@ -128,6 +137,7 @@ "willdurand/negotiation": "^2", "symfony/proxy-manager-bridge": "<5.4", "symfony/serializer": "<=6.1.2", + "symfony/var-exporter": "<6.1", "symfony/web-link": "<=6.0", "doctrine/collections": "<1.7", "doctrine/orm": "<2.13" diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 967769bb..3476c5e8 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -14,7 +14,7 @@ GEM execjs coffee-script-source (1.11.1) colorator (1.1.0) - commonmarker (0.23.5) + commonmarker (0.23.6) concurrent-ruby (1.1.10) dnsruby (1.61.9) simpleidn (~> 0.1) @@ -25,10 +25,10 @@ GEM ffi (>= 1.15.0) eventmachine (1.2.7) execjs (2.8.1) - faraday (2.5.2) + faraday (2.6.0) faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) - faraday-net_http (3.0.0) + faraday-net_http (3.0.1) ffi (1.15.5) forwardable-extended (2.6.0) gemoji (3.0.1) @@ -83,7 +83,7 @@ GEM octokit (~> 4.0) public_suffix (>= 3.0, < 5.0) typhoeus (~> 1.3) - html-pipeline (2.14.2) + html-pipeline (2.14.3) activesupport (>= 2) nokogiri (>= 1.4) http_parser.rb (0.8.0) @@ -212,7 +212,7 @@ GEM jekyll-feed (~> 0.9) jekyll-seo-tag (~> 2.1) minitest (5.16.3) - nokogiri (1.13.8) + nokogiri (1.13.9) mini_portile2 (~> 2.8.0) racc (~> 1.4) octokit (4.25.1) @@ -251,7 +251,7 @@ GEM unf_ext unf_ext (0.0.8.2) unicode-display_width (1.8.0) - zeitwerk (2.6.0) + zeitwerk (2.6.1) PLATFORMS ruby diff --git a/features/bootstrap/JsonContext.php b/features/bootstrap/JsonContext.php index e53c0f0a..1acfdfab 100644 --- a/features/bootstrap/JsonContext.php +++ b/features/bootstrap/JsonContext.php @@ -21,6 +21,7 @@ use Behatch\Json\Json; use Behatch\Json\JsonInspector; use Behatch\Json\JsonSchema; +use Lexik\Bundle\JWTAuthenticationBundle\Services\JWSProvider\JWSProviderInterface; use PHPUnit\Framework\Assert; use Symfony\Component\HttpFoundation\Cookie; @@ -29,10 +30,12 @@ class JsonContext implements Context private JsonInspector $inspector; private ?BehatchJsonContext $jsonContext; private ?RestContext $restContext; + private JWSProviderInterface $jwsProvider; - public function __construct() + public function __construct(JWSProviderInterface $jwsProvider) { $this->inspector = new JsonInspector('javascript'); + $this->jwsProvider = $jwsProvider; } /** @@ -147,9 +150,52 @@ public function theResponseShouldBeTheResource($name): void */ public function theResponseShouldHaveACookie(string $name): void { - $cookie = Cookie::fromString($this->jsonContext->getSession()->getResponseHeader('set-cookie')); - $realName = $cookie->getName(); - Assert::assertEquals($realName, $name, sprintf('The cookie "%s" was not found in the response headers.', $name)); + $responseHeaders = $this->jsonContext->getSession()->getResponseHeaders(); + $setCookieHeaders = $responseHeaders['set-cookie']; + foreach ($setCookieHeaders as $setCookieHeader) { + $cookie = Cookie::fromString($setCookieHeader); + $realName = $cookie->getName(); + if ($realName === $name) { + return; + } + } + throw new \Exception(sprintf('The cookie "%s" was not found in the response headers.', $name)); + } + + private function getMercureCookieDraftTopics(): array + { + $responseHeaders = $this->jsonContext->getSession()->getResponseHeaders(); + $setCookieHeaders = $responseHeaders['set-cookie']; + foreach ($setCookieHeaders as $setCookieHeader) { + $cookie = Cookie::fromString($setCookieHeader); + $realName = $cookie->getName(); + if ('mercureAuthorization' === $realName) { + $token = $this->jwsProvider->load($cookie->getValue()); + $payload = $token->getPayload(); + + return array_filter($payload['mercure']['subscribe'], static function ($topic) { + return str_ends_with($topic, '?draft=1'); + }); + } + } + + return []; + } + + /** + * @Then the mercure cookie should not contain draft resource topics + */ + public function theMercureCookieShouldNotContainDraftResources() + { + Assert::assertCount(0, $this->getMercureCookieDraftTopics(), 'The cookie allows a user to be subscribed to draft resources'); + } + + /** + * @Then the mercure cookie should contain draft resource topics + */ + public function theMercureCookieShouldContainDraftResources() + { + Assert::assertGreaterThan(0, $this->getMercureCookieDraftTopics(), 'The cookie does not allow a user to subscribe to any draft resources'); } /** diff --git a/features/bootstrap/ProfilerContext.php b/features/bootstrap/ProfilerContext.php index 7db89b52..f6bb449d 100644 --- a/features/bootstrap/ProfilerContext.php +++ b/features/bootstrap/ProfilerContext.php @@ -27,10 +27,12 @@ use Silverback\ApiComponentsBundle\Factory\User\Mailer\WelcomeEmailFactory; use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\User; use Symfony\Bridge\Twig\Mime\TemplatedEmail; +use Symfony\Bundle\MercureBundle\DataCollector\MercureDataCollector; use Symfony\Component\BrowserKit\AbstractBrowser; use Symfony\Component\HttpClient\DataCollector\HttpClientDataCollector; use Symfony\Component\HttpKernel\Profiler\Profile as HttpProfile; use Symfony\Component\Mailer\DataCollector\MessageDataCollector; +use Symfony\Component\Mercure\Update; use Symfony\Component\Mime\Header\Headers; use Symfony\Component\VarDumper\Cloner\Data; @@ -53,6 +55,54 @@ public function getContexts(BeforeScenarioScope $scope) $this->client = $this->minkContext->getSession()->getDriver()->getClient(); } + /** + * @return Update[] + */ + private function getMercureMessageObjects(): array + { + $objects = []; + /** @var MercureDataCollector $collector */ + $collector = $this->getProfile()->getCollector('mercure'); + $hubs = $collector->getHubs(); + foreach ($hubs['default']['messages'] as $message) { + $objects[] = $message['object']; + } + + return $objects; + } + + /** + * @Then there should be :count mercure messages + */ + public function thereShouldBeAPublishedMercureUpdatePublished(int $count) + { + $messageObjects = $this->getMercureMessageObjects(); + if (\count($messageObjects) !== $count) { + throw new ExpectationException(sprintf('%d updates were published but %d were expected', \count($messageObjects), $count), $this->minkContext->getSession()->getDriver()); + } + } + + /** + * @Then there should be :count mercure messages for draft resources + */ + public function thereShouldMercureMessagesForDraftResources(int $count) + { + $messageObjects = $this->getMercureMessageObjects(); + $draftCount = 0; + foreach ($messageObjects as $messageObject) { + $iri = $messageObject->getTopics()[0]; + if (str_ends_with($iri, '?draft=1')) { + if (!$messageObject->isPrivate()) { + throw new ExpectationException('Draft resource messages must be private', $this->minkContext->getSession()->getDriver()); + } + ++$draftCount; + } + } + if ($draftCount !== $count) { + throw new ExpectationException(sprintf('%d draft updates were published but %d were expected', $draftCount, $count), $this->minkContext->getSession()->getDriver()); + } + } + /** * @Then the resource :resource_name should be purged from the cache */ @@ -100,12 +150,15 @@ public function iShouldGetAnEmail(string $emailType, string $emailAddress = 'use { /** @var MessageDataCollector $collector */ $collector = $this->getProfile()->getCollector('mailer'); + /** @var TemplatedEmail[] $messages */ $messages = $collector->getEvents()->getMessages(); + Assert::assertCount(1, $messages); Assert::assertInstanceOf(TemplatedEmail::class, $email = $messages[0]); /** @var TemplatedEmail $email */ $context = $email->getContext(); + Assert::assertArrayHasKey('website_name', $context); Assert::assertEquals('New Website', $context['website_name']); Assert::assertInstanceOf(User::class, $context['user']); diff --git a/features/mercure/mercure.feature b/features/mercure/mercure.feature new file mode 100644 index 00000000..1f3f443f --- /dev/null +++ b/features/mercure/mercure.feature @@ -0,0 +1,21 @@ +Feature: Mercure authorization cookies and messages are published + In order to restrict access to draft components + As a an application developer + I must be able to configure the ability to access the resource + + Background: + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + + Scenario: A Mercure authorization cookie is set WITHOUT topic draft access + When I send a "GET" request to "/docs.jsonld" + Then the response status code should be 200 + And the response should have a "mercureAuthorization" cookie + And the mercure cookie should not contain draft resource topics + + @loginAdmin + Scenario: A Mercure authorization cookie is set WITH topic draft access + When I send a "GET" request to "/docs.jsonld" + Then the response status code should be 200 + And the response should have a "mercureAuthorization" cookie + And the mercure cookie should contain draft resource topics diff --git a/features/publishable/publishable.feature b/features/publishable/publishable.feature index 3288060c..b1971b28 100644 --- a/features/publishable/publishable.feature +++ b/features/publishable/publishable.feature @@ -47,6 +47,7 @@ Feature: Access to unpublished/draft resources should be configurable And the JSON should be valid according to the schema file "publishable.schema.json" And the JSON node publishedAt should not exist And the JSON node "_metadata.publishable.published" should be false + And there should be 1 mercure messages for draft resources @loginAdmin Scenario Outline: As a user with draft access, when I create a resource, I should be able to set the publishedAt date to specify if it is draft/published @@ -70,6 +71,8 @@ Feature: Access to unpublished/draft resources should be configurable Then the response status code should be 201 And the JSON node publishedAt should exist And the JSON node "_metadata.publishable.published" should be true + And there should be 1 mercure messages + And there should be 0 mercure messages for draft resources @loginUser Scenario Outline: As a user with draft access to a specific resource, when I create a resource, I should be able to set the publishedAt date to specify if it is draft/published diff --git a/src/ApiPlatform/Api/MercureIriConverter.php b/src/ApiPlatform/Api/MercureIriConverter.php new file mode 100644 index 00000000..86cfe3d9 --- /dev/null +++ b/src/ApiPlatform/Api/MercureIriConverter.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Silverback\ApiComponentsBundle\ApiPlatform\Api; + +use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Api\UrlGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker; + +/** + * @author Daniel West + */ +class MercureIriConverter implements IriConverterInterface +{ + public function __construct(private IriConverterInterface $decorated, private PublishableStatusChecker $publishableStatusChecker) + { + } + + public function getResourceFromIri(string $iri, array $context = [], ?Operation $operation = null): object + { + return $this->decorated->getResourceFromIri($iri, $context, $operation); + } + + public function getIriFromResource($resource, int $referenceType = UrlGeneratorInterface::ABS_PATH, ?Operation $operation = null, array $context = []): ?string + { + $iri = $this->decorated->getIriFromResource($resource, $referenceType, $operation, $context); + + if (\is_string($resource)) { + return $iri; + } + + if ($this->publishableStatusChecker->getAnnotationReader()->isConfigured($resource) && !$this->publishableStatusChecker->isActivePublishedAt($resource)) { + $iri .= '?draft=1'; + } + + return $iri; + } +} diff --git a/src/DataProvider/PageDataProvider.php b/src/DataProvider/PageDataProvider.php index 683386b7..d3083c17 100644 --- a/src/DataProvider/PageDataProvider.php +++ b/src/DataProvider/PageDataProvider.php @@ -58,7 +58,7 @@ public function __construct( $this->managerRegistry = $managerRegistry; } - private function getOriginalRequestPath(): ?string + public function getOriginalRequestPath(): ?string { $request = $this->requestStack->getCurrentRequest(); if (!$request) { diff --git a/src/DependencyInjection/CompilerPass/ApiPlatformCompilerPass.php b/src/DependencyInjection/CompilerPass/ApiPlatformCompilerPass.php index 56828469..66eed2f8 100644 --- a/src/DependencyInjection/CompilerPass/ApiPlatformCompilerPass.php +++ b/src/DependencyInjection/CompilerPass/ApiPlatformCompilerPass.php @@ -29,8 +29,20 @@ public function process(ContainerBuilder $container): void $container->getDefinition(CollectionApiEventListener::class)->setArgument('$itemsPerPageParameterName', $itemsPerPageParameterName); $purgeListener = 'silverback.api_components.event_listener.doctrine.purge_http_cache_listener'; - if (!$container->hasAlias('api_platform.http_cache.purger')) { + if ($container->hasAlias('api_platform.http_cache.purger')) { + // we have implemented fully custom logic + $container->removeDefinition('api_platform.doctrine.listener.http_cache.purge'); + } else { $container->removeDefinition($purgeListener); } + + $publishListener = 'silverback.api_components.event_listener.doctrine.mercure_publish_listener'; + $apiPlatformMercurePublishListener = 'api_platform.doctrine.orm.listener.mercure.publish'; + if ($container->hasDefinition($apiPlatformMercurePublishListener)) { + // we have implemented fully custom logic + $container->removeDefinition($apiPlatformMercurePublishListener); + } else { + $container->removeDefinition($publishListener); + } } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 00e2fb5e..adbd9083 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -16,6 +16,7 @@ use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\HttpFoundation\Cookie; /** * @author Daniel West @@ -33,6 +34,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarNode('metadata_key')->defaultValue('_metadata')->end() ->end(); + $this->addMercureNode($rootNode); $this->addRouteSecurityNode($rootNode); $this->addRoutableSecurityNode($rootNode); $this->addRefreshTokenNode($rootNode); @@ -43,6 +45,30 @@ public function getConfigTreeBuilder(): TreeBuilder return $treeBuilder; } + private function addMercureNode(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('mercure') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('hub_name')->defaultNull()->end() + ->arrayNode('cookie') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('samesite')->defaultValue(Cookie::SAMESITE_STRICT) + ->validate() + ->ifNotInArray([Cookie::SAMESITE_STRICT, Cookie::SAMESITE_LAX, Cookie::SAMESITE_NONE]) + ->thenInvalid('Invalid Mercure cookie samesite value %s') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + } + private function addRouteSecurityNode(ArrayNodeDefinition $rootNode): void { $rootNode diff --git a/src/DependencyInjection/SilverbackApiComponentsExtension.php b/src/DependencyInjection/SilverbackApiComponentsExtension.php index 6346604d..7643c1fc 100644 --- a/src/DependencyInjection/SilverbackApiComponentsExtension.php +++ b/src/DependencyInjection/SilverbackApiComponentsExtension.php @@ -21,6 +21,7 @@ use Silverback\ApiComponentsBundle\Doctrine\Extension\ORM\TablePrefixExtension; use Silverback\ApiComponentsBundle\Event\FormSuccessEvent; use Silverback\ApiComponentsBundle\EventListener\Form\FormSuccessEventListenerInterface; +use Silverback\ApiComponentsBundle\EventListener\Mercure\AddMercureTokenListener; use Silverback\ApiComponentsBundle\Exception\ApiPlatformAuthenticationException; use Silverback\ApiComponentsBundle\Exception\UnparseableRequestHeaderException; use Silverback\ApiComponentsBundle\Exception\UserDisabledException; @@ -42,6 +43,7 @@ use Silverback\ApiComponentsBundle\Helper\Uploadable\UploadableFileManager; use Silverback\ApiComponentsBundle\Helper\User\UserDataProcessor; use Silverback\ApiComponentsBundle\Helper\User\UserMailer; +use Silverback\ApiComponentsBundle\HttpCache\ResourceChangedPropagatorInterface; use Silverback\ApiComponentsBundle\Repository\Core\RefreshTokenRepository; use Silverback\ApiComponentsBundle\Repository\User\UserRepositoryInterface; use Silverback\ApiComponentsBundle\Security\UserChecker; @@ -150,6 +152,10 @@ public function load(array $configs, ContainerBuilder $container): void $definition = $container->getDefinition(RoutableVoter::class); $definition->setArgument('$securityStr', $config['routable_security']); + + $definition = $container->getDefinition(AddMercureTokenListener::class); + $definition->setArgument('$cookieSameSite', $config['mercure']['cookie']['samesite']); + $definition->setArgument('$hubName', $config['mercure']['hub_name']); } private function setEmailVerificationArguments(ContainerBuilder $container, array $emailVerificationConfig, int $passwordRepeatTtl): void @@ -230,6 +236,9 @@ private function loadServiceConfig(ContainerBuilder $container): void $container->registerForAutoconfiguration(FormTypeInterface::class) ->addTag('silverback_api_components.form_type'); + $container->registerForAutoconfiguration(ResourceChangedPropagatorInterface::class) + ->addTag('silverback_api_components.resource_changed_propagator'); + $container->registerForAutoconfiguration(FormSuccessEventListenerInterface::class) ->addTag('kernel.event_listener', ['event' => FormSuccessEvent::class]); @@ -237,6 +246,7 @@ private function loadServiceConfig(ContainerBuilder $container): void $loader->load('services.php'); $loader->load('services_normalizers.php'); $loader->load('services_doctrine_orm_http_cache_purger.php'); + $loader->load('services_doctrine_orm_mercure_publisher.php'); } public function prepend(ContainerBuilder $container): void diff --git a/src/Event/ResourceChangedEvent.php b/src/Event/ResourceChangedEvent.php new file mode 100644 index 00000000..a8146acc --- /dev/null +++ b/src/Event/ResourceChangedEvent.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Silverback\ApiComponentsBundle\Event; + +use Symfony\Contracts\EventDispatcher\Event; + +/** + * @author Daniel West + */ +class ResourceChangedEvent extends Event +{ + public function __construct(private readonly object $resource, string $type) + { + } + + public function getResource(): object + { + return $this->resource; + } + + public function getType(): string + { + return $this->getType(); + } +} diff --git a/src/EventListener/Api/UploadableEventListener.php b/src/EventListener/Api/UploadableEventListener.php index 48fc46ec..0dead173 100644 --- a/src/EventListener/Api/UploadableEventListener.php +++ b/src/EventListener/Api/UploadableEventListener.php @@ -52,7 +52,6 @@ public function onPreWrite(ViewEvent $event): void return; } - $this->uploadableFileManager->persistFiles($data); } diff --git a/src/EventListener/Doctrine/DoctrineResourceFlushListenerInterface.php b/src/EventListener/Doctrine/DoctrineResourceFlushListenerInterface.php new file mode 100644 index 00000000..d879071e --- /dev/null +++ b/src/EventListener/Doctrine/DoctrineResourceFlushListenerInterface.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Silverback\ApiComponentsBundle\EventListener\Doctrine; + +use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\ORM\Event\PreUpdateEventArgs; + +/** + * Purges desired resources on when doctrine is flushed from the proxy cache. + * Will purge resources with related mapping too. + * + * @author Daniel West + * + * @experimental + */ +interface DoctrineResourceFlushListenerInterface +{ + public function preUpdate(PreUpdateEventArgs $eventArgs): void; + + public function onFlush(OnFlushEventArgs $eventArgs): void; + + public function postFlush(): void; +} diff --git a/src/EventListener/Doctrine/DoctrineResourceFlushTrait.php b/src/EventListener/Doctrine/DoctrineResourceFlushTrait.php new file mode 100644 index 00000000..a12a945f --- /dev/null +++ b/src/EventListener/Doctrine/DoctrineResourceFlushTrait.php @@ -0,0 +1,203 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Silverback\ApiComponentsBundle\EventListener\Doctrine; + +use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Api\ResourceClassResolverInterface; +use ApiPlatform\Api\UrlGeneratorInterface; +use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\GetCollection; +use Doctrine\Common\Util\ClassUtils; +use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\ORM\Event\PreUpdateEventArgs; +use Doctrine\ORM\PersistentCollection; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectManager; +use Doctrine\Persistence\ObjectRepository; +use Silverback\ApiComponentsBundle\Entity\Component\Collection; +use Silverback\ApiComponentsBundle\Entity\Core\ComponentPosition; +use Silverback\ApiComponentsBundle\Entity\Core\PageDataInterface; +use Silverback\ApiComponentsBundle\HttpCache\ResourceChangedPropagatorInterface; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessor; + +trait DoctrineResourceFlushTrait +{ + private array $pageDataPropertiesChanged = []; + private PropertyAccessor $propertyAccessor; + private ObjectRepository|EntityRepository $collectionRepository; + private ObjectRepository|EntityRepository $positionRepository; + private array $resourceIris = []; + + public function __construct( + private readonly IriConverterInterface $iriConverter, + ManagerRegistry $entityManager, + private readonly ResourceChangedPropagatorInterface $resourceChangedPropagator, + private readonly ResourceClassResolverInterface $resourceClassResolver + ) { + $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); + $this->collectionRepository = $entityManager->getRepository(Collection::class); + $this->positionRepository = $entityManager->getRepository(ComponentPosition::class); + } + + public function preUpdate(PreUpdateEventArgs $eventArgs): void + { + $object = $eventArgs->getObject(); + $this->collectUpdatedResource($object, 'updated'); + + $changeSet = $eventArgs->getEntityChangeSet(); + $associationMappings = $this->getAssociationMappings($eventArgs->getObjectManager(), $eventArgs->getObject()); + + if ($object instanceof PageDataInterface) { + $this->pageDataPropertiesChanged = array_keys($changeSet); + } + + foreach ($changeSet as $field => $value) { + if (!isset($associationMappings[$field])) { + continue; + } + + $this->collectUpdatedResource($value[0], 'updated'); + $this->collectUpdatedResource($value[1], 'updated'); + } + } + + public function onFlush(OnFlushEventArgs $eventArgs): void + { + $em = $eventArgs->getObjectManager(); + $uow = $em->getUnitOfWork(); + + foreach ($uow->getScheduledEntityInsertions() as $entity) { + $this->collectUpdatedResource($entity, 'created', $em, true); + } + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + $this->collectUpdatedResource($entity, 'updated', $em, true); + } + + foreach ($uow->getScheduledEntityDeletions() as $entity) { + $this->collectUpdatedResource($entity, 'deleted', $em, true); + } + } + + public function postFlush(): void + { + $this->addResourcesToPurge($this->gatherResourcesForPositionsWithPageDataProperties(), 'updated'); + $this->addResourcesToPurge($this->gatherIrisForCollectionResources(), 'updated'); + $this->purgeResources(); + } + + private function gatherRelationResourceClasses(ObjectManager $em, $entity): void + { + $associationMappings = $this->getAssociationMappings($em, $entity); + foreach (array_keys($associationMappings) as $property) { + if ($this->propertyAccessor->isReadable($entity, $property)) { + $value = $this->propertyAccessor->getValue($entity, $property); + if ($value instanceof PersistentCollection) { + foreach ($value as $item) { + $this->collectUpdatedResource($item, 'updated'); + } + } else { + $this->collectUpdatedResource($value, 'updated'); + } + } + } + } + + private function gatherResourcesForPositionsWithPageDataProperties(): array + { + $positionResources = []; + foreach ($this->pageDataPropertiesChanged as $pageDataProperty) { + $positions = $this->positionRepository->findBy([ + 'pageDataProperty' => $pageDataProperty, + ]); + foreach ($positions as $position) { + $positionResources[] = $position; + } + } + + return $positionResources; + } + + private function gatherIrisForCollectionResources(): array + { + if (empty($this->resourceIris)) { + return []; + } + + $collectionResources = []; + foreach ($this->resourceIris as $resourceIri) { + $collections = $this->collectionRepository->findBy([ + 'resourceIri' => $resourceIri, + ]); + foreach ($collections as $collection) { + $collectionResources[] = $collection; + } + } + + $this->resourceIris = []; + if (empty($collectionResources)) { + return []; + } + + return $collectionResources; + } + + private function getAssociationMappings(ObjectManager $em, $entity): array + { + return $em->getClassMetadata(ClassUtils::getClass($entity))->getAssociationMappings(); + } + + private function collectUpdatedResource($resource, string $type, ?ObjectManager $em = null, bool $gatherRelated = false): void + { + if (!$resource) { + return; + } + $this->addResourceIris([$resource], $type); + $this->resourceChangedPropagator->collectItems([$resource], $type); + if ($gatherRelated && $em) { + $this->gatherRelationResourceClasses($em, $resource); + } + } + + private function addResourcesToPurge(array $resources, string $type): void + { + $this->addResourceIris($resources, $type); + $this->resourceChangedPropagator->collectItems($resources, $type); + } + + private function addResourceIris(array $resources, string $type): void + { + foreach ($resources as $resource) { + try { + $this->resourceChangedPropagator->collectResource($resource, $type); + + $resourceClass = $this->resourceClassResolver->getResourceClass($resource); + $resourceIri = $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, (new GetCollection())->withClass($resourceClass)); + + if (!\in_array($resourceIri, $this->resourceIris, true)) { + $this->resourceIris[] = $resourceIri; + } + } catch (InvalidArgumentException $e) { + } + } + } + + private function purgeResources(): void + { + $this->resourceChangedPropagator->propagate(); + $this->resourceIris = []; + } +} diff --git a/src/EventListener/Doctrine/PublishMercureUpdatesListener.php b/src/EventListener/Doctrine/PublishMercureUpdatesListener.php new file mode 100644 index 00000000..e3aa4219 --- /dev/null +++ b/src/EventListener/Doctrine/PublishMercureUpdatesListener.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Silverback\ApiComponentsBundle\EventListener\Doctrine; + +class PublishMercureUpdatesListener implements DoctrineResourceFlushListenerInterface +{ + use DoctrineResourceFlushTrait; +} diff --git a/src/EventListener/Doctrine/PurgeHttpCacheListener.php b/src/EventListener/Doctrine/PurgeHttpCacheListener.php index d7b1d985..4662fa00 100644 --- a/src/EventListener/Doctrine/PurgeHttpCacheListener.php +++ b/src/EventListener/Doctrine/PurgeHttpCacheListener.php @@ -13,240 +13,15 @@ namespace Silverback\ApiComponentsBundle\EventListener\Doctrine; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface; -use ApiPlatform\Api\UrlGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; -use ApiPlatform\Exception\OperationNotFoundException; -use ApiPlatform\Exception\RuntimeException; -use ApiPlatform\HttpCache\PurgerInterface; -use ApiPlatform\Metadata\GetCollection; -use Doctrine\Common\Util\ClassUtils; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\EntityRepository; -use Doctrine\ORM\Event\OnFlushEventArgs; -use Doctrine\ORM\Event\PreUpdateEventArgs; -use Doctrine\ORM\PersistentCollection; -use Doctrine\Persistence\ManagerRegistry; -use Doctrine\Persistence\ObjectRepository; -use Silverback\ApiComponentsBundle\Entity\Component\Collection; -use Silverback\ApiComponentsBundle\Entity\Core\ComponentPosition; -use Silverback\ApiComponentsBundle\Entity\Core\PageDataInterface; -use Symfony\Component\PropertyAccess\PropertyAccess; -use Symfony\Component\PropertyAccess\PropertyAccessor; - /** * Purges desired resources on when doctrine is flushed from the proxy cache. + * Will purge resources with related mapping too. * * @author Daniel West * * @experimental */ -class PurgeHttpCacheListener +class PurgeHttpCacheListener implements DoctrineResourceFlushListenerInterface { - private PurgerInterface $purger; - private IriConverterInterface $iriConverter; - private ResourceClassResolverInterface $resourceClassResolver; - private array $resourceIris = []; - private array $tags = []; - private array $pageDataPropertiesChanged = []; - private PropertyAccessor $propertyAccessor; - private ObjectRepository|EntityRepository $collectionRepository; - private ObjectRepository|EntityRepository $positionRepository; - - public function __construct(PurgerInterface $purger, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ManagerRegistry $entityManager) - { - $this->purger = $purger; - $this->iriConverter = $iriConverter; - $this->resourceClassResolver = $resourceClassResolver; - $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); - - $this->collectionRepository = $entityManager->getRepository(Collection::class); - $this->positionRepository = $entityManager->getRepository(ComponentPosition::class); - } - - /** - * Collects modified resources so we can check if any collection components need purging. - * - * Based on: - * - * @see \ApiPlatform\Doctrine\EventListener\PurgeHttpCacheListener - */ - public function preUpdate(PreUpdateEventArgs $eventArgs): void - { - $object = $eventArgs->getObject(); - $this->addResourceClass($object); - - $changeSet = $eventArgs->getEntityChangeSet(); - $associationMappings = $this->getAssociationMappings($eventArgs->getEntityManager(), $eventArgs->getObject()); - - if ($object instanceof PageDataInterface) { - $this->pageDataPropertiesChanged = array_keys($changeSet); - } - - foreach ($changeSet as $field => $value) { - if (!isset($associationMappings[$field])) { - continue; - } - - $this->addResourceClass($value[0]); - $this->addResourceClass($value[1]); - } - } - - public function onFlush(OnFlushEventArgs $eventArgs): void - { - $em = $eventArgs->getEntityManager(); - $uow = $em->getUnitOfWork(); - - foreach ($uow->getScheduledEntityInsertions() as $entity) { - $this->addResourceClass($entity); - $this->gatherRelationResourceClasses($em, $entity); - } - - foreach ($uow->getScheduledEntityUpdates() as $entity) { - $this->addResourceClass($entity); - $this->gatherRelationResourceClasses($em, $entity); - } - - foreach ($uow->getScheduledEntityDeletions() as $entity) { - $this->addResourceClass($entity); - $this->gatherRelationResourceClasses($em, $entity); - } - } - - /** - * Purges tags collected during this request, and clears the tag list. - * - * @see \ApiPlatform\Doctrine\EventListener\PurgeHttpCacheListener - */ - public function postFlush(): void - { - $this->purgePositionsWithPageDataProperties(); - $this->purgeCollectionResources(); - $this->purgeTags(); - } - - private function purgePositionsWithPageDataProperties(): void - { - foreach ($this->pageDataPropertiesChanged as $pageDataProperty) { - $positions = $this->positionRepository->findBy([ - 'pageDataProperty' => $pageDataProperty, - ]); - $positionIris = []; - foreach ($positions as $position) { - $positionIris[] = $this->iriConverter->getIriFromResource($position); - } - $this->purger->purge($positionIris); - } - } - - private function purgeCollectionResources(): void - { - if (empty($this->resourceIris)) { - return; - } - - $collectionIris = []; - foreach ($this->resourceIris as $resourceIri) { - $collections = $this->collectionRepository->findBy([ - 'resourceIri' => $resourceIri, - ]); - foreach ($collections as $collection) { - $collectionIris[] = $this->iriConverter->getIriFromResource($collection); - } - } - - $this->resourceIris = []; - if (empty($collectionIris)) { - return; - } - - $this->purger->purge($collectionIris); - } - - private function purgeTags(): void - { - if (empty($this->tags)) { - return; - } - - $this->purger->purge(array_values($this->tags)); - $this->tags = []; - } - - private function addResourceClass($entity): void - { - if (null === $entity) { - return; - } - - try { - $resourceClass = $this->resourceClassResolver->getResourceClass($entity); - $resourceIri = $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, (new GetCollection())->withClass($resourceClass)); - if (!\in_array($resourceIri, $this->resourceIris, true)) { - $this->resourceIris[] = $resourceIri; - } - } catch (OperationNotFoundException|InvalidArgumentException $e) { - } - } - - private function gatherRelationResourceClasses(EntityManagerInterface $em, $entity): void - { - $associationMappings = $this->getAssociationMappings($em, $entity); - foreach (array_keys($associationMappings) as $property) { - if ($this->propertyAccessor->isReadable($entity, $property)) { - $value = $this->propertyAccessor->getValue($entity, $property); - if ($value instanceof PersistentCollection) { - foreach ($value as $item) { - $this->addResourceClass($item); - } - } else { - $this->addResourceClass($value); - } - } - } - } - - private function getAssociationMappings(EntityManagerInterface $em, $entity): array - { - return $em->getClassMetadata(ClassUtils::getClass($entity))->getAssociationMappings(); - } - - /** - * @see \ApiPlatform\Doctrine\EventListener\PurgeHttpCacheListener - */ - public function addTagsFor($value): void - { - if (!$value) { - return; - } - - if (!is_iterable($value)) { - $this->addTagForItem($value); - - return; - } - - if ($value instanceof PersistentCollection) { - $value = clone $value; - } - - foreach ($value as $v) { - $this->addTagForItem($v); - } - } - - /** - * @see \ApiPlatform\Doctrine\EventListener\PurgeHttpCacheListener - */ - private function addTagForItem($value): void - { - try { - $iri = $this->iriConverter->getIriFromResource($value); - $this->tags[$iri] = $iri; - } catch (InvalidArgumentException $e) { - } catch (RuntimeException $e) { - } - } + use DoctrineResourceFlushTrait; } diff --git a/src/EventListener/Mercure/AddMercureTokenListener.php b/src/EventListener/Mercure/AddMercureTokenListener.php new file mode 100644 index 00000000..024d5781 --- /dev/null +++ b/src/EventListener/Mercure/AddMercureTokenListener.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Silverback\ApiComponentsBundle\EventListener\Mercure; + +use ApiPlatform\Exception\OperationNotFoundException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Util\CorsTrait; +use Silverback\ApiComponentsBundle\Annotation\Publishable; +use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker; +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\Mercure\Authorization; +use Symfony\Component\Routing\RequestContext; + +class AddMercureTokenListener +{ + use CorsTrait; + + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + private readonly PublishableStatusChecker $publishableStatusChecker, + private readonly RequestContext $requestContext, + private readonly Authorization $mercureAuthorization, + private readonly string $cookieSameSite = Cookie::SAMESITE_STRICT, + private readonly ?string $hubName = null + ) { + } + + /** + * Sends the Mercure header on each response. + * Probably lock this on the "/me" route. + */ + public function onKernelResponse(ResponseEvent $event): void + { + $request = $event->getRequest(); + // Prevent issues with NelmioCorsBundle + if ($this->isPreflightRequest($request)) { + return; + } + + $subscribeIris = []; + $response = $event->getResponse(); + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + if ($resourceIris = $this->getSubscribeIrisForResource($resourceClass)) { + $subscribeIris[] = $resourceIris; + } + } + $subscribeIris = array_merge([], ...$subscribeIris); + + // Todo: await merge of https://github.com/symfony/mercure/pull/93 to remove ability to publish any updates and set to null + // May also be able to await a mercure bundle update to set the cookie samesite in mercure configs + $cookie = $this->mercureAuthorization->createCookie($request, $request, $subscribeIris, [], $this->hubName); + $cookie->withSameSite($this->cookieSameSite); + $response->headers->setCookie($cookie); + } + + private function getSubscribeIrisForResource(string $resourceClass): ?array + { + $operation = $this->getMercureResourceOperation($resourceClass); + if (!$operation) { + return null; + } + + $refl = new \ReflectionClass($operation->getClass()); + $isPublishable = \count($refl->getAttributes(Publishable::class)); + + $uriTemplate = $this->buildAbsoluteUriTemplate() . $operation->getRoutePrefix() . $operation->getUriTemplate(); + $subscribeIris = [$uriTemplate]; + + if (!$isPublishable) { + return $subscribeIris; + } + + // Note that `?draft=1` is also hard coded into the PublishableIriConverter, probably make this configurable somewhere + if ($this->publishableStatusChecker->isGranted($operation->getClass())) { + $subscribeIris[] = $uriTemplate . '?draft=1'; + } + + return $subscribeIris; + } + + private function getMercureResourceOperation(string $resourceClass): ?HttpOperation + { + $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); + + try { + $operation = $resourceMetadataCollection->getOperation(forceCollection: false, httpOperation: true); + } catch (OperationNotFoundException $e) { + return null; + } + + if (!$operation instanceof HttpOperation) { + return null; + } + + $mercure = $operation->getMercure(); + + if (!$mercure) { + return null; + } + + return $operation; + } + + /** + * Mercure subscribe iris should be absolute + * this code can also be found in Symfony's URL Generator + * but as we work without a symfony route here (and we would not want to do this as its not spec-compliant) + * we do it by hand. + */ + private function buildAbsoluteUriTemplate(): string + { + $scheme = $this->requestContext->getScheme(); + $host = $this->requestContext->getHost(); + $port = $this->requestContext->isSecure() ? $this->requestContext->getHttpsPort() : $this->requestContext->getHttpPort(); + + if (80 !== $port || 443 !== $port) { + return sprintf('%s://%s:%d', $scheme, $host, $port); + } + + return sprintf('%s://%s', $scheme, $host); + } +} diff --git a/src/EventListener/ResourceChangedEventListener.php b/src/EventListener/ResourceChangedEventListener.php new file mode 100644 index 00000000..4f0e4ac5 --- /dev/null +++ b/src/EventListener/ResourceChangedEventListener.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Silverback\ApiComponentsBundle\EventListener; + +use Silverback\ApiComponentsBundle\Event\ResourceChangedEvent; +use Silverback\ApiComponentsBundle\HttpCache\ResourceChangedPropagatorInterface; + +class ResourceChangedEventListener +{ + public function __construct(private readonly iterable $resourceChangedPropagators) + { + } + + public function __invoke(ResourceChangedEvent $event): void + { + /** @var ResourceChangedPropagatorInterface $resourceChangedPropagator */ + foreach ($this->resourceChangedPropagators as $resourceChangedPropagator) { + $resourceChangedPropagator->collectItems([$event->getResource()], $event->getType()); + } + } +} diff --git a/src/Helper/Uploadable/UploadableFileManager.php b/src/Helper/Uploadable/UploadableFileManager.php index 9da0b4be..f77a5e01 100644 --- a/src/Helper/Uploadable/UploadableFileManager.php +++ b/src/Helper/Uploadable/UploadableFileManager.php @@ -76,7 +76,7 @@ public function processClonedUploadable(object $oldObject, object $newObject): o $propertyAccessor = PropertyAccess::createPropertyAccessor(); $configuredProperties = $this->annotationReader->getConfiguredProperties($oldObject, false); - foreach ($configuredProperties as $fileProperty => $fieldConfiguration) { + foreach ($configuredProperties as $fieldConfiguration) { if ($propertyAccessor->getValue($oldObject, $fieldConfiguration->property)) { $newPath = $this->copyFilepath($oldObject, $fieldConfiguration); $propertyAccessor->setValue($newObject, $fieldConfiguration->property, $newPath); @@ -129,10 +129,11 @@ public function persistFiles(object $object): void $configuredProperties = $this->annotationReader->getConfiguredProperties($object, true); foreach ($configuredProperties as $fileProperty => $fieldConfiguration) { - // this is null if null is submitted as the value and null if not submitted + // this is null if null is submitted as the value... also null if not submitted /** @var File|UploadedDataUriFile|null $file */ $file = $propertyAccessor->getValue($object, $fileProperty); if (!$file) { + // so we need to know if it was a deleted field from the denormalizer if ($this->deletedFields->contains($fieldConfiguration->property)) { // this will not have been updated yet, original database value - string file path $currentFilepath = $classMetadata->getFieldValue($object, $fieldConfiguration->property); diff --git a/src/HttpCache/HttpCachePurger.php b/src/HttpCache/HttpCachePurger.php new file mode 100644 index 00000000..51b52c5d --- /dev/null +++ b/src/HttpCache/HttpCachePurger.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Silverback\ApiComponentsBundle\HttpCache; + +use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Api\ResourceClassResolverInterface; +use ApiPlatform\Api\UrlGeneratorInterface; +use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Exception\OperationNotFoundException; +use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\Metadata\GetCollection; +use Doctrine\ORM\PersistentCollection; + +class HttpCachePurger implements ResourceChangedPropagatorInterface +{ + private array $tags; + + public function __construct( + private readonly IriConverterInterface $iriConverter, + private readonly ResourceClassResolverInterface $resourceClassResolver, + private readonly PurgerInterface $httpCachePurger, + ) { + $this->reset(); + } + + public function collectResource($entity, ?string $type = null): void + { + if (null === $entity) { + return; + } + + try { + $resourceClass = $this->resourceClassResolver->getResourceClass($entity); + $resourceIri = $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, (new GetCollection())->withClass($resourceClass)); + $this->collectIri([$resourceIri]); + } catch (OperationNotFoundException|InvalidArgumentException $e) { + } + } + + public function collectItems($items, ?string $type = null): void + { + if (!$items) { + return; + } + + if (!is_iterable($items)) { + $this->collectItem($items); + + return; + } + + if ($items instanceof PersistentCollection) { + $items = clone $items; + } + + foreach ($items as $i) { + $this->collectItem($i); + } + } + + private function collectItem($item): void + { + try { + $iri = $this->iriConverter->getIriFromResource($item); + $this->collectIri([$iri]); + } catch (InvalidArgumentException|RuntimeException $e) { + } + } + + private function collectIri(array $iris): void + { + foreach ($iris as $iri) { + if (!\in_array($iri, $this->tags, true)) { + $this->tags[$iri] = $iri; + } + } + } + + public function propagate(): void + { + if (empty($this->tags)) { + return; + } + + $this->httpCachePurger->purge(array_values($this->tags)); + $this->reset(); + } + + public function reset(): void + { + $this->tags = []; + } +} diff --git a/src/HttpCache/ResourceChangedPropagatorInterface.php b/src/HttpCache/ResourceChangedPropagatorInterface.php new file mode 100644 index 00000000..38d2b54e --- /dev/null +++ b/src/HttpCache/ResourceChangedPropagatorInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Silverback\ApiComponentsBundle\HttpCache; + +interface ResourceChangedPropagatorInterface +{ + public function collectResource($entity, ?string $type = null): void; + + public function collectItems($items, ?string $type = null): void; + + public function propagate(): void; + + public function reset(): void; +} diff --git a/src/Mercure/MercureResourcePublisher.php b/src/Mercure/MercureResourcePublisher.php new file mode 100644 index 00000000..ef5a6920 --- /dev/null +++ b/src/Mercure/MercureResourcePublisher.php @@ -0,0 +1,291 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Silverback\ApiComponentsBundle\Mercure; + +use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Api\ResourceClassResolverInterface; +use ApiPlatform\Api\UrlGeneratorInterface; +use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Exception\OperationNotFoundException; +use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface as GraphQlMercureSubscriptionIriGeneratorInterface; +use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface as GraphQlSubscriptionManagerInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Symfony\Messenger\DispatchTrait; +use ApiPlatform\Util\ResourceClassInfoTrait; +use Silverback\ApiComponentsBundle\HttpCache\ResourceChangedPropagatorInterface; +use Symfony\Component\ExpressionLanguage\ExpressionFunction; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Mercure\HubRegistry; +use Symfony\Component\Mercure\Update; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +class MercureResourcePublisher implements SerializerAwareInterface, ResourceChangedPropagatorInterface +{ + use DispatchTrait; + use ResourceClassInfoTrait; + use SerializerAwareTrait; + private const ALLOWED_KEYS = [ + 'topics' => true, + 'data' => true, + 'private' => true, + 'id' => true, + 'type' => true, + 'retry' => true, + 'normalization_context' => true, + 'hub' => true, + 'enable_async_update' => true, + ]; + + private readonly ?ExpressionLanguage $expressionLanguage; + private \SplObjectStorage $createdObjects; + private \SplObjectStorage $updatedObjects; + private \SplObjectStorage $deletedObjects; + + // Do we want MessageBusInterface instead ? we don't have messenger installed yet, probably just use the default hub for now + public function __construct( + private readonly HubRegistry $hubRegistry, + private readonly IriConverterInterface $iriConverter, + private readonly array $formats, + ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, + ResourceClassResolverInterface $resourceClassResolver, + ?MessageBusInterface $messageBus = null, + private readonly ?GraphQlSubscriptionManagerInterface $graphQlSubscriptionManager = null, + private readonly ?GraphQlMercureSubscriptionIriGeneratorInterface $graphQlMercureSubscriptionIriGenerator = null, + ?ExpressionLanguage $expressionLanguage = null + ) { + $this->reset(); + $this->resourceClassResolver = $resourceClassResolver; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->messageBus = $messageBus; + $this->expressionLanguage = $expressionLanguage ?? (class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null); + if ($this->expressionLanguage) { + $rawurlencode = ExpressionFunction::fromPhp('rawurlencode', 'escape'); + $this->expressionLanguage->addFunction($rawurlencode); + + $this->expressionLanguage->addFunction( + new ExpressionFunction('iri', static fn (string $apiResource, int $referenceType = UrlGeneratorInterface::ABS_URL): string => sprintf('iri(%s, %d)', $apiResource, $referenceType), static fn (array $arguments, $apiResource, int $referenceType = UrlGeneratorInterface::ABS_URL): string => $iriConverter->getIriFromResource($apiResource, $referenceType)) + ); + } + } + + public function reset(): void + { + $this->createdObjects = new \SplObjectStorage(); + $this->updatedObjects = new \SplObjectStorage(); + $this->deletedObjects = new \SplObjectStorage(); + } + + public function collectResource($entity, ?string $type = null): void + { + // this is not needed for Mercure. + // this clears cache for endpoints getting collections etc. + // Mercure will only update for individual items + } + + public function collectItems($items, ?string $type = null): void + { + $property = sprintf('%sObjects', $type); + if (!isset($this->{$property})) { + throw new \InvalidArgumentException(sprintf('Cannot collect Mercure resource with type %s : the property %s does not exist.', $type, $property)); + } + + foreach ($items as $item) { + $this->storeObjectToPublish($item, $property); + } + } + + /** + * @throws \ApiPlatform\Exception\ResourceClassNotFoundException + * + * @description See: ApiPlatform\Doctrine\EventListener\PublishMercureUpdatesListener + */ + private function storeObjectToPublish(object $object, string $property): void + { + if (null === $resourceClass = $this->getResourceClass($object)) { + return; + } + + try { + $options = $this->resourceMetadataFactory->create($resourceClass)->getOperation()->getMercure() ?? false; + } catch (OperationNotFoundException) { + return; + } + + if (\is_string($options)) { + if (null === $this->expressionLanguage) { + throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".'); + } + + $options = $this->expressionLanguage->evaluate($options, ['object' => $object]); + } + + if (false === $options) { + return; + } + + if (true === $options) { + $options = []; + } + + if (!\is_array($options)) { + throw new InvalidArgumentException(sprintf('The value of the "mercure" attribute of the "%s" resource class must be a boolean, an array of options or an expression returning this array, "%s" given.', $resourceClass, \gettype($options))); + } + + foreach ($options as $key => $value) { + if (!isset(self::ALLOWED_KEYS[$key])) { + throw new InvalidArgumentException(sprintf('The option "%s" set in the "mercure" attribute of the "%s" resource does not exist. Existing options: "%s"', $key, $resourceClass, implode('", "', self::ALLOWED_KEYS))); + } + } + + $options['enable_async_update'] ??= true; + + if ($options['topics'] ?? false) { + $topics = []; + foreach ((array) $options['topics'] as $topic) { + if (!\is_string($topic)) { + $topics[] = $topic; + continue; + } + + if (!str_starts_with($topic, '@=')) { + $topics[] = $topic; + continue; + } + + if (null === $this->expressionLanguage) { + throw new \LogicException('The "@=" expression syntax cannot be used without the Expression Language component. Try running "composer require symfony/expression-language".'); + } + + $topics[] = $this->expressionLanguage->evaluate(substr($topic, 2), ['object' => $object]); + } + + $options['topics'] = $topics; + } + + $id = $this->iriConverter->getIriFromResource($object); + $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL); + $objectData = ['id' => $id, 'iri' => $iri, 'mercureOptions' => $options]; + + if ('deletedObjects' === $property) { + $this->createdObjects->detach($object); + $this->updatedObjects->detach($object); + $deletedObject = (object) [ + 'id' => $this->iriConverter->getIriFromResource($object), + 'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL), + ]; + $this->deletedObjects[$deletedObject] = $objectData; + + return; + } + + $this->{$property}[$object] = $objectData; + } + + public function propagate(): void + { + try { + foreach ($this->createdObjects as $object) { + $this->publishUpdate($object, $this->createdObjects[$object], 'create'); + } + + foreach ($this->updatedObjects as $object) { + $this->publishUpdate($object, $this->updatedObjects[$object], 'update'); + } + + foreach ($this->deletedObjects as $object) { + $this->publishUpdate($object, $this->deletedObjects[$object], 'delete'); + } + } finally { + $this->reset(); + } + } + + private static function getDeletedIriAndData(array $objectData): array + { + // By convention, if the object has been deleted, we send only its IRI. + // This may change in the feature, because it's not JSON Merge Patch compliant, + // and I'm not a fond of this approach. + $iri = $options['topics'] ?? $objectData['iri']; + /** @var string $data */ + $data = json_encode(['@id' => $objectData['id']], \JSON_THROW_ON_ERROR); + + return [$iri, $data]; + } + + private function publishUpdate(object $object, array $objectData, string $type): void + { + $options = $objectData['mercureOptions']; + + if ($object instanceof \stdClass) { + [$iri, $data] = self::getDeletedIriAndData($objectData); + } else { + $resourceClass = $this->getObjectClass($object); + $context = $options['normalization_context'] ?? $this->resourceMetadataFactory->create($resourceClass)->getOperation()->getNormalizationContext() ?? []; + try { + $iri = $options['topics'] ?? $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL); + $data = $options['data'] ?? $this->serializer->serialize($object, key($this->formats), $context); + } catch (InvalidArgumentException) { + // the object may have been deleted at the database level with delete cascades... + [$iri, $data] = self::getDeletedIriAndData($objectData); + $type = 'delete'; + } + } + + $updates = array_merge([$this->buildUpdate($iri, $data, $options)], $this->getGraphQlSubscriptionUpdates($object, $options, $type)); + + foreach ($updates as $update) { + if ($options['enable_async_update'] && $this->messageBus) { + $this->dispatch($update); + continue; + } + + $this->hubRegistry->getHub($options['hub'] ?? null)->publish($update); + } + } + + /** + * @return Update[] + */ + private function getGraphQlSubscriptionUpdates(object $object, array $options, string $type): array + { + if ('update' !== $type || !$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) { + return []; + } + + $payloads = $this->graphQlSubscriptionManager->getPushPayloads($object); + + $updates = []; + foreach ($payloads as [$subscriptionId, $data]) { + $updates[] = $this->buildUpdate( + $this->graphQlMercureSubscriptionIriGenerator->generateTopicIri($subscriptionId), + (string) (new JsonResponse($data))->getContent(), + $options + ); + } + + return $updates; + } + + /** + * @param string|string[] $iri + */ + private function buildUpdate(string|array $iri, string $data, array $options): Update + { + return new Update($iri, $data, $options['private'] ?? false, $options['id'] ?? null, $options['type'] ?? null, $options['retry'] ?? null); + } +} diff --git a/src/Mercure/PublishableAwareHub.php b/src/Mercure/PublishableAwareHub.php new file mode 100644 index 00000000..7a40cdb2 --- /dev/null +++ b/src/Mercure/PublishableAwareHub.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Silverback\ApiComponentsBundle\Mercure; + +use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Exception\ItemNotFoundException; +use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker; +use Symfony\Component\Mercure\HubInterface; +use Symfony\Component\Mercure\Jwt\TokenFactoryInterface; +use Symfony\Component\Mercure\Jwt\TokenProviderInterface; +use Symfony\Component\Mercure\Update; + +/** + * @description Force draft resources to be private updates + */ +class PublishableAwareHub implements HubInterface +{ + public function __construct(private HubInterface $decorated, private PublishableStatusChecker $publishableStatusChecker, private IriConverterInterface $iriConverter) + { + } + + public function getUrl(): string + { + return $this->decorated->getUrl(); + } + + public function getPublicUrl(): string + { + return $this->decorated->getPublicUrl(); + } + + public function getProvider(): TokenProviderInterface + { + return $this->decorated->getProvider(); + } + + public function getFactory(): ?TokenFactoryInterface + { + return $this->decorated->getFactory(); + } + + public function publish(Update $update): string + { + if ($update->getData() && $data = json_decode($update->getData(), true, 512, \JSON_THROW_ON_ERROR)) { + try { + $resource = $this->iriConverter->getResourceFromIri($data['@id']); + } catch (ItemNotFoundException $e) { + return $this->decorated->publish($update); + } + + if ($this->publishableStatusChecker->getAnnotationReader()->isConfigured($resource) && !$this->publishableStatusChecker->isActivePublishedAt($resource)) { + $update = new Update(topics: $update->getTopics(), data: $update->getData(), private: true, id: $update->getId(), type: $update->getType(), retry: $update->getRetry()); + } + } + + return $this->decorated->publish($update); + } +} diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index c13b034e..6f85f456 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -14,6 +14,8 @@ namespace Silverback\ApiComponentsBundle\Resources\config; use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Symfony\EventListener\EventPriorities; use Doctrine\ORM\EntityManagerInterface; @@ -25,6 +27,7 @@ use Silverback\ApiComponentsBundle\Action\User\PasswordRequestAction; use Silverback\ApiComponentsBundle\Action\User\VerifyEmailAddressAction; use Silverback\ApiComponentsBundle\ApiPlatform\Api\IriConverter; +use Silverback\ApiComponentsBundle\ApiPlatform\Api\MercureIriConverter; use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Property\ComponentPropertyMetadataFactory; use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Property\ImagineFiltersPropertyMetadataFactory; use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Resource\ComponentResourceMetadataFactory; @@ -52,6 +55,7 @@ use Silverback\ApiComponentsBundle\Event\ImagineRemoveEvent; use Silverback\ApiComponentsBundle\Event\ImagineStoreEvent; use Silverback\ApiComponentsBundle\Event\JWTRefreshedEvent; +use Silverback\ApiComponentsBundle\Event\ResourceChangedEvent; use Silverback\ApiComponentsBundle\EventListener\Api\CollectionApiEventListener; use Silverback\ApiComponentsBundle\EventListener\Api\ComponentPositionEventListener; use Silverback\ApiComponentsBundle\EventListener\Api\ComponentUsageEventListener; @@ -73,6 +77,8 @@ use Silverback\ApiComponentsBundle\EventListener\Jwt\JWTClearTokenListener; use Silverback\ApiComponentsBundle\EventListener\Jwt\JWTEventListener; use Silverback\ApiComponentsBundle\EventListener\Mailer\MessageEventListener; +use Silverback\ApiComponentsBundle\EventListener\Mercure\AddMercureTokenListener; +use Silverback\ApiComponentsBundle\EventListener\ResourceChangedEventListener; use Silverback\ApiComponentsBundle\Factory\Form\FormViewFactory; use Silverback\ApiComponentsBundle\Factory\Uploadable\MediaObjectFactory; use Silverback\ApiComponentsBundle\Factory\User\Mailer\AbstractUserEmailFactory; @@ -105,6 +111,7 @@ use Silverback\ApiComponentsBundle\Helper\User\UserDataProcessor; use Silverback\ApiComponentsBundle\Helper\User\UserMailer; use Silverback\ApiComponentsBundle\Imagine\FlysystemDataLoader; +use Silverback\ApiComponentsBundle\Mercure\PublishableAwareHub; use Silverback\ApiComponentsBundle\Metadata\Factory\CachedPageDataMetadataFactory; use Silverback\ApiComponentsBundle\Metadata\Factory\ComponentUsageMetadataFactory; use Silverback\ApiComponentsBundle\Metadata\Factory\PageDataMetadataFactory; @@ -136,7 +143,7 @@ use Silverback\ApiComponentsBundle\Serializer\MappingLoader\TimestampedLoader; use Silverback\ApiComponentsBundle\Serializer\MappingLoader\UploadableLoader; use Silverback\ApiComponentsBundle\Serializer\Normalizer\PublishableNormalizer; -use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataBuilder; +use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataProvider; use Silverback\ApiComponentsBundle\Serializer\SerializeFormatResolver; use Silverback\ApiComponentsBundle\Utility\ApiResourceRouteFinder; use Silverback\ApiComponentsBundle\Validator\Constraints\ComponentPositionValidator; @@ -161,6 +168,7 @@ use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Mailer\Event\MessageEvent; use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mercure\Authorization; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\RouterInterface; @@ -246,7 +254,6 @@ new Reference(CwaResourceContextBuilder::class . '.inner'), new Reference(RoleHierarchyInterface::class), new Reference(Security::class), - new Reference('silverback.serializer.resource_metadata.resource_metadata_builder'), ] ) ->autoconfigure(false); @@ -609,6 +616,32 @@ ->args([new Reference(PublishableAttributeReader::class)]) ->tag('doctrine.event_listener', ['event' => 'loadClassMetadata']); + $services + ->set(AddMercureTokenListener::class) + ->args( + [ + new Reference(ResourceNameCollectionFactoryInterface::class), + new Reference(ResourceMetadataCollectionFactoryInterface::class), + new Reference(PublishableStatusChecker::class), + new Reference('router.request_context'), + new Reference(Authorization::class), + '', // injected with dependency injection + ] + ) + ->tag('kernel.event_listener', ['event' => ResponseEvent::class, 'method' => 'onKernelResponse']); + + $services + ->set(PublishableAwareHub::class) + ->decorate('mercure.hub.default', null, -1) + ->args( + [ + new Reference(PublishableAwareHub::class . '.inner'), + new Reference(PublishableStatusChecker::class), + new Reference(IriConverterInterface::class), + ] + ) + ->tag('mercure.hub'); + // High priority for item because of queryBuilder reset $services ->set(PublishableExtension::class) @@ -723,6 +756,18 @@ ] ); + $services + ->set(ResourceChangedEventListener::class) + ->tag('kernel.event_listener', ['event' => ResourceChangedEvent::class]) + ->args( + [ + '$resourceChangedPropagators' => new TaggedIteratorArgument('silverback_api_components.resource_changed_propagator'), + ] + ); + + $services + ->set(ResourceMetadataProvider::class); + $services ->set(RouteStateProvider::class) ->args( @@ -1301,16 +1346,12 @@ ]); $services->alias('silverback.iri_converter', IriConverter::class); - /* - * - - - - - - - - */ + $services + ->set(MercureIriConverter::class) + ->args([ + new Reference('api_platform.iri_converter'), + new Reference(PublishableStatusChecker::class), + ]); $services ->set('silverback.doctrine.orm.or_search_filter') @@ -1325,8 +1366,4 @@ ]) ->arg('$nameConverter', new Reference('api_platform.name_converter', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)); $services->alias(OrSearchFilter::class, 'silverback.doctrine.orm.or_search_filter'); - - $services - ->set('silverback.serializer.resource_metadata.resource_metadata_builder') - ->class(ResourceMetadataBuilder::class); }; diff --git a/src/Resources/config/services_doctrine_orm_http_cache_purger.php b/src/Resources/config/services_doctrine_orm_http_cache_purger.php index c2a695dc..7b7c6463 100644 --- a/src/Resources/config/services_doctrine_orm_http_cache_purger.php +++ b/src/Resources/config/services_doctrine_orm_http_cache_purger.php @@ -18,6 +18,7 @@ use Doctrine\ORM\Events as DoctrineEvents; use Doctrine\Persistence\ManagerRegistry; use Silverback\ApiComponentsBundle\EventListener\Doctrine\PurgeHttpCacheListener; +use Silverback\ApiComponentsBundle\HttpCache\HttpCachePurger; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Reference; @@ -28,13 +29,23 @@ ->set('silverback.api_components.event_listener.doctrine.purge_http_cache_listener') ->class(PurgeHttpCacheListener::class) ->args([ - new Reference('api_platform.http_cache.purger'), new Reference('api_platform.iri_converter'), - new Reference('api_platform.resource_class_resolver'), new Reference(ManagerRegistry::class), + new Reference('silverback.api_components.http_cache.purger'), + new Reference('api_platform.resource_class_resolver'), ]) ->tag('doctrine.event_listener', ['event' => DoctrineEvents::onFlush]) ->tag('doctrine.event_listener', ['event' => DoctrineEvents::preUpdate]) ->tag('doctrine.event_listener', ['event' => DoctrineEvents::postFlush]); $services->alias(PurgeHttpCacheListener::class, 'silverback.api_components.event_listener.doctrine.purge_http_cache_listener'); + + $services + ->set('silverback.api_components.http_cache.purger') + ->class(HttpCachePurger::class) + ->args([ + new Reference('api_platform.iri_converter'), + new Reference('api_platform.resource_class_resolver'), + new Reference('api_platform.http_cache.purger'), + ]); + $services->alias(HttpCachePurger::class, 'silverback.api_components.http_cache.purger'); }; diff --git a/src/Resources/config/services_doctrine_orm_mercure_publisher.php b/src/Resources/config/services_doctrine_orm_mercure_publisher.php new file mode 100644 index 00000000..4972c453 --- /dev/null +++ b/src/Resources/config/services_doctrine_orm_mercure_publisher.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +/* + * @author Daniel West + */ + +use Doctrine\ORM\Events as DoctrineEvents; +use Doctrine\Persistence\ManagerRegistry; +use Silverback\ApiComponentsBundle\ApiPlatform\Api\MercureIriConverter; +use Silverback\ApiComponentsBundle\EventListener\Doctrine\PublishMercureUpdatesListener; +use Silverback\ApiComponentsBundle\Mercure\MercureResourcePublisher; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Mercure\HubRegistry; + +return static function (ContainerConfigurator $configurator) { + $services = $configurator->services(); + + $services + ->set('silverback.api_components.event_listener.doctrine.mercure_publish_listener') + ->class(PublishMercureUpdatesListener::class) + ->args([ + new Reference('api_platform.iri_converter'), + new Reference(ManagerRegistry::class), + new Reference('silverback.api_components.mercure.resource_publisher'), + new Reference('api_platform.resource_class_resolver'), + ]) + ->tag('doctrine.event_listener', ['event' => DoctrineEvents::onFlush]) + ->tag('doctrine.event_listener', ['event' => DoctrineEvents::preUpdate]) + ->tag('doctrine.event_listener', ['event' => DoctrineEvents::postFlush]); + $services->alias(PublishMercureUpdatesListener::class, 'silverback.api_components.event_listener.doctrine.mercure_publish_listener'); + + $services + ->set('silverback.api_components.mercure.resource_publisher') + ->class(MercureResourcePublisher::class) + ->args([ + new Reference(HubRegistry::class), + new Reference(MercureIriConverter::class), + '%api_platform.formats%', + new Reference('api_platform.metadata.resource.metadata_collection_factory'), + new Reference('api_platform.resource_class_resolver'), + new Reference('messenger.default_bus', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), + new Reference('api_platform.graphql.subscription.subscription_manager', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), + new Reference('api_platform.graphql.subscription.mercure_iri_generator', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), + ]) + ->call('setSerializer', [new Reference('serializer')]); + $services->alias(MercureResourcePublisher::class, 'silverback.api_components.mercure.resource_publisher'); +}; diff --git a/src/Resources/config/services_normalizers.php b/src/Resources/config/services_normalizers.php index 60312dc6..270b0e63 100644 --- a/src/Resources/config/services_normalizers.php +++ b/src/Resources/config/services_normalizers.php @@ -38,10 +38,11 @@ use Silverback\ApiComponentsBundle\Serializer\Normalizer\TimestampedNormalizer; use Silverback\ApiComponentsBundle\Serializer\Normalizer\UploadableNormalizer; use Silverback\ApiComponentsBundle\Serializer\Normalizer\UserNormalizer; +use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataProvider; use Silverback\ApiComponentsBundle\Utility\ApiResourceRouteFinder; -use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; @@ -73,7 +74,7 @@ ->set(CollectionNormalizer::class) ->autoconfigure(false) ->args([ - new Reference('silverback.serializer.resource_metadata.resource_metadata_builder'), + new Reference(ResourceMetadataProvider::class), ]) ->tag('serializer.normalizer', ['priority' => -499]); @@ -87,7 +88,7 @@ new Reference(PublishableStatusChecker::class), new Reference(ManagerRegistry::class), new Reference('api_platform.iri_converter'), - new Reference('silverback.serializer.resource_metadata.resource_metadata_builder'), + new Reference(ResourceMetadataProvider::class), ]) ->tag('serializer.normalizer', ['priority' => -499]); @@ -108,7 +109,7 @@ ->args( [ '', // set in dependency injection - new Reference('silverback.serializer.resource_metadata.resource_metadata_builder'), + new Reference(ResourceMetadataProvider::class), ] ) ->tag('serializer.normalizer', ['priority' => -500]); @@ -120,7 +121,7 @@ [ new Reference(EntityManagerInterface::class), new Reference(ResourceClassResolverInterface::class), - new Reference('silverback.serializer.resource_metadata.resource_metadata_builder'), + new Reference(ResourceMetadataProvider::class), ] ) ->tag('serializer.normalizer', ['priority' => -499]); @@ -136,8 +137,8 @@ new Reference('api_platform.validator'), new Reference(IriConverterInterface::class), new Reference(UploadableFileManager::class), - new Reference('silverback.serializer.resource_metadata.resource_metadata_builder'), - new Reference('silverback.api_components.event_listener.doctrine.purge_http_cache_listener', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), + new Reference(ResourceMetadataProvider::class), + new Reference(EventDispatcherInterface::class), ] )->tag('serializer.normalizer', ['priority' => -400]); @@ -147,7 +148,7 @@ ->args( [ new Reference('silverback.metadata_factory.page_data'), - new Reference('silverback.serializer.resource_metadata.resource_metadata_builder'), + new Reference(ResourceMetadataProvider::class), ] )->tag('serializer.normalizer', ['priority' => -499]); @@ -176,8 +177,8 @@ new Reference(MediaObjectFactory::class), new Reference(UploadableAttributeReader::class), new Reference(UploadableFileManager::class), - new Reference('silverback.serializer.resource_metadata.resource_metadata_builder'), new Reference(ManagerRegistry::class), + new Reference(ResourceMetadataProvider::class), ] ) ->tag('serializer.normalizer', ['priority' => -499]); diff --git a/src/Serializer/ContextBuilder/CwaResourceContextBuilder.php b/src/Serializer/ContextBuilder/CwaResourceContextBuilder.php index e660724c..426f9ce4 100644 --- a/src/Serializer/ContextBuilder/CwaResourceContextBuilder.php +++ b/src/Serializer/ContextBuilder/CwaResourceContextBuilder.php @@ -17,8 +17,6 @@ use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent; use Silverback\ApiComponentsBundle\Entity\Core\AbstractPageData; use Silverback\ApiComponentsBundle\Serializer\MappingLoader\CwaResourceLoader; -use Silverback\ApiComponentsBundle\Serializer\Normalizer\MetadataNormalizer; -use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataBuilder; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; use Symfony\Component\Security\Core\Security; @@ -32,17 +30,13 @@ public function __construct( private SerializerContextBuilderInterface $decorated, private RoleHierarchyInterface $roleHierarchy, private Security $security, - private ResourceMetadataBuilder $resourceMetadataProvider ) { } public function createFromRequest(Request $request, bool $normalization, array $extractedAttributes = null): array { $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes); - if (!isset($context['groups']) || !\in_array('Route:manifest:read', $context['groups'], true)) { - $this->resourceMetadataProvider->init(); - $context[MetadataNormalizer::ALREADY_CALLED] = []; - } + if ( !is_a($resourceClass = $context['resource_class'], AbstractComponent::class, true) && !is_a($resourceClass, AbstractPageData::class, true) diff --git a/src/Serializer/Normalizer/CollectionNormalizer.php b/src/Serializer/Normalizer/CollectionNormalizer.php index ca2c9b0f..8c94d6b0 100644 --- a/src/Serializer/Normalizer/CollectionNormalizer.php +++ b/src/Serializer/Normalizer/CollectionNormalizer.php @@ -15,7 +15,7 @@ use ApiPlatform\Util\ClassInfoTrait; use Silverback\ApiComponentsBundle\Entity\Component\Collection; -use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataInterface; +use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataProvider; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessor; @@ -37,7 +37,7 @@ class CollectionNormalizer implements NormalizerInterface, CacheableSupportsMeth private PropertyAccessor $propertyAccessor; - public function __construct(private ResourceMetadataInterface $resourceMetadata) + public function __construct(private readonly ResourceMetadataProvider $resourceMetadataProvider) { $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); } @@ -45,7 +45,9 @@ public function __construct(private ResourceMetadataInterface $resourceMetadata) public function normalize($object, $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null { $context[self::ALREADY_CALLED][] = $this->propertyAccessor->getValue($object, 'id'); - $this->resourceMetadata->setCollection(true); + + $resourceMetadata = $this->resourceMetadataProvider->findResourceMetadata($object); + $resourceMetadata->setCollection(true); return $this->normalizer->normalize($object, $format, $context); } diff --git a/src/Serializer/Normalizer/ComponentPositionNormalizer.php b/src/Serializer/Normalizer/ComponentPositionNormalizer.php index 2c813280..0a81f907 100644 --- a/src/Serializer/Normalizer/ComponentPositionNormalizer.php +++ b/src/Serializer/Normalizer/ComponentPositionNormalizer.php @@ -22,8 +22,9 @@ use Silverback\ApiComponentsBundle\Exception\InvalidArgumentException; use Silverback\ApiComponentsBundle\Helper\ComponentPosition\ComponentPositionSortValueHelper; use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker; -use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataInterface; +use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataProvider; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\PropertyAccess\Exception\NoSuchIndexException; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException; @@ -50,13 +51,13 @@ class ComponentPositionNormalizer implements CacheableSupportsMethodInterface, D private const ALREADY_CALLED = 'COMPONENT_POSITION_NORMALIZER_ALREADY_CALLED'; public function __construct( - private PageDataProvider $pageDataProvider, - private ComponentPositionSortValueHelper $componentPositionSortValueHelper, - private RequestStack $requestStack, - private PublishableStatusChecker $publishableStatusChecker, - private ManagerRegistry $registry, - private IriConverterInterface $iriConverter, - private ResourceMetadataInterface $resourceMetadata + private readonly PageDataProvider $pageDataProvider, + private readonly ComponentPositionSortValueHelper $componentPositionSortValueHelper, + private readonly RequestStack $requestStack, + private readonly PublishableStatusChecker $publishableStatusChecker, + private readonly ManagerRegistry $registry, + private readonly IriConverterInterface $iriConverter, + private readonly ResourceMetadataProvider $resourceMetadataProvider ) { } @@ -97,15 +98,19 @@ public function normalize($object, $format = null, array $context = []): float|a $context[self::ALREADY_CALLED] = true; - $staticComponent = $object->component ? $this->getPublishableComponent($object->component) : null; - if ($staticComponent) { - $this->resourceMetadata->setStaticComponent($this->iriConverter->getIriFromResource($staticComponent)); - } + $staticComponent = $object->component; + $resourceMetadata = $this->resourceMetadataProvider->findResourceMetadata($object); $object = $this->normalizeForPageData($object); if ($object->component !== $staticComponent) { - $component = $object->component; - $object->setComponent($this->getPublishableComponent($component)); + $resourceMetadata->setPageDataPath($this->pageDataProvider->getOriginalRequestPath()); + } + + if ($object->component) { + $object->component = $this->getPublishableComponent($object->component); + } + if ($staticComponent) { + $resourceMetadata->setStaticComponent($this->iriConverter->getIriFromResource($this->getPublishableComponent($staticComponent))); } return $this->normalizer->normalize($object, $format, $context); @@ -143,7 +148,13 @@ private function normalizeForPageData(ComponentPosition $object): ComponentPosit if (!$object->pageDataProperty || !$this->requestStack->getCurrentRequest()) { return $object; } - $pageData = $this->pageDataProvider->getPageData(); + try { + $pageData = $this->pageDataProvider->getPageData(); + } catch (UnprocessableEntityHttpException $e) { + // when serializing for mercure, we do not need the path header + return $object; + } + if (!$pageData) { return $object; } diff --git a/src/Serializer/Normalizer/MetadataNormalizer.php b/src/Serializer/Normalizer/MetadataNormalizer.php index 8713884c..f1040edd 100644 --- a/src/Serializer/Normalizer/MetadataNormalizer.php +++ b/src/Serializer/Normalizer/MetadataNormalizer.php @@ -13,7 +13,7 @@ namespace Silverback\ApiComponentsBundle\Serializer\Normalizer; -use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataInterface; +use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataProvider; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessor; @@ -33,7 +33,7 @@ class MetadataNormalizer implements NormalizerInterface, CacheableSupportsMethod private PropertyAccessor $propertyAccessor; - public function __construct(private string $metadataKey, private ResourceMetadataInterface $resourceMetadata) + public function __construct(private string $metadataKey, private readonly ResourceMetadataProvider $resourceMetadataProvider) { $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); } @@ -48,7 +48,7 @@ public function supportsNormalization($data, $format = null, array $context = [] if (!\is_object($data)) { return false; } - if (!isset($context[self::ALREADY_CALLED])) { + if (!$this->resourceMetadataProvider->resourceMetadataExists($data)) { return false; } try { @@ -56,9 +56,11 @@ public function supportsNormalization($data, $format = null, array $context = [] } catch (NoSuchPropertyException $e) { return false; } + if (!isset($context[self::ALREADY_CALLED])) { + return true; + } - return !\in_array($id, $context[self::ALREADY_CALLED], true) && - $this->resourceMetadata->isInit(); + return !\in_array($id, $context[self::ALREADY_CALLED], true); } public function normalize($object, $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null @@ -71,13 +73,14 @@ public function normalize($object, $format = null, array $context = []): float|a } else { $metadataContext['groups'] = ['cwa_resource:metadata']; } - $metadata = $this->resourceMetadata->getResourceMetadata(); - $metadataContext['resource_class'] = $metadata ? \get_class($metadata) : null; - $metadata = $this->normalizer->normalize($metadata, $format, $metadataContext); + $resourceMetadata = $this->resourceMetadataProvider->findResourceMetadata($object); + $metadataContext['resource_class'] = \get_class($resourceMetadata); + + $normalizedResourceMetadata = $this->normalizer->normalize($resourceMetadata, $format, $metadataContext); $data = $this->normalizer->normalize($object, $format, $context); - $data[$this->metadataKey] = empty($metadata) ? null : $metadata; + $data[$this->metadataKey] = empty($normalizedResourceMetadata) ? null : $normalizedResourceMetadata; return $data; } diff --git a/src/Serializer/Normalizer/PageDataNormalizer.php b/src/Serializer/Normalizer/PageDataNormalizer.php index 6284345a..1fa0e17b 100644 --- a/src/Serializer/Normalizer/PageDataNormalizer.php +++ b/src/Serializer/Normalizer/PageDataNormalizer.php @@ -15,7 +15,7 @@ use Silverback\ApiComponentsBundle\Entity\Core\AbstractPageData; use Silverback\ApiComponentsBundle\Metadata\Factory\PageDataMetadataFactoryInterface; -use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataInterface; +use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataProvider; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessor; @@ -36,8 +36,8 @@ final class PageDataNormalizer implements NormalizerInterface, CacheableSupports private PropertyAccessor $propertyAccessor; public function __construct( - private PageDataMetadataFactoryInterface $pageDataMetadataFactory, - private ResourceMetadataInterface $resourceMetadata + private readonly PageDataMetadataFactoryInterface $pageDataMetadataFactory, + private readonly ResourceMetadataProvider $resourceMetadataProvider ) { $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); } @@ -46,7 +46,9 @@ public function normalize($object, $format = null, array $context = []): float|a { $context[self::ALREADY_CALLED][] = $this->propertyAccessor->getValue($object, 'id'); $metadata = $this->pageDataMetadataFactory->create(\get_class($object)); - $this->resourceMetadata->setPageDataMetadata($metadata); + + $resourceMetadata = $this->resourceMetadataProvider->findResourceMetadata($object); + $resourceMetadata->setPageDataMetadata($metadata); return $this->normalizer->normalize($object, $format, $context); } diff --git a/src/Serializer/Normalizer/PersistedNormalizer.php b/src/Serializer/Normalizer/PersistedNormalizer.php index 7c481f69..aea81c70 100644 --- a/src/Serializer/Normalizer/PersistedNormalizer.php +++ b/src/Serializer/Normalizer/PersistedNormalizer.php @@ -16,7 +16,7 @@ use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Util\ClassInfoTrait; use Doctrine\ORM\EntityManagerInterface; -use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataInterface; +use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataProvider; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessor; @@ -41,7 +41,7 @@ class PersistedNormalizer implements NormalizerInterface, CacheableSupportsMetho public function __construct( private EntityManagerInterface $entityManager, private ResourceClassResolverInterface $resourceClassResolver, - private ResourceMetadataInterface $resourceMetadata + private ResourceMetadataProvider $resourceMetadataProvider ) { $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); } @@ -49,7 +49,9 @@ public function __construct( public function normalize($object, $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null { $context[self::ALREADY_CALLED][] = $this->propertyAccessor->getValue($object, 'id'); - $this->resourceMetadata->setPersisted($this->entityManager->contains($object)); + + $resourceMetadata = $this->resourceMetadataProvider->findResourceMetadata($object); + $resourceMetadata->setPersisted($this->entityManager->contains($object)); return $this->normalizer->normalize($object, $format, $context); } diff --git a/src/Serializer/Normalizer/PublishableNormalizer.php b/src/Serializer/Normalizer/PublishableNormalizer.php index d630915c..cbc57234 100644 --- a/src/Serializer/Normalizer/PublishableNormalizer.php +++ b/src/Serializer/Normalizer/PublishableNormalizer.php @@ -20,12 +20,13 @@ use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager; use Silverback\ApiComponentsBundle\Annotation\Publishable; -use Silverback\ApiComponentsBundle\EventListener\Doctrine\PurgeHttpCacheListener; +use Silverback\ApiComponentsBundle\Event\ResourceChangedEvent; use Silverback\ApiComponentsBundle\Exception\InvalidArgumentException; use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker; use Silverback\ApiComponentsBundle\Helper\Uploadable\UploadableFileManager; -use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataInterface; +use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataProvider; use Silverback\ApiComponentsBundle\Validator\PublishableValidator; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -55,14 +56,14 @@ final class PublishableNormalizer implements NormalizerInterface, CacheableSuppo private PropertyAccessor $propertyAccessor; public function __construct( - private PublishableStatusChecker $publishableStatusChecker, - private ManagerRegistry $registry, - private RequestStack $requestStack, - private ValidatorInterface $validator, - private IriConverterInterface $iriConverter, - private UploadableFileManager $uploadableFileManager, - private ResourceMetadataInterface $resourceMetadata, - private ?PurgeHttpCacheListener $purgeHttpCacheListener = null + private readonly PublishableStatusChecker $publishableStatusChecker, + private readonly ManagerRegistry $registry, + private readonly RequestStack $requestStack, + private readonly ValidatorInterface $validator, + private readonly IriConverterInterface $iriConverter, + private readonly UploadableFileManager $uploadableFileManager, + private readonly ResourceMetadataProvider $resourceMetadataProvider, + private readonly EventDispatcherInterface $eventDispatcher ) { $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); } @@ -76,7 +77,9 @@ public function normalize($object, $format = null, array $context = []): float|a } $isPublished = $this->publishableStatusChecker->isActivePublishedAt($object); - $this->resourceMetadata->setPublishable($isPublished); + + $resourceMetadata = $this->resourceMetadataProvider->findResourceMetadata($object); + $resourceMetadata->setPublishable($isPublished); $type = \get_class($object); $configuration = $this->publishableStatusChecker->getAnnotationReader()->getConfiguration($type); @@ -90,7 +93,7 @@ public function normalize($object, $format = null, array $context = []): float|a // using static name 'publishedAt' for predictable API and easy metadata object instead of dynamic $configuration->fieldName if ($publishedAtDateTime) { - $this->resourceMetadata->setPublishable($isPublished, $publishedAtDateTime); + $resourceMetadata->setPublishable($isPublished, $publishedAtDateTime); } if (\is_object($assocObject = $classMetadata->getFieldValue($object, $configuration->associationName))) { @@ -104,7 +107,7 @@ public function normalize($object, $format = null, array $context = []): float|a try { $this->validator->validate($object, [PublishableValidator::PUBLISHED_KEY => true]); } catch (ValidationException $exception) { - $this->resourceMetadata->setViolationList($exception->getConstraintViolationList()); + $resourceMetadata->setViolationList($exception->getConstraintViolationList()); } } @@ -169,7 +172,6 @@ public function denormalize($data, $type, $format = null, array $context = []): // Any field has been modified: create a draft $draft = $this->createDraft($object, $configuration, $type); - $context[AbstractNormalizer::OBJECT_TO_POPULATE] = $draft; return $this->denormalizer->denormalize($data, $type, $format, $context); @@ -236,9 +238,8 @@ public function createDraft(object $object, Publishable $configuration, string $ $em->persist($draft); // Clear the cache of the published resource because it should now also return an associated draft - if ($this->purgeHttpCacheListener) { - $this->purgeHttpCacheListener->addTagsFor($object); - } + $event = new ResourceChangedEvent($object, 'updated'); + $this->eventDispatcher->dispatch($event); return $draft; } diff --git a/src/Serializer/Normalizer/UploadableNormalizer.php b/src/Serializer/Normalizer/UploadableNormalizer.php index 4b93596c..879e0af1 100644 --- a/src/Serializer/Normalizer/UploadableNormalizer.php +++ b/src/Serializer/Normalizer/UploadableNormalizer.php @@ -20,7 +20,7 @@ use Silverback\ApiComponentsBundle\Helper\Uploadable\UploadableFileManager; use Silverback\ApiComponentsBundle\Model\Uploadable\DataUriFile; use Silverback\ApiComponentsBundle\Model\Uploadable\UploadedDataUriFile; -use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataInterface; +use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataProvider; use Silverback\ApiComponentsBundle\Utility\ClassMetadataTrait; use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; @@ -47,7 +47,6 @@ final class UploadableNormalizer implements CacheableSupportsMethodInterface, De use NormalizerAwareTrait; private const ALREADY_CALLED = 'UPLOADABLE_NORMALIZER_ALREADY_CALLED'; - public const UPLOADABLE_TO_DELETE = 'cwa_uploadable_to_delete'; private PropertyAccessor $propertyAccessor; @@ -55,8 +54,8 @@ public function __construct( private MediaObjectFactory $mediaObjectFactory, private UploadableAttributeReader $annotationReader, private UploadableFileManager $uploadableFileManager, - private ResourceMetadataInterface $resourceMetadata, ManagerRegistry $registry, + private ResourceMetadataProvider $resourceMetadataProvider ) { $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); $this->initRegistry($registry); @@ -143,7 +142,9 @@ public function normalize($object, $format = null, array $context = []): float|a 'skip_null_values' => $context['skip_null_values'] ?? false, ] ); - $this->resourceMetadata->setMediaObjects($mediaObjects); + + $resourceMetadata = $this->resourceMetadataProvider->findResourceMetadata($object); + $resourceMetadata->setMediaObjects($mediaObjects); } $fieldConfigurations = $this->annotationReader->getConfiguredProperties($object, true); diff --git a/src/Serializer/ResourceMetadata/ResourceMetadata.php b/src/Serializer/ResourceMetadata/ResourceMetadata.php index c504a82b..87e8e486 100644 --- a/src/Serializer/ResourceMetadata/ResourceMetadata.php +++ b/src/Serializer/ResourceMetadata/ResourceMetadata.php @@ -19,12 +19,18 @@ class ResourceMetadata implements ResourceMetadataInterface { + // for page data #[Groups('cwa_resource:metadata')] private ?PageDataMetadata $pageDataMetadata = null; + // for component position #[Groups('cwa_resource:metadata')] private ?string $staticComponent = null; + // for component position + #[Groups('cwa_resource:metadata')] + private ?string $pageDataPath = null; + #[Groups('cwa_resource:metadata')] private ?bool $collection = null; @@ -40,11 +46,6 @@ class ResourceMetadata implements ResourceMetadataInterface #[Groups('cwa_resource:metadata')] private ?array $mediaObjects = null; - public function isInit(): bool - { - return true; - } - public function getResourceMetadata(): ?ResourceMetadataInterface { return $this; @@ -70,6 +71,16 @@ public function setStaticComponent(string $staticComponentIri): void $this->staticComponent = $staticComponentIri; } + public function getPageDataPath(): ?string + { + return $this->pageDataPath; + } + + public function setPageDataPath(?string $pageDataPath): void + { + $this->pageDataPath = $pageDataPath; + } + public function getCollection(): ?bool { return $this->collection; diff --git a/src/Serializer/ResourceMetadata/ResourceMetadataBuilder.php b/src/Serializer/ResourceMetadata/ResourceMetadataBuilder.php deleted file mode 100644 index e4240381..00000000 --- a/src/Serializer/ResourceMetadata/ResourceMetadataBuilder.php +++ /dev/null @@ -1,122 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace Silverback\ApiComponentsBundle\Serializer\ResourceMetadata; - -use Silverback\ApiComponentsBundle\Metadata\PageDataMetadata; -use Symfony\Component\Validator\ConstraintViolationListInterface; - -class ResourceMetadataBuilder implements ResourceMetadataInterface -{ - public ?ResourceMetadata $resourceMetadata = null; - - public function isInit(): bool - { - return null !== $this->resourceMetadata; - } - - public function init(): void - { - $this->resourceMetadata = new ResourceMetadata(); - } - - public function destroy(): void - { - $this->resourceMetadata = null; - } - - public function getResourceMetadata(): ?ResourceMetadataInterface - { - return $this->resourceMetadata; - } - - public function getPageDataMetadata(): ?PageDataMetadata - { - return $this->getValue(__FUNCTION__); - } - - public function setPageDataMetadata(PageDataMetadata $pageDataMetadata): void - { - $this->setValue(__FUNCTION__, [$pageDataMetadata]); - } - - public function getStaticComponent(): ?string - { - return $this->getValue(__FUNCTION__); - } - - public function setStaticComponent(string $staticComponentIri): void - { - $this->setValue(__FUNCTION__, [$staticComponentIri]); - } - - public function getCollection(): ?bool - { - return $this->getValue(__FUNCTION__); - } - - public function setCollection(bool $collection): void - { - $this->setValue(__FUNCTION__, [$collection]); - } - - public function getPersisted(): ?bool - { - return $this->getValue(__FUNCTION__); - } - - public function setPersisted(?bool $persisted): void - { - $this->setValue(__FUNCTION__, [$persisted]); - } - - public function getPublishable(): ?ResourcePublishableMetadata - { - return $this->getValue(__FUNCTION__); - } - - public function setPublishable(bool $published, ?string $publishedAt = null): void - { - $this->setValue(__FUNCTION__, [$published, $publishedAt]); - } - - public function getViolationList(): ?ConstraintViolationListInterface - { - return $this->getValue(__FUNCTION__); - } - - public function setViolationList(?ConstraintViolationListInterface $violationList): void - { - $this->setValue(__FUNCTION__, [$violationList]); - } - - public function getMediaObjects(): ?array - { - return $this->getValue(__FUNCTION__); - } - - public function setMediaObjects(array $mediaObjects): void - { - $this->setValue(__FUNCTION__, [$mediaObjects]); - } - - private function getValue(string $methodName) - { - return $this->resourceMetadata?->{$methodName}(); - } - - private function setValue(string $methodName, array $value): void - { - $this->resourceMetadata?->{$methodName}(...$value); - } -} diff --git a/src/Serializer/ResourceMetadata/ResourceMetadataInterface.php b/src/Serializer/ResourceMetadata/ResourceMetadataInterface.php index 73ca8d7c..e31af507 100644 --- a/src/Serializer/ResourceMetadata/ResourceMetadataInterface.php +++ b/src/Serializer/ResourceMetadata/ResourceMetadataInterface.php @@ -18,8 +18,6 @@ interface ResourceMetadataInterface { - public function isInit(): bool; - public function getResourceMetadata(): ?self; public function getPageDataMetadata(): ?PageDataMetadata; diff --git a/src/Serializer/ResourceMetadata/ResourceMetadataProvider.php b/src/Serializer/ResourceMetadata/ResourceMetadataProvider.php new file mode 100644 index 00000000..5c6aa0bd --- /dev/null +++ b/src/Serializer/ResourceMetadata/ResourceMetadataProvider.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Silverback\ApiComponentsBundle\Serializer\ResourceMetadata; + +class ResourceMetadataProvider +{ + public array $metadatas = []; + + public function findResourceMetadata(object $object): ResourceMetadata + { + $hash = spl_object_id($object); + if ($this->resourceMetadataExists($object)) { + return $this->metadatas[$hash]['metadata']; + } + $this->metadatas[$hash] = [ + 'resource' => $object, + 'metadata' => new ResourceMetadata(), + ]; + + return $this->metadatas[$hash]['metadata']; + } + + public function resourceMetadataExists(object $object): bool + { + $hash = spl_object_id($object); + + return isset($this->metadatas[$hash]); + } + + public function getMetadatas(): array + { + return $this->metadatas; + } +} diff --git a/tests/Functional/TestBundle/Entity/DummyPublishableComponent.php b/tests/Functional/TestBundle/Entity/DummyPublishableComponent.php index d017f4cb..f8b64070 100644 --- a/tests/Functional/TestBundle/Entity/DummyPublishableComponent.php +++ b/tests/Functional/TestBundle/Entity/DummyPublishableComponent.php @@ -23,7 +23,7 @@ * @author Daniel West */ #[Silverback\Publishable] -#[ApiResource] +#[ApiResource(mercure: true)] #[ORM\Entity] class DummyPublishableComponent extends AbstractComponent { diff --git a/tests/Functional/TestBundle/Stub/HubStub.php b/tests/Functional/TestBundle/Stub/HubStub.php index 4b9095be..52337c97 100644 --- a/tests/Functional/TestBundle/Stub/HubStub.php +++ b/tests/Functional/TestBundle/Stub/HubStub.php @@ -34,7 +34,16 @@ public function __construct(LcobucciFactory $factory) public function publish(Update $update): string { - return 'id'; + $postData = [ + 'topic' => $update->getTopics(), + 'data' => $update->getData(), + 'private' => $update->isPrivate() ? 'on' : null, + 'id' => $update->getId(), + 'type' => $update->getType(), + 'retry' => $update->getRetry(), + ]; + + return json_encode($postData); } public function getUrl(): string diff --git a/tests/Functional/app/config/packages/mailer.yaml b/tests/Functional/app/config/packages/mailer.yaml index 56a650d8..6b3d7b3a 100644 --- a/tests/Functional/app/config/packages/mailer.yaml +++ b/tests/Functional/app/config/packages/mailer.yaml @@ -1,3 +1,4 @@ framework: mailer: dsn: '%env(MAILER_DSN)%' + message_bus: ~ diff --git a/tests/Functional/app/config/packages/mercure.yaml b/tests/Functional/app/config/packages/mercure.yaml index 8ac58d82..873291fa 100644 --- a/tests/Functional/app/config/packages/mercure.yaml +++ b/tests/Functional/app/config/packages/mercure.yaml @@ -4,5 +4,5 @@ mercure: url: 'https://internal/.well-known/mercure' public_url: 'https://internal/.well-known/mercure' jwt: - secret: 'TEST_SECRET' + secret: 'ab0a54cf2375ab2368d97166b94e108c' publish: '*' diff --git a/tests/Functional/app/config/packages/messenger.yaml b/tests/Functional/app/config/packages/messenger.yaml new file mode 100644 index 00000000..1fee5a37 --- /dev/null +++ b/tests/Functional/app/config/packages/messenger.yaml @@ -0,0 +1,10 @@ +framework: + messenger: + default_bus: messenger.bus.default + buses: + messenger.bus.default: + default_middleware: allow_no_handlers + transports: + async_priority_normal: 'in-memory://' + routing: + 'Symfony\Component\Mailer\Messenger\SendEmailMessage': async_priority_normal diff --git a/tests/Serializer/PersistedNormalizerTest.php b/tests/Serializer/PersistedNormalizerTest.php index 72ecb3f2..52cfba7f 100644 --- a/tests/Serializer/PersistedNormalizerTest.php +++ b/tests/Serializer/PersistedNormalizerTest.php @@ -18,7 +18,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Silverback\ApiComponentsBundle\Serializer\Normalizer\PersistedNormalizer; +use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadata; use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataInterface; +use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataProvider; use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\DummyComponent; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Traversable; @@ -47,7 +49,7 @@ protected function setUp(): void { $this->entityManagerMock = $this->createMock(EntityManagerInterface::class); $this->resourceClassResolverMock = $this->createMock(ResourceClassResolverInterface::class); - $this->resourceMetadataMock = $this->createMock(ResourceMetadataInterface::class); + $this->resourceMetadataMock = $this->createMock(ResourceMetadataProvider::class); $this->normalizerMock = $this->createMock(NormalizerInterface::class); $this->apiNormalizer = new PersistedNormalizer($this->entityManagerMock, $this->resourceClassResolverMock, $this->resourceMetadataMock); $this->apiNormalizer->setNormalizer($this->normalizerMock); @@ -129,12 +131,15 @@ public function test_normalization_result_entity_is_persisted(): void ->with($dummyComponent) ->willReturn(true); + $resourceMetadata = new ResourceMetadata(); $this->resourceMetadataMock ->expects(self::once()) - ->method('setPersisted') - ->with(true); + ->method('findResourceMetadata') + ->with($dummyComponent) + ->willReturn($resourceMetadata); $result = $this->apiNormalizer->normalize($dummyComponent, $format, ['default_context_param' => 'default_value', 'silverback_api_components_bundle_metadata' => ['persisted' => true]]); + self::assertTrue($resourceMetadata->getPersisted()); self::assertEquals('anything', $result); }