diff --git a/.github/scripts/stale.js b/.github/scripts/stale.js new file mode 100644 index 00000000000..d48818477d2 --- /dev/null +++ b/.github/scripts/stale.js @@ -0,0 +1,167 @@ +module.exports = async ({ github, context, core }) => { + const DAYS_UNTIL_STALE = 60; + const DAYS_UNTIL_CLOSE = 7; + const STALE_LABEL = 'stale'; + const EXEMPT_LABELS = new Set([ + 'Hacktoberfest', + 'RFC', + '⭐ EU-FOSSA Hackathon', + ]); + const EXEMPT_TYPES = new Set(['Bug', 'Feature']); + const STALE_COMMENT = [ + 'This issue has been automatically marked as stale because it has not had', + 'recent activity. It will be closed if no further activity occurs. Thank you', + 'for your contributions.', + ].join(' '); + const BOT_LOGINS = new Set(['github-actions[bot]', 'github-actions']); + + const DRY_RUN = /^(1|true|yes)$/i.test(process.env.DRY_RUN || ''); + const MAX_ACTIONS_PER_RUN = Number.parseInt(process.env.MAX_ACTIONS_PER_RUN || '25', 10); + + const { owner, repo } = context.repo; + const now = Date.now(); + const staleCutoff = new Date(now - DAYS_UNTIL_STALE * 86400000); + const closeCutoff = new Date(now - DAYS_UNTIL_CLOSE * 86400000); + + let actionsTaken = 0; + const budgetExhausted = () => actionsTaken >= MAX_ACTIONS_PER_RUN; + + async function* iterateOpenIssues() { + let cursor = null; + while (true) { + const data = await github.graphql(` + query($owner: String!, $name: String!, $cursor: String) { + repository(owner: $owner, name: $name) { + issues(first: 100, after: $cursor, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) { + pageInfo { hasNextPage endCursor } + nodes { + number + updatedAt + issueType { name } + labels(first: 50) { nodes { name } } + timelineItems(last: 100, itemTypes: [LABELED_EVENT]) { + nodes { + ... on LabeledEvent { + createdAt + label { name } + } + } + } + } + } + } + }`, { owner, name: repo, cursor }); + + const page = data.repository.issues; + for (const node of page.nodes) yield node; + if (!page.pageInfo.hasNextPage) break; + cursor = page.pageInfo.endCursor; + } + } + + async function hasNonBotActivitySince(issue_number, since) { + const events = await github.paginate( + github.rest.issues.listEventsForTimeline, + { owner, repo, issue_number, per_page: 100 }, + ); + return events.some(e => { + const ts = e.created_at || e.submitted_at; + if (!ts) return false; + if (new Date(ts) <= since) return false; + const actor = e.actor?.login || e.user?.login; + if (actor && BOT_LOGINS.has(actor)) return false; + return true; + }); + } + + function mostRecentStaleAt(issue) { + let latest = null; + for (const e of issue.timelineItems.nodes) { + if (e?.label?.name !== STALE_LABEL) continue; + const at = new Date(e.createdAt); + if (!latest || at > latest) latest = at; + } + return latest; + } + + async function addStale(issue_number) { + if (DRY_RUN) { + core.info(`DRY_RUN would stale #${issue_number}`); + return; + } + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [STALE_LABEL] }); + await github.rest.issues.createComment({ owner, repo, issue_number, body: STALE_COMMENT }); + } + + async function close(issue_number) { + if (DRY_RUN) { + core.info(`DRY_RUN would close #${issue_number}`); + return; + } + await github.rest.issues.update({ + owner, repo, issue_number, state: 'closed', state_reason: 'not_planned', + }); + } + + async function unstale(issue_number) { + if (DRY_RUN) { + core.info(`DRY_RUN would unstale #${issue_number}`); + return; + } + await github.rest.issues.removeLabel({ + owner, repo, issue_number, name: STALE_LABEL, + }).catch(err => { + if (err.status !== 404) throw err; + }); + } + + const summary = { staled: 0, closed: 0, unstaled: 0, exempt: 0, scanned: 0, skipped: 0 }; + + for await (const issue of iterateOpenIssues()) { + summary.scanned++; + const labels = new Set(issue.labels.nodes.map(l => l.name)); + const typeName = issue.issueType?.name; + const exempt = (typeName && EXEMPT_TYPES.has(typeName)) + || [...labels].some(l => EXEMPT_LABELS.has(l)); + const hasStale = labels.has(STALE_LABEL); + + if (hasStale) { + const staleAt = mostRecentStaleAt(issue); + if (!staleAt) continue; + + const interacted = await hasNonBotActivitySince(issue.number, staleAt); + if (interacted) { + if (budgetExhausted()) { summary.skipped++; continue; } + await unstale(issue.number); + summary.unstaled++; + actionsTaken++; + } else if (staleAt <= closeCutoff) { + if (budgetExhausted()) { summary.skipped++; continue; } + await close(issue.number); + summary.closed++; + actionsTaken++; + } + continue; + } + + if (exempt) { + summary.exempt++; + continue; + } + + if (new Date(issue.updatedAt) <= staleCutoff) { + if (budgetExhausted()) { summary.skipped++; continue; } + await addStale(issue.number); + summary.staled++; + actionsTaken++; + } + } + + const prefix = DRY_RUN ? 'DRY_RUN ' : ''; + core.info( + `${prefix}scanned=${summary.scanned} staled=${summary.staled} ` + + `closed=${summary.closed} unstaled=${summary.unstaled} ` + + `exempt=${summary.exempt} skipped=${summary.skipped} ` + + `budget=${MAX_ACTIONS_PER_RUN}`, + ); +}; diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 6ad93d1570f..00000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - Hacktoberfest - - bug - - enhancement - - RFC - - ⭐ EU-FOSSA Hackathon -# Label to use when marking an issue as stale -staleLabel: stale -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df8aa29a71b..04acb811585 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ permissions: env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMPOSER_ROOT_VERSION: "4.3.x-dev" + COMPOSER_ROOT_VERSION: "5.0.x-dev" # Pinned build tooling: these are installed and executed on the runner, so an # unconstrained version would run whatever the registry serves that day. PMU_VERSION: "0.3.0" @@ -41,7 +41,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -54,7 +54,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -79,7 +79,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -102,7 +102,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -115,7 +115,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -128,6 +128,50 @@ jobs: - name: Run container lint run: tests/Fixtures/app/console lint:container + upgrade-filter: + name: Upgrade Filter Codemod + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + matrix: + php: + - '8.5' + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: intl, bcmath, curl, openssl, mbstring + ini-values: memory_limit=-1 + tools: composer + coverage: none + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@v6 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: Update project dependencies + run: | + composer global require "soyuka/pmu:$PMU_VERSION" + composer global config allow-plugins.soyuka/pmu true --no-interaction + composer global link . + - name: Codemod unit tests + run: vendor/bin/phpunit src/Symfony/Tests/Bundle/Command + # The #[ApiFilter] fixtures are already migrated in the tree, so --force only re-skips the + # special cases (name conversion, service/#[ApiFilter] key overlap); it must not error. + - name: Codemod force run + run: tests/Fixtures/app/console api:upgrade-filter --force + # Guard both paths: the migrated QueryParameter fixtures and the Legacy/ #[ApiFilter] fixtures. + - name: Functional suite + run: vendor/bin/phpunit tests/Functional + phpstan: name: PHPStan (PHP ${{ matrix.php }}) runs-on: ubuntu-latest @@ -141,7 +185,7 @@ jobs: APP_DEBUG: '1' # https://github.com/phpstan/phpstan-symfony/issues/37 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # https://github.com/staabm/phpstan-todo-by#prerequisite - name: Get tags run: git fetch --tags origin @@ -157,7 +201,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -169,7 +213,7 @@ jobs: composer global link . composer require --dev doctrine/mongodb-odm-bundle - name: Cache PHPStan results - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: /tmp/phpstan key: phpstan-php${{ matrix.php }}-${{ github.sha }} @@ -206,7 +250,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -219,7 +263,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -230,7 +274,7 @@ jobs: composer global config allow-plugins.soyuka/pmu true --no-interaction composer global link . - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '20' - name: Install Redocly CLI @@ -259,7 +303,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -272,7 +316,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -295,7 +339,7 @@ jobs: continue-on-error: true - name: Upload coverage results to Codecov if: matrix.coverage - uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: token: ${{ secrets.CODECOV_TOKEN }} directory: build/logs/phpunit @@ -310,7 +354,14 @@ jobs: timeout-minutes: 20 strategy: matrix: - php: ${{ fromJSON(github.event_name == 'pull_request' && '[{"version":"8.2"},{"version":"8.5","coverage":true},{"version":"8.5","lowest":true},{"version":"8.5","minimal-changes":true}]' || '[{"version":"8.2"},{"version":"8.3"},{"version":"8.4"},{"version":"8.5","coverage":true},{"version":"8.5","lowest":true},{"version":"8.5","minimal-changes":true}]') }} + php: + - version: '8.2' + - version: '8.5' + coverage: true + - version: '8.5' + lowest: true + - version: '8.5' + minimal-changes: true component: - api-platform/doctrine-common - api-platform/doctrine-orm @@ -324,6 +375,7 @@ jobs: - api-platform/openapi - api-platform/graphql - api-platform/http-cache + - api-platform/mcp - api-platform/ramsey-uuid - api-platform/serializer - api-platform/state @@ -337,7 +389,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -368,6 +420,11 @@ jobs: run: | mkdir -p /tmp/build/logs/phpunit composer ${{matrix.component}} test -- --log-junit "/tmp/build/logs/phpunit/junit.xml" ${{ matrix.php.coverage && '--coverage-clover /tmp/build/logs/phpunit/clover.xml' || '' }}${{ matrix.php.lowest && ' --ignore-baseline' || '' }} + - name: Run ${{ matrix.component }} tests (no deprecations) + if: ${{ matrix.php.version == '8.5' && !matrix.php.lowest && !matrix.php.minimal-changes }} + run: | + cd $(composer ${{matrix.component}} --cwd) + ./vendor/bin/phpunit --fail-on-deprecation --display-deprecations - name: Upload test artifacts if: always() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 @@ -377,7 +434,7 @@ jobs: continue-on-error: true - name: Upload coverage results to Codecov if: matrix.coverage - uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: token: ${{ secrets.CODECOV_TOKEN }} directory: /tmp/build/logs/phpunit @@ -451,7 +508,7 @@ jobs: PGPASSWORD: apiplatformrocks steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup postgres run: | sudo systemctl start postgresql @@ -470,7 +527,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -507,7 +564,7 @@ jobs: DATABASE_URL: mysql://root:root@127.0.0.1/api_platform_test steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -520,7 +577,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -549,7 +606,7 @@ jobs: MONGODB_URL: mongodb://localhost:27017 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup MongoDB run: | sudo apt update @@ -571,7 +628,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -594,7 +651,7 @@ jobs: path: build/logs/phpunit continue-on-error: true - name: Upload coverage results to Codecov - uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: token: ${{ secrets.CODECOV_TOKEN }} directory: build/logs/phpunit @@ -631,7 +688,7 @@ jobs: - 1337:1337 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -644,7 +701,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -666,7 +723,7 @@ jobs: path: build/logs/phpunit continue-on-error: true - name: Upload coverage results to Codecov - uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: token: ${{ secrets.CODECOV_TOKEN }} directory: build/logs/phpunit @@ -699,7 +756,7 @@ jobs: APP_ENV: elasticsearch steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Configure sysctl limits run: | sudo swapoff -a @@ -723,7 +780,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -772,7 +829,7 @@ jobs: --health-retries 10 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -785,7 +842,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -812,7 +869,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -825,7 +882,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -851,7 +908,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -866,7 +923,7 @@ jobs: - name: Allow unstable project dependencies run: composer config minimum-stability dev - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -895,7 +952,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -915,7 +972,7 @@ jobs: - name: Force Symfony 8.1 dev run: composer require --dev --no-update --no-interaction "symfony/framework-bundle:8.1.x-dev" "symfony/json-streamer:8.1.x-dev" "symfony/serializer:8.1.x-dev" "symfony/property-info:8.1.x-dev" "symfony/type-info:8.1.x-dev" - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-symfony-edge-${{ hashFiles('**/composer.json') }} @@ -943,7 +1000,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -956,7 +1013,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -992,7 +1049,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -1005,7 +1062,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -1037,7 +1094,7 @@ jobs: continue-on-error: true - name: Upload coverage results to Codecov if: matrix.coverage - uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: token: ${{ secrets.CODECOV_TOKEN }} directory: build/logs/phpunit @@ -1057,7 +1114,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -1066,14 +1123,14 @@ jobs: extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite ini-values: memory_limit=-1 - name: Setup node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '22' - name: Get composer cache directory id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -1107,7 +1164,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -1133,7 +1190,7 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index bb6c8fd0211..8dbc26b11d8 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -12,7 +12,7 @@ jobs: if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 ref: ${{ github.event.pull_request.base.ref }} diff --git a/.github/workflows/guides.yml b/.github/workflows/guides.yml index 81789aa125d..b6d062c9d18 100644 --- a/.github/workflows/guides.yml +++ b/.github/workflows/guides.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup PHP with pre-release PECL extension uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -43,7 +43,7 @@ jobs: # pdg has no usable tag, so the branch is pinned to a commit composer global require "php-documentation-generator/php-documentation-generator:dev-main#0d8dd6222ef14338d502294016a3a263437d6b7a" - name: Cache dependencies - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0937176f95..86ac89d7ca5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: private_key: ${{ secrets.API_PLATFORM_APP_PRIVATE_KEY }} - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: token: ${{ steps.generate_token.outputs.token }} fetch-depth: 0 @@ -47,7 +47,7 @@ jobs: echo "$(pwd)" >> $GITHUB_PATH - name: Split to manyrepo - run: find src -maxdepth 3 -name composer.json -print0 | xargs -I '{}' -n 1 -0 bash subtree.sh {} ${{ github.ref }} + run: find src -maxdepth 3 -name composer.json -print0 | xargs -I '{}' -n 1 -0 bash tools/subtree.sh {} ${{ github.ref }} dispatch-distribution-update: name: Dispatch Distribution Update diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000000..be1a6096000 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,33 @@ +name: Mark stale issues + +on: + schedule: + - cron: '0 1 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Log actions without mutating issues' + type: boolean + default: true + max_actions: + description: 'Maximum mutating actions per run' + type: string + default: '25' + +permissions: + issues: write + contents: read + +jobs: + stale: + runs-on: ubuntu-latest + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} + MAX_ACTIONS_PER_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.max_actions || '25' }} + steps: + - uses: actions/checkout@v7 + - uses: actions/github-script@v9 + with: + script: | + const script = require('./.github/scripts/stale.js'); + await script({ github, context, core }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4fcb4a13e..d16b38ed4b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,103 @@ # Changelog +## v5.0.0-alpha.3 + +### Features + +* [672d48e25](https://github.com/api-platform/core/commit/672d48e2510ca08aed92958677c8f861cb36f959) feat(symfony): map UniqueConstraintViolationException to 422 by default (#8478) + +### Bug fixes + +* [78151b7d1](https://github.com/api-platform/core/commit/78151b7d19da1dfba9a37362bb23a9cb4ff4acd1) fix(mcp): return resource read results for resources (#8436) + +### Dependencies + +* The Doctrine (ORM, ODM, Common) and JSON:API components now require `api-platform/metadata` `^5.0.0-alpha.3`. Their filters instantiate `ApiPlatform\OpenApi\Model\Parameter`, which the metadata component only guards behind a `class_exists()` check from that version on, so an older metadata makes them fatal on an install without `api-platform/openapi`. + +### Notes + +* JSON-LD: `/contexts/Error` and `/contexts/ConstraintViolationList` are no longer special-cased to the base context; they are built like any other resource context, since exceptions have been resources since 3.2 (#8402). +* `SerializerContextBuilder` no longer injects `uri_variables` into the serialization context — URI variables are parsed by the serializer processor instead (#8402). + +Also contains [v4.4.0-alpha.4 changes](#v440-alpha4). + +## v5.0.0-alpha.2 + +### Breaking changes + +* [e22e74464](https://github.com/api-platform/core/commit/e22e74464e49d0dc0bd86e7407f1e19b6c5db9ca) feat!: remove deprecated APIs scheduled for 5.0 (#8367) +* [4a9a14507](https://github.com/api-platform/core/commit/4a9a14507e5ca97c85fcf8dd1e240008f000c525) feat!: remove the legacy PropertyInfo Type system, use symfony/type-info (#8364) +* [1e6d13ae1](https://github.com/api-platform/core/commit/1e6d13ae117471dd6e549cd1cc5dc8816d5804fd) feat!: core 5.0 cleanups — PropertyAwareFilterInterface::getProperties(), JSON:API status as string (#8366) + +### Features + +* [d37a75379](https://github.com/api-platform/core/commit/d37a753790f8a8e1481a118b6a8fc9a08f57e962) feat(doctrine): standalone Date/Exists filters, ComparisonFilter [between], deprecate RangeFilter (#8351) + +### Bug fixes + +* [88f458a11](https://github.com/api-platform/core/commit/88f458a1108ba2fcd052a58d7647f23dad1b186b) fix(jsonschema): drop removed getBuiltinTypes path in SchemaPropertyMetadataFactory + +## v4.4.0-alpha.4 + +### Bug fixes + +* [854c9218e](https://github.com/api-platform/core/commit/854c9218efd907b87c096a2a6e3e44c7f8c36f1a) fix(metadata): stop alerting on inline unwired filters at warmup (#8490) + +### Dependencies + +* The Doctrine (ORM, ODM, Common) and JSON:API components now require `api-platform/metadata` `^4.4.0-alpha.4`. Their filters instantiate `ApiPlatform\OpenApi\Model\Parameter`, which the metadata component only guards behind a `class_exists()` check from that version on, so an older metadata makes them fatal on an install without `api-platform/openapi`. + +Also contains [v4.3.18 changes](#v4318). + +## v4.4.0-alpha.3 + +### Bug fixes + +* [f8c217283](https://github.com/api-platform/core/commit/f8c217283659dc80985e85c5570e113e1eba6482) fix(state): correct composer "conflicts" key to "conflict" (#8400) + +### Dependencies + +* Require `symfony/*` `^7.4 || ^8.0` across all components; drop support for Symfony 6.4 and 7.0–7.3 (#8397) +* Stabilize formerly `@experimental` APIs (Elasticsearch, State parameter providers, PropertyAwareFilterInterface, Laravel); `@experimental` kept only on MCP (#8365) + +## v4.4.0-alpha.2 + +### Bug fixes + +* [b3f02f4e0](https://github.com/api-platform/core/commit/b3f02f4e08edbcb25777815c1f38920ea187a5a9) fix(laravel): require `api-platform/metadata` `^4.4@alpha` so inter-package dependencies resolve to 4.4 (fixes a broken `composer require api-platform/laravel` install where `SortFilterInterface` was missing) + +## v4.4.0-alpha.1 + +### Bug fixes + +* [9b7ace54f](https://github.com/api-platform/core/commit/9b7ace54fdef376d249243f1220d7df638f19b34) fix(graphql): build filter args from parameters (#8347) +* [a47e36c33](https://github.com/api-platform/core/commit/a47e36c33b8436abe2e52413dee3acb71e83843f) fix(state): scope ReadLinkParameterProvider to current Link's class (#7943) +* [c2909a1ff](https://github.com/api-platform/core/commit/c2909a1ff2016fb78ff81ea9f5fa97452e28fcd4) fix(mcp): fallback to sdk handler when not found (#7818) + + +### Features + +* [0fb1dc8f6](https://github.com/api-platform/core/commit/0fb1dc8f6ff5457df773299f0c7eb7071494be69) feat(symfony): api:upgrade-filter codemod + filter fixture migration (#8344) +* [2ff386bd8](https://github.com/api-platform/core/commit/2ff386bd854fbf0192d521be641fb7304b65688d) feat(symfony,laravel): `withCredentials` option to Swagger UI (#8197) +* [373b56b98](https://github.com/api-platform/core/commit/373b56b98c01ee09583714b79ab0aa0bf3232508) feat(doctrine): deprecate the extends-AbstractFilter form of Date/Range/Exists filters (#8340) +* [48bc56e9a](https://github.com/api-platform/core/commit/48bc56e9ab34532d87aafb9abd2e6c6c98768571) feat(metadata): document BackwardCompatibleFilterDescriptionTrait as public API (#8326) +* [4bf850fc8](https://github.com/api-platform/core/commit/4bf850fc8957616f94c3faf5a1abc799826c6379) feat(doctrine): add StartSearchFilter and WordStartSearchFilter (ORM + ODM) (#8328) +* [5ddf94aeb](https://github.com/api-platform/core/commit/5ddf94aeb9b560fd981bab4ddf62ad8d16641cd1) feat(jsonld): add resource-level jsonldContext for namespace prefixes (#8204) +* [6942dc0a1](https://github.com/api-platform/core/commit/6942dc0a1bc708c0f86c454a8553c25d7e9e7fff) feat(doctrine): promote OrFilter out of @experimental (#8324) +* [72b02afb0](https://github.com/api-platform/core/commit/72b02afb031d9aad0e84d2f8c230905f3a4437a9) feat(hydra): use hydra:memberAssertion instead of owl:equivalentClass (#7944) +* [75f9056d3](https://github.com/api-platform/core/commit/75f9056d32d696fdfd729ead9d4dd5e443eb6062) feat(openapi): support OpenAPI 3.2.0 (#8350) +* [8f48b9dbc](https://github.com/api-platform/core/commit/8f48b9dbc02e1dd35e02151edcab1bb0138d1ed9) feat(symfony): deprecate jsonapi.use_iri_as_id defaulting to true (#8327) +* [9179b3667](https://github.com/api-platform/core/commit/9179b366710e50085b796430e390a6a158f40e24) feat(doctrine): per-property filter map in FreeTextQueryFilter (#8257) +* [94f3c7fe8](https://github.com/api-platform/core/commit/94f3c7fe8b681dc76d7d061092916d4bd7c1b900) feat(openapi): Scalar API Reference documentation support (#7817) +* [98dc77ba7](https://github.com/api-platform/core/commit/98dc77ba734d4fb9dfd46d76885a706aed3b6405) feat(doctrine): state options repositoryMethod for query builder (#7115) +* [9b1a58fd5](https://github.com/api-platform/core/commit/9b1a58fd533839a94f7ead264645410745f104fa) feat(doctrine): deprecate the legacy SearchFilter/Boolean/Numeric/BackedEnum/OrderFilter (#8341) +* [af0a0ab6c](https://github.com/api-platform/core/commit/af0a0ab6c286b5e30160dce9f4bcc23836125dab) feat(doctrine): promote ComparisonFilter out of @experimental (#8323) +* [b0f6dbd63](https://github.com/api-platform/core/commit/b0f6dbd63b1314efadedfd3a0b35474f6f1b8cbf) feat(doctrine): add EndSearchFilter primary for ORM and ODM (#8319) +* [b2f1a5ac3](https://github.com/api-platform/core/commit/b2f1a5ac34c6b6eb71bce40a91e34c5bee63f514) feat(metadata): throwOnNotFound option (#6027) +* [c3fd6dd6b](https://github.com/api-platform/core/commit/c3fd6dd6b5dedbc96851ccc2fcc6d01ac2fc4e46) feat(doctrine): deprecate AbstractFilter base class (#8330) +* [c9e5071d9](https://github.com/api-platform/core/commit/c9e5071d973caedeb9b554f885dedbc89743b93d) feat(symfony): deprecate Symfony Security AccessDeniedException (#8318) +* [cc0ae1254](https://github.com/api-platform/core/commit/cc0ae1254acaf9742c7f899dd24a14d46c89ca6e) feat: support dynamic HTTP response status code via request attribute (#7904) + ## v4.3.18 ### Bug fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9579d92a29..5c18c931cfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -241,11 +241,13 @@ If you include code from another project, please mention it in the Pull Request This section is for maintainers. -1. Update the JavaScript dependencies by running `./update-js.sh` (always check if it works in a browser) +Maintenance scripts live in [`tools/`](tools/). GitHub Actions helper scripts live in [`.github/scripts/`](.github/scripts/). + +1. Update the JavaScript dependencies by running `./tools/update-js.sh` (always check if it works in a browser) 2. Update the `CHANGELOG.md` file (be sure to include Pull Request numbers when appropriate) we use: ```bash -bash generate-changelog.sh v4.1.11 v4.1.12 > CHANGELOG.new +bash tools/generate-changelog.sh v4.1.11 v4.1.12 > CHANGELOG.new mv CHANGELOG.new CHANGELOG.md ``` 4. Update `composer.json` `version` node and use diff --git a/composer.json b/composer.json index 0295a010325..c30213051e0 100644 --- a/composer.json +++ b/composer.json @@ -50,10 +50,10 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.4.x-dev" + "dev-main": "5.0.x-dev" }, "symfony": { - "require": "^6.4 || ^7.1 || ^8.0" + "require": "^7.4 || ^8.0" }, "pmu": { "projects": [ @@ -113,15 +113,15 @@ "psr/cache": "^1.0 || ^2.0 || ^3.0", "psr/container": "^1.0 || ^2.0", "symfony/deprecation-contracts": "^3.1", - "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", - "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", + "symfony/http-foundation": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4.9 || ^8.0.9", "symfony/translation-contracts": "^3.3", "symfony/type-info": "^7.4 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.1 || ^8.0", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, "require-dev": { @@ -157,39 +157,39 @@ "ramsey/uuid-doctrine": "^2.0", "soyuka/pmu": "^0.2.0", "soyuka/stubs-mongodb": "^1.0", - "symfony/asset": "^6.4 || ^7.0 || ^8.0", - "symfony/browser-kit": "^6.4 || ^7.0 || ^8.0", - "symfony/cache": "^6.4 || ^7.0 || ^8.0", - "symfony/config": "^6.4 || ^7.0 || ^8.0", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/css-selector": "^6.4 || ^7.0 || ^8.0", - "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", - "symfony/doctrine-bridge": "^6.4.2 || ^7.1 || ^8.0", - "symfony/dom-crawler": "^6.4 || ^7.0 || ^8.0", - "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", - "symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0", - "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", - "symfony/finder": "^6.4 || ^7.0 || ^8.0", - "symfony/form": "^6.4 || ^7.0 || ^8.0", - "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/http-client": "^6.4 || ^7.0 || ^8.0", - "symfony/intl": "^6.4 || ^7.0 || ^8.0", + "symfony/asset": "^7.4 || ^8.0", + "symfony/browser-kit": "^7.4 || ^8.0", + "symfony/cache": "^7.4 || ^8.0", + "symfony/config": "^7.4 || ^8.0", + "symfony/console": "^7.4 || ^8.0", + "symfony/css-selector": "^7.4 || ^8.0", + "symfony/dependency-injection": "^7.4 || ^8.0", + "symfony/doctrine-bridge": "^7.4 || ^8.0", + "symfony/dom-crawler": "^7.4 || ^8.0", + "symfony/error-handler": "^7.4 || ^8.0", + "symfony/event-dispatcher": "^7.4 || ^8.0", + "symfony/expression-language": "^7.4 || ^8.0", + "symfony/finder": "^7.4 || ^8.0", + "symfony/form": "^7.4 || ^8.0", + "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/http-client": "^7.4 || ^8.0", + "symfony/intl": "^7.4 || ^8.0", "symfony/json-streamer": "^7.4 || ^8.0", "symfony/maker-bundle": "^1.24", "symfony/mcp-bundle": "^0.13", "symfony/mercure-bundle": "^0.4.3|^0.5", - "symfony/messenger": "^6.4 || ^7.0 || ^8.0", + "symfony/messenger": "^7.4 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", - "symfony/routing": "^6.4 || ^7.0 || ^8.0", - "symfony/security-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/security-core": "^6.4 || ^7.0 || ^8.0", - "symfony/stopwatch": "^6.4 || ^7.0 || ^8.0", - "symfony/string": "^6.4 || ^7.0 || ^8.0", - "symfony/twig-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0", + "symfony/routing": "^7.4 || ^8.0", + "symfony/security-bundle": "^7.4 || ^8.0", + "symfony/security-core": "^7.4 || ^8.0", + "symfony/stopwatch": "^7.4 || ^8.0", + "symfony/string": "^7.4 || ^8.0", + "symfony/twig-bundle": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0", "symfony/var-exporter": "^7.4 || ^8.0", - "symfony/web-profiler-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0", + "symfony/web-profiler-bundle": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0", "twig/twig": "^1.42.3 || ^2.12 || ^3.0", "webonyx/graphql-php": "^15.0" }, diff --git a/docs/composer.json b/docs/composer.json index bbc61158231..c3625686908 100644 --- a/docs/composer.json +++ b/docs/composer.json @@ -22,7 +22,7 @@ "phpstan/phpdoc-parser": "^1.15", "symfony/framework-bundle": "^7.0", "symfony/property-access": "^7.0", - "symfony/property-info": "^7.0", + "symfony/property-info": "^7.1 || ^8.0", "symfony/runtime": "^7.0", "symfony/security-bundle": "^7.0", "symfony/type-info": "^7.3-dev", diff --git a/docs/guides/computed-field.php b/docs/guides/computed-field.php index 24b86bacee3..5eb987ccc65 100644 --- a/docs/guides/computed-field.php +++ b/docs/guides/computed-field.php @@ -1,4 +1,15 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); // --- // slug: computed-field // name: Compute a field @@ -12,6 +23,7 @@ // by modifying the SQL query (via `stateOptions`/`handleLinks`), mapping the computed value // to the entity object (via `processor`/`process`), and optionally enabling sorting on it // using a custom filter configured via `parameters`. + namespace App\Filter { use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; @@ -44,7 +56,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q */ // Defines the OpenAPI/Swagger schema for this filter parameter. // Tells API Platform documentation generators that 'sort[totalQuantity]' expects 'asc' or 'desc'. - // This also add constraint violations to the parameter that will reject any wrong values. + // This also add constraint violations to the parameter that will reject any wrong values. public function getSchema(Parameter $parameter): array { return ['type' => 'string', 'enum' => ['asc', 'desc']]; @@ -59,29 +71,28 @@ public function getDescription(string $resourceClass): array namespace App\Entity { use ApiPlatform\Doctrine\Orm\State\Options; - use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\NotExposed; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\QueryParameter; use App\Filter\SortComputedFieldFilter; + use App\Repository\CartRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; - use Doctrine\ORM\QueryBuilder; - #[ORM\Entity] + #[ORM\Entity(repositoryClass: CartRepository::class)] // Defines the GetCollection operation for Cart, including computed 'totalQuantity'. // Recipe involves: - // 1. handleLinks (modify query) - // 2. process (map result) - // 3. parameters (filters) + // 1. setup the repository method (modify query) + // 2. process (map result) + // 3. parameters (filters) #[GetCollection( normalizationContext: ['hydra_prefix' => false], paginationItemsPerPage: 3, paginationPartial: false, - // stateOptions: Uses handleLinks to modify the query *before* fetching. - stateOptions: new Options(handleLinks: [self::class, 'handleLinks']), + // stateOptions: Uses repositoryMethod to modify the query *before* fetching. See App\Repository\CartRepository. + stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'), // processor: Uses process to map the result *after* fetching, *before* serialization. processor: [self::class, 'process'], write: true, @@ -99,20 +110,6 @@ public function getDescription(string $resourceClass): array )] class Cart { - // Handles links/joins and modifications to the QueryBuilder *before* data is fetched (via stateOptions). - // Adds SQL logic (JOIN, SELECT aggregate, GROUP BY) to calculate 'totalQuantity' at the database level. - // The alias 'totalQuantity' created here is crucial for the filter and processor. - public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void - { - // Get the alias for the root entity (Cart), usually 'o'. - $rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o'; - // Generate a unique alias for the joined 'items' relation to avoid conflicts. - $itemsAlias = $queryNameGenerator->generateParameterName('items'); - $queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias) - ->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias)) - ->addGroupBy(\sprintf('%s.id', $rootAlias)); - } - // Processor function called *after* fetching data, *before* serialization. // Maps the raw 'totalQuantity' from Doctrine result onto the Cart entity's property. // Handles Doctrine's array result structure: [0 => Entity, 'alias' => computedValue]. @@ -238,6 +235,30 @@ public function setQuantity(int $quantity): self } } +namespace App\Repository { + use Doctrine\ORM\EntityRepository; + use Doctrine\ORM\QueryBuilder; + + /** + * @extends EntityRepository + */ + class CartRepository extends EntityRepository + { + // This repository method is used via stateOptions to alter the QueryBuilder *before* data is fetched. + // Adds SQL logic (JOIN, SELECT aggregate, GROUP BY) to calculate 'totalQuantity' at the database level. + // The alias 'totalQuantity' created here is crucial for the filter and processor. + public function getCartsWithTotalQuantity(): QueryBuilder + { + $queryBuilder = $this->createQueryBuilder('o'); + $queryBuilder->leftJoin('o.items', 'items') + ->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity') + ->addGroupBy('o.id'); + + return $queryBuilder; + } + } +} + namespace App\Playground { use Symfony\Component\HttpFoundation\Request; diff --git a/phpstan.neon.dist b/phpstan.neon.dist index c89e42edec1..ee3f9221f42 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -59,6 +59,7 @@ parameters: - tests/Fixtures/TestBundle/Document/ - tests/Fixtures/TestBundle/Entity/ - src/OpenApi/Factory/OpenApiFactory.php + - src/JsonLd/Serializer/ObjectNormalizer.php - message: '#is never assigned .* so it can be removed from the property type.#' paths: @@ -90,11 +91,8 @@ parameters: - "#Call to function method_exists\\(\\) with 'Symfony\\\\\\\\Component\\\\\\\\Serializer\\\\\\\\Serializer' and 'getSupportedTypes' will always evaluate to true\\.#" - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Normalizer\\\\NormalizerInterface and 'getSupportedTypes' will always evaluate to true\\.#" - "#Call to function method_exists\\(\\) with Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\|Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata and 'isChangeTrackingDef…' will always evaluate to true\\.#" + - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#" - # See https://github.com/phpstan/phpstan-symfony/issues/27 - - - message: '#^Service "[^"]+" is private.$#' - path: src # Allow extra assertions in tests: https://github.com/phpstan/phpstan-strict-rules/issues/130 diff --git a/phpunit.baseline.xml b/phpunit.baseline.xml index bb10f6ce0f9..559aaf8a570 100644 --- a/phpunit.baseline.xml +++ b/phpunit.baseline.xml @@ -48,4 +48,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Doctrine/Common/Filter/NameConverterAwareTrait.php b/src/Doctrine/Common/Filter/NameConverterAwareTrait.php new file mode 100644 index 00000000000..4b1b6bb7a87 --- /dev/null +++ b/src/Doctrine/Common/Filter/NameConverterAwareTrait.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Filter; + +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Holds an optional name converter and (de)normalizes property names through it. + * + * @author Antoine Bluchet + */ +trait NameConverterAwareTrait +{ + private ?NameConverterInterface $nameConverter = null; + + public function hasNameConverter(): bool + { + return $this->nameConverter instanceof NameConverterInterface; + } + + public function getNameConverter(): ?NameConverterInterface + { + return $this->nameConverter; + } + + public function setNameConverter(NameConverterInterface $nameConverter): void + { + $this->nameConverter = $nameConverter; + } + + protected function denormalizePropertyName(string|int $property): string + { + if (!$this->nameConverter instanceof NameConverterInterface) { + return (string) $property; + } + + return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property))); + } + + protected function normalizePropertyName(string $property): string + { + if (!$this->nameConverter instanceof NameConverterInterface) { + return $property; + } + + return implode('.', array_map($this->nameConverter->normalize(...), explode('.', $property))); + } +} diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php index 33b9fad001a..65710033fce 100644 --- a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php +++ b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php @@ -14,13 +14,7 @@ namespace ApiPlatform\Doctrine\Common\Filter; /** - * TODO: 5.x uncomment method. - * * @author Antoine Bluchet - * - * @method array|null getProperties() - * - * @experimental */ interface PropertyAwareFilterInterface { @@ -29,8 +23,8 @@ interface PropertyAwareFilterInterface */ public function setProperties(array $properties): void; - // /** - // * @return string[] - // */ - // public function getProperties(): ?array; + /** + * @return string[] + */ + public function getProperties(): ?array; } diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterTrait.php b/src/Doctrine/Common/Filter/PropertyAwareFilterTrait.php new file mode 100644 index 00000000000..970da6779e0 --- /dev/null +++ b/src/Doctrine/Common/Filter/PropertyAwareFilterTrait.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Filter; + +/** + * @author Antoine Bluchet + */ +trait PropertyAwareFilterTrait +{ + /** + * @var array|null + */ + private ?array $properties = null; + + public function getProperties(): ?array + { + return $this->properties; + } + + /** + * @param array $properties + */ + public function setProperties(array $properties): void + { + $this->properties = $properties; + } +} diff --git a/src/Doctrine/Common/ParameterExtensionTrait.php b/src/Doctrine/Common/ParameterExtensionTrait.php index 57f1ff140c0..7950b20eaa1 100644 --- a/src/Doctrine/Common/ParameterExtensionTrait.php +++ b/src/Doctrine/Common/ParameterExtensionTrait.php @@ -51,11 +51,7 @@ private function configureFilter(object $filter, Parameter $parameter): void } if ($filter instanceof PropertyAwareFilterInterface) { - $properties = []; - // Check if the filter has getProperties method (e.g., if it's an AbstractFilter) - if (method_exists($filter, 'getProperties')) { // @phpstan-ignore-line todo 5.x remove this check @see interface - $properties = $filter->getProperties() ?? []; - } + $properties = $filter->getProperties() ?? []; $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); foreach ($parameter->getProperties() ?? [$propertyKey] as $property) { diff --git a/src/Doctrine/Common/State/Options.php b/src/Doctrine/Common/State/Options.php index df42fc6cb84..a5815332498 100644 --- a/src/Doctrine/Common/State/Options.php +++ b/src/Doctrine/Common/State/Options.php @@ -22,6 +22,7 @@ class Options implements OptionsInterface */ public function __construct( protected mixed $handleLinks = null, + protected ?string $repositoryMethod = null, ) { } @@ -37,4 +38,17 @@ public function withHandleLinks(mixed $handleLinks): self return $self; } + + public function getRepositoryMethod(): ?string + { + return $this->repositoryMethod; + } + + public function withRepositoryMethod(?string $repositoryMethod): self + { + $self = clone $this; + $self->repositoryMethod = $repositoryMethod; + + return $self; + } } diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index 779cc26c7c8..15ab1faa141 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -24,8 +24,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.2.6", - "api-platform/state": "^4.2.4", + "api-platform/metadata": "^5.0.0-alpha.3", + "api-platform/state": "^5.0@alpha", "doctrine/collections": "^2.1 || ^3.0", "doctrine/common": "^3.2.2", "doctrine/persistence": "^3.2 || ^4.0" @@ -35,7 +35,7 @@ "doctrine/orm": "^2.17 || ^3.0", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "conflict": { "doctrine/persistence": "<1.3" @@ -61,13 +61,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Doctrine/Odm/Filter/AbstractFilter.php b/src/Doctrine/Odm/Filter/AbstractFilter.php index 1f897d223c3..42170f36495 100644 --- a/src/Doctrine/Odm/Filter/AbstractFilter.php +++ b/src/Doctrine/Odm/Filter/AbstractFilter.php @@ -18,7 +18,9 @@ use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\FilterInterface as MetadataFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\Persistence\ManagerRegistry; @@ -32,6 +34,8 @@ * Abstract class for easing the implementation of a filter. * * @author Alan Poulain + * + * @deprecated since API Platform 4.4, implement {@see MetadataFilterInterface} directly together with {@see BackwardCompatibleFilterDescriptionTrait} and the canonical QueryParameter-based filters (ExactFilter, PartialSearchFilter, EndSearchFilter, ComparisonFilter, OrFilter, …) instead; this class is removed in 6.0 */ abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface { diff --git a/src/Doctrine/Odm/Filter/BooleanFilter.php b/src/Doctrine/Odm/Filter/BooleanFilter.php index 855b593e0ea..0b10583de01 100644 --- a/src/Doctrine/Odm/Filter/BooleanFilter.php +++ b/src/Doctrine/Odm/Filter/BooleanFilter.php @@ -105,6 +105,8 @@ * @author Amrouche Hamza * @author Teoh Han Hui * @author Alan Poulain + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a boolean `nativeType` instead. Removed in 6.0. */ final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInterface { diff --git a/src/Doctrine/Odm/Filter/ComparisonFilter.php b/src/Doctrine/Odm/Filter/ComparisonFilter.php index 1593fc473ed..984b97a0d04 100644 --- a/src/Doctrine/Odm/Filter/ComparisonFilter.php +++ b/src/Doctrine/Odm/Filter/ComparisonFilter.php @@ -29,8 +29,6 @@ /** * Decorates an equality filter (ExactFilter) to add comparison operators (gt, gte, lt, lte). - * - * @experimental */ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface { @@ -47,6 +45,12 @@ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterI 'ne' => 'notEqual', ]; + /** + * Friendly range syntax: `?price[between]=10..100`. MongoDB has no BETWEEN keyword, so a range + * is expressed as the native `gte`/`lte` pair on the field. + */ + public const OPERATOR_BETWEEN = 'between'; + public function __construct(private readonly FilterInterface $filter) { } @@ -76,6 +80,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera continue; } + if (self::OPERATOR_BETWEEN === $operator) { + $this->applyBetween($aggregationBuilder, $resourceClass, $operation, $context, $parameter, $value); + + continue; + } + if (isset(self::OPERATORS[$operator])) { $this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, self::OPERATORS[$operator], $value); } @@ -93,6 +103,7 @@ public function getOpenApiParameters(Parameter $parameter): array new OpenApiParameter(name: "{$key}[lt]", in: $in), new OpenApiParameter(name: "{$key}[lte]", in: $in), new OpenApiParameter(name: "{$key}[ne]", in: $in), + new OpenApiParameter(name: "{$key}[between]", in: $in), ]; } @@ -111,6 +122,7 @@ public function getSchema(Parameter $parameter): array 'lt' => $innerSchema, 'lte' => $innerSchema, 'ne' => $innerSchema, + 'between' => ['type' => 'string'], ], ]; } @@ -133,4 +145,25 @@ private function applyOperator(Builder $aggregationBuilder, string $resourceClas $context['match'] = $newContext['match']; } } + + /** + * @param array $context + * + * @param-out array $context + */ + private function applyBetween(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation, array &$context, Parameter $parameter, mixed $value): void + { + if (!\is_string($value)) { + return; + } + + $bounds = explode('..', $value, 2); + if (2 !== \count($bounds) || !is_numeric($bounds[0]) || !is_numeric($bounds[1])) { + return; + } + + // MongoDB range = native gte/lte pair (coerce bounds to numbers) + $this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, 'gte', $bounds[0] + 0); + $this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, 'lte', $bounds[1] + 0); + } } diff --git a/src/Doctrine/Odm/Filter/DateFilter.php b/src/Doctrine/Odm/Filter/DateFilter.php index 7bc5451d172..97294a7d860 100644 --- a/src/Doctrine/Odm/Filter/DateFilter.php +++ b/src/Doctrine/Odm/Filter/DateFilter.php @@ -15,6 +15,13 @@ use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterTrait; +use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; @@ -23,6 +30,10 @@ use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\Persistence\ManagerRegistry; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * The date filter allows to filter a collection by date intervals. @@ -121,17 +132,58 @@ * @author Théo FIDRY * @author Alan Poulain */ -final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class DateFilter implements DateFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface { use DateFilterTrait; + use ManagerRegistryAwareTrait; + use MongoDbOdmPropertyHelperTrait; + use NameConverterAwareTrait; + use PropertyAwareFilterTrait; public const DOCTRINE_DATE_TYPES = [ 'date' => true, 'date_immutable' => true, ]; + private LoggerInterface $logger; + + /** + * @param array|null $properties + */ + public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null) + { + $this->managerRegistry = $managerRegistry; + $this->logger = $logger ?? new NullLogger(); + $this->properties = $properties; + $this->nameConverter = $nameConverter; + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + foreach ($context['filters'] ?? [] as $property => $value) { + $this->filterProperty($this->denormalizePropertyName($property), $value, $aggregationBuilder, $resourceClass, $operation, $context); + } + } + + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + + protected function isPropertyEnabled(string $property, string $resourceClass): bool + { + if (null === $this->properties) { + // to ensure sanity, nested properties must still be explicitly enabled + return !$this->isPropertyNested($property, $resourceClass); + } + + return \array_key_exists($property, $this->properties); + } + /** - * {@inheritdoc} + * @param array $context + * + * @param-out array $context */ protected function filterProperty(string $property, mixed $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { diff --git a/src/Doctrine/Odm/Filter/EndSearchFilter.php b/src/Doctrine/Odm/Filter/EndSearchFilter.php new file mode 100644 index 00000000000..f798dd636bf --- /dev/null +++ b/src/Doctrine/Odm/Filter/EndSearchFilter.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use MongoDB\BSON\Regex; + +/** + * Filters the collection by the end of a string property, using a regular expression anchored at the end. + */ +final class EndSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = true) + { + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $values = $parameter->getValue(); + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); + $operator = $context['operator'] ?? 'addAnd'; + + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context); + + if (!is_iterable($values)) { + $escapedValue = preg_quote($values, '/'); + $match->{$operator}( + $aggregationBuilder->matchExpr()->field($matchField)->equals(new Regex($escapedValue.'$', $this->caseSensitive ? '' : 'i')) + ); + + return; + } + + $or = $aggregationBuilder->matchExpr(); + foreach ($values as $value) { + $escapedValue = preg_quote($value, '/'); + + $or->addOr( + $aggregationBuilder->matchExpr() + ->field($matchField) + ->equals(new Regex($escapedValue.'$', $this->caseSensitive ? '' : 'i')) + ); + } + + $match->{$operator}($or); + } +} diff --git a/src/Doctrine/Odm/Filter/ExistsFilter.php b/src/Doctrine/Odm/Filter/ExistsFilter.php index 452df1e9d85..72b8a66135b 100644 --- a/src/Doctrine/Odm/Filter/ExistsFilter.php +++ b/src/Doctrine/Odm/Filter/ExistsFilter.php @@ -15,7 +15,14 @@ use ApiPlatform\Doctrine\Common\Filter\ExistsFilterInterface; use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterTrait; use ApiPlatform\Doctrine\Common\Filter\PropertyPlaceholderOpenApiParameterTrait; +use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait; use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; @@ -24,6 +31,7 @@ use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\Persistence\ManagerRegistry; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -111,16 +119,42 @@ * @author Teoh Han Hui * @author Alan Poulain */ -final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class ExistsFilter implements ExistsFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface { use ExistsFilterTrait; + use ManagerRegistryAwareTrait; + use MongoDbOdmPropertyHelperTrait; + use NameConverterAwareTrait; + use PropertyAwareFilterTrait; use PropertyPlaceholderOpenApiParameterTrait; + private LoggerInterface $logger; + + /** + * @param array|null $properties + */ public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) { - parent::__construct($managerRegistry, $logger, $properties, $nameConverter); - + $this->managerRegistry = $managerRegistry; + $this->logger = $logger ?? new NullLogger(); $this->existsParameterName = $existsParameterName; + $this->properties = $properties; + $this->nameConverter = $nameConverter; + } + + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + + protected function isPropertyEnabled(string $property, string $resourceClass): bool + { + if (null === $this->properties) { + // to ensure sanity, nested properties must still be explicitly enabled + return !$this->isPropertyNested($property, $resourceClass); + } + + return \array_key_exists($property, $this->properties); } /** @@ -141,7 +175,9 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera } /** - * {@inheritdoc} + * @param array $context + * + * @param-out array $context */ protected function filterProperty(string $property, mixed $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { diff --git a/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php index b4f545e8f83..4262dca5e45 100644 --- a/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php +++ b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php @@ -28,24 +28,51 @@ final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAware use ManagerRegistryAwareTrait; /** - * @param list $properties an array of properties, defaults to `parameter->getProperties()` + * @param FilterInterface|array $filter a filter applied to every property, + * or a map of `property => filter` to use a + * dedicated filter per property + * @param list|null $properties an array of properties, defaults to + * the map keys when `$filter` is a map, + * otherwise to `parameter->getProperties()` */ - public function __construct(private readonly FilterInterface $filter, private readonly ?array $properties = null) + public function __construct(private readonly FilterInterface|array $filter, private readonly ?array $properties = null) { } public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { - if ($this->filter instanceof ManagerRegistryAwareInterface) { - $this->filter->setManagerRegistry($this->getManagerRegistry()); - } + $filterMap = \is_array($this->filter) ? $this->filter : null; + + if (null === $filterMap) { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } - if ($this->filter instanceof LoggerAwareInterface) { - $this->filter->setLogger($this->getLogger()); + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } } $parameter = $context['parameter']; - foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $properties = $this->properties ?? (null !== $filterMap ? array_keys($filterMap) : $parameter->getProperties()) ?? []; + + foreach ($properties as $property) { + $filter = null !== $filterMap ? ($filterMap[$property] ?? null) : $this->filter; + + if (null === $filter) { + continue; + } + + if (null !== $filterMap) { + if ($filter instanceof ManagerRegistryAwareInterface) { + $filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($filter instanceof LoggerAwareInterface) { + $filter->setLogger($this->getLogger()); + } + } + $subParameter = $parameter->withProperty($property); $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; @@ -57,7 +84,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera ]); $newContext = ['parameter' => $subParameter, 'match' => $context['match'] ?? $aggregationBuilder->match()->expr()] + $context; - $this->filter->apply( + $filter->apply( $aggregationBuilder, $resourceClass, $operation, diff --git a/src/Doctrine/Odm/Filter/NumericFilter.php b/src/Doctrine/Odm/Filter/NumericFilter.php index c6122e4705e..f5411a0bc4d 100644 --- a/src/Doctrine/Odm/Filter/NumericFilter.php +++ b/src/Doctrine/Odm/Filter/NumericFilter.php @@ -105,6 +105,8 @@ * @author Amrouche Hamza * @author Teoh Han Hui * @author Alan Poulain + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a numeric `nativeType` (int/float) instead. Removed in 6.0. */ final class NumericFilter extends AbstractFilter implements JsonSchemaFilterInterface { diff --git a/src/Doctrine/Odm/Filter/OrderFilter.php b/src/Doctrine/Odm/Filter/OrderFilter.php index c518cf6ca4d..0ba287de25f 100644 --- a/src/Doctrine/Odm/Filter/OrderFilter.php +++ b/src/Doctrine/Odm/Filter/OrderFilter.php @@ -199,6 +199,8 @@ * @author Kévin Dunglas * @author Théo FIDRY * @author Alan Poulain + * + * @deprecated since API Platform 4.4: use {@see SortFilter} instead. Removed in 6.0. */ final class OrderFilter extends AbstractFilter implements OrderFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { diff --git a/src/Doctrine/Odm/Filter/RangeFilter.php b/src/Doctrine/Odm/Filter/RangeFilter.php index 0356b29b6fa..195aacd7b66 100644 --- a/src/Doctrine/Odm/Filter/RangeFilter.php +++ b/src/Doctrine/Odm/Filter/RangeFilter.php @@ -107,6 +107,8 @@ * * @author Lee Siong Chan * @author Alan Poulain + * + * @deprecated since API Platform 4.4: use {@see ComparisonFilter} instead, which now covers the full range syntax (`[gt]`/`[gte]`/`[lt]`/`[lte]` and `[between]=X..Y`). This filter is removed in 6.0; the upgrade codemod rewrites it to a QueryParameter declared with `ComparisonFilter`. */ final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface { diff --git a/src/Doctrine/Odm/Filter/SearchFilter.php b/src/Doctrine/Odm/Filter/SearchFilter.php index 8d597836fa3..ec070e33fd5 100644 --- a/src/Doctrine/Odm/Filter/SearchFilter.php +++ b/src/Doctrine/Odm/Filter/SearchFilter.php @@ -133,6 +133,8 @@ * * @author Kévin Dunglas * @author Alan Poulain + * + * @deprecated since API Platform 4.4: use the per-strategy QueryParameter-based filters instead — {@see ExactFilter} (`exact`), {@see PartialSearchFilter} (`partial`), {@see StartSearchFilter} (`start`), {@see EndSearchFilter} (`end`); for relation properties matched by IRI use {@see IriFilter}. Removed in 6.0. */ final class SearchFilter extends AbstractFilter implements SearchFilterInterface { diff --git a/src/Doctrine/Odm/Filter/SortFilter.php b/src/Doctrine/Odm/Filter/SortFilter.php index abadd4926cc..4acad603409 100644 --- a/src/Doctrine/Odm/Filter/SortFilter.php +++ b/src/Doctrine/Odm/Filter/SortFilter.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\SortFilterInterface; use Doctrine\ODM\MongoDB\Aggregation\Builder; /** @@ -33,7 +34,7 @@ * * @author Antoine Bluchet */ -final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface, SortFilterInterface { use BackwardCompatibleFilterDescriptionTrait; use NestedPropertyHelperTrait; diff --git a/src/Doctrine/Odm/Filter/StartSearchFilter.php b/src/Doctrine/Odm/Filter/StartSearchFilter.php new file mode 100644 index 00000000000..bbec450837d --- /dev/null +++ b/src/Doctrine/Odm/Filter/StartSearchFilter.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use MongoDB\BSON\Regex; + +/** + * Filters the collection by the beginning of a string property, using a regular expression anchored at the start. + */ +final class StartSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = true) + { + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $values = $parameter->getValue(); + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); + $operator = $context['operator'] ?? 'addAnd'; + + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context); + + if (!is_iterable($values)) { + $escapedValue = preg_quote($values, '/'); + $match->{$operator}( + $aggregationBuilder->matchExpr()->field($matchField)->equals(new Regex('^'.$escapedValue, $this->caseSensitive ? '' : 'i')) + ); + + return; + } + + $or = $aggregationBuilder->matchExpr(); + foreach ($values as $value) { + $escapedValue = preg_quote($value, '/'); + + $or->addOr( + $aggregationBuilder->matchExpr() + ->field($matchField) + ->equals(new Regex('^'.$escapedValue, $this->caseSensitive ? '' : 'i')) + ); + } + + $match->{$operator}($or); + } +} diff --git a/src/Doctrine/Odm/Filter/WordStartSearchFilter.php b/src/Doctrine/Odm/Filter/WordStartSearchFilter.php new file mode 100644 index 00000000000..3ece9a2cdee --- /dev/null +++ b/src/Doctrine/Odm/Filter/WordStartSearchFilter.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use MongoDB\BSON\Regex; + +/** + * Filters the collection by a word boundary prefix, matching documents that contain a word starting with the value, + * using a regular expression anchored at the start of the string or at a word boundary. + */ +final class WordStartSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = true) + { + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $values = $parameter->getValue(); + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); + $operator = $context['operator'] ?? 'addAnd'; + + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context); + + if (!is_iterable($values)) { + $match->{$operator}( + $aggregationBuilder->matchExpr()->field($matchField)->equals($this->createRegex($values)) + ); + + return; + } + + $or = $aggregationBuilder->matchExpr(); + foreach ($values as $value) { + $or->addOr( + $aggregationBuilder->matchExpr() + ->field($matchField) + ->equals($this->createRegex($value)) + ); + } + + $match->{$operator}($or); + } + + private function createRegex(string $value): Regex + { + $escapedValue = preg_quote($value, '/'); + + return new Regex('(^'.$escapedValue.'|\s'.$escapedValue.')', $this->caseSensitive ? '' : 'i'); + } +} diff --git a/src/Doctrine/Odm/State/CollectionProvider.php b/src/Doctrine/Odm/State/CollectionProvider.php index 6c68b663f3e..e28d2ca7ef1 100644 --- a/src/Doctrine/Odm/State/CollectionProvider.php +++ b/src/Doctrine/Odm/State/CollectionProvider.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\StateOptionsTrait; +use Doctrine\ODM\MongoDB\Aggregation\Builder as AggregationBuilder; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\Persistence\ManagerRegistry; @@ -57,7 +58,19 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new RuntimeException(\sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class)); } - $aggregationBuilder = $repository->createAggregationBuilder(); + if ($method = $this->getStateOptionsRepositoryMethod($operation)) { + if (!method_exists($repository, $method)) { + throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method)); + } + + $aggregationBuilder = $repository->{$method}(); + + if (!$aggregationBuilder instanceof AggregationBuilder) { + throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, AggregationBuilder::class)); + } + } else { + $aggregationBuilder = $repository->createAggregationBuilder(); + } if ($handleLinks = $this->getLinksHandler($operation)) { $handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context); diff --git a/src/Doctrine/Odm/State/ItemProvider.php b/src/Doctrine/Odm/State/ItemProvider.php index 50fc50f6253..95d78dd2262 100644 --- a/src/Doctrine/Odm/State/ItemProvider.php +++ b/src/Doctrine/Odm/State/ItemProvider.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\StateOptionsTrait; +use Doctrine\ODM\MongoDB\Aggregation\Builder as AggregationBuilder; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\Persistence\ManagerRegistry; @@ -71,7 +72,19 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new RuntimeException(\sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class)); } - $aggregationBuilder = $repository->createAggregationBuilder(); + if ($method = $this->getStateOptionsRepositoryMethod($operation)) { + if (!method_exists($repository, $method)) { + throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method)); + } + + $aggregationBuilder = $repository->{$method}(); + + if (!$aggregationBuilder instanceof AggregationBuilder) { + throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, AggregationBuilder::class)); + } + } else { + $aggregationBuilder = $repository->createAggregationBuilder(); + } if ($handleLinks = $this->getLinksHandler($operation)) { $handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context); diff --git a/src/Doctrine/Odm/State/Options.php b/src/Doctrine/Odm/State/Options.php index 459d6bc49ec..00650e5de32 100644 --- a/src/Doctrine/Odm/State/Options.php +++ b/src/Doctrine/Odm/State/Options.php @@ -26,8 +26,9 @@ class Options extends CommonOptions implements OptionsInterface public function __construct( protected ?string $documentClass = null, mixed $handleLinks = null, + ?string $repositoryMethod = null, ) { - parent::__construct(handleLinks: $handleLinks); + parent::__construct(handleLinks: $handleLinks, repositoryMethod: $repositoryMethod); } public function getDocumentClass(): ?string diff --git a/src/Doctrine/Odm/Tests/Filter/EndSearchFilterTest.php b/src/Doctrine/Odm/Tests/Filter/EndSearchFilterTest.php new file mode 100644 index 00000000000..88b7eab45d2 --- /dev/null +++ b/src/Doctrine/Odm/Tests/Filter/EndSearchFilterTest.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Tests\Filter; + +use ApiPlatform\Doctrine\Odm\Filter\EndSearchFilter; +use ApiPlatform\Doctrine\Odm\Tests\DoctrineMongoDbOdmTestCase; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\Dummy; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\RelatedDummy; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\DocumentManager; +use MongoDB\BSON\Regex; +use PHPUnit\Framework\TestCase; + +class EndSearchFilterTest extends TestCase +{ + private DocumentManager $manager; + + protected function setUp(): void + { + $this->manager = DoctrineMongoDbOdmTestCase::createTestDocumentManager(); + } + + public function testEndSearchSimpleProperty(): void + { + $filter = new EndSearchFilter(); + + $parameter = new QueryParameter(property: 'name', key: 'name'); + $parameter->setValue('foo'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['name' => 'foo'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + // The filter populates $context['match'] with the match expression (no pipeline stage added) + $this->assertArrayHasKey('match', $context); + $this->assertEquals( + ['$and' => [['name' => new Regex('foo$', '')]]], + $context['match']->getQuery() + ); + $this->assertNoPipelineStages($aggregationBuilder); + } + + public function testEndSearchCaseInsensitive(): void + { + $filter = new EndSearchFilter(caseSensitive: false); + + $parameter = new QueryParameter(property: 'name', key: 'name'); + $parameter->setValue('foo'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['name' => 'foo'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + $this->assertEquals( + ['$and' => [['name' => new Regex('foo$', 'i')]]], + $context['match']->getQuery() + ); + } + + public function testEndSearchNestedProperty(): void + { + $filter = new EndSearchFilter(); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + 'odm_segments' => [ + [ + 'type' => 'reference', + 'target_document' => RelatedDummy::class, + 'is_owning_side' => true, + 'mapped_by' => null, + ], + ], + ]], + ], + ); + $parameter->setValue('bar'); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['relatedDummy.name' => 'bar'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + // Nested property adds $lookup + $unwind stages + $this->assertCount(2, $pipeline); + $this->assertArrayHasKey('$lookup', $pipeline[0]); + $this->assertArrayHasKey('$unwind', $pipeline[1]); + + // The match expression is populated for the parameter extension to commit + $this->assertArrayHasKey('match', $context); + } + + private function assertNoPipelineStages(Builder $aggregationBuilder): void + { + try { + $pipeline = $aggregationBuilder->getPipeline(); + $this->assertEmpty($pipeline); + } catch (\OutOfRangeException) { + // No stages added — expected for simple property filters + } + } +} diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index af514fb7349..a633f30de8c 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -25,26 +25,26 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.2.23", - "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.16", - "api-platform/state": "^4.2.4", + "api-platform/doctrine-common": "^5.0.0-alpha.2", + "api-platform/metadata": "^5.0.0-alpha.3", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "doctrine/mongodb-odm": "^2.10", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/property-info": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11 || ^3.1", "doctrine/mongodb-odm-bundle": "^5.0", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/cache": "^6.4 || ^7.0 || ^8.0", - "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + "symfony/cache": "^7.4 || ^8.0", + "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -62,13 +62,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php index e07597bea1e..55a31104c21 100644 --- a/src/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractFilter.php @@ -19,7 +19,9 @@ use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\FilterInterface as MetadataFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -27,6 +29,9 @@ use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +/** + * @deprecated since API Platform 4.4, implement {@see MetadataFilterInterface} directly together with {@see BackwardCompatibleFilterDescriptionTrait} and the canonical QueryParameter-based filters (ExactFilter, PartialSearchFilter, EndSearchFilter, ComparisonFilter, OrFilter, …) instead; this class is removed in 6.0 + */ abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface { use OrmPropertyHelperTrait; diff --git a/src/Doctrine/Orm/Filter/BackedEnumFilter.php b/src/Doctrine/Orm/Filter/BackedEnumFilter.php index ab39bd0d405..29e30c60580 100644 --- a/src/Doctrine/Orm/Filter/BackedEnumFilter.php +++ b/src/Doctrine/Orm/Filter/BackedEnumFilter.php @@ -107,6 +107,8 @@ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?status=published`. * * @author Rémi Marseille + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a backed-enum `nativeType` instead. Removed in 6.0. */ final class BackedEnumFilter extends AbstractFilter { diff --git a/src/Doctrine/Orm/Filter/BooleanFilter.php b/src/Doctrine/Orm/Filter/BooleanFilter.php index 9fda1f507d8..785bb4e76b1 100644 --- a/src/Doctrine/Orm/Filter/BooleanFilter.php +++ b/src/Doctrine/Orm/Filter/BooleanFilter.php @@ -107,6 +107,8 @@ * * @author Amrouche Hamza * @author Teoh Han Hui + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a boolean `nativeType` instead. Removed in 6.0. */ final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInterface { diff --git a/src/Doctrine/Orm/Filter/ComparisonFilter.php b/src/Doctrine/Orm/Filter/ComparisonFilter.php index 474973d72fd..785136ef884 100644 --- a/src/Doctrine/Orm/Filter/ComparisonFilter.php +++ b/src/Doctrine/Orm/Filter/ComparisonFilter.php @@ -30,8 +30,6 @@ /** * Decorates an equality filter (ExactFilter, UuidFilter) to add comparison operators (gt, gte, lt, lte). - * - * @experimental */ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface { @@ -50,6 +48,12 @@ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterI public const ALLOWED_DQL_OPERATORS = ['=', '>', '>=', '<', '<=', '!=', '<>']; + /** + * Friendly range syntax: `?price[between]=10..100`. Translates to a single BETWEEN clause + * (or `=` when both bounds are equal), letting the SQL optimizer treat it as a bounded range. + */ + public const OPERATOR_BETWEEN = 'between'; + public function __construct(private readonly FilterInterface $filter) { } @@ -76,6 +80,12 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q continue; } + if (self::OPERATOR_BETWEEN === $operator) { + $this->applyBetween($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context, $parameter, $value); + + continue; + } + if (isset(self::OPERATORS[$operator])) { $this->applyOperator($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context, $parameter, self::OPERATORS[$operator], $value); } @@ -93,6 +103,7 @@ public function getOpenApiParameters(Parameter $parameter): array new OpenApiParameter(name: "{$key}[lt]", in: $in), new OpenApiParameter(name: "{$key}[lte]", in: $in), new OpenApiParameter(name: "{$key}[ne]", in: $in), + new OpenApiParameter(name: "{$key}[between]", in: $in), ]; } @@ -111,6 +122,7 @@ public function getSchema(Parameter $parameter): array 'lt' => $innerSchema, 'lte' => $innerSchema, 'ne' => $innerSchema, + 'between' => ['type' => 'string'], ], ]; } @@ -133,4 +145,29 @@ private function applyOperator(QueryBuilder $queryBuilder, QueryNameGeneratorInt ['operator' => $operator, 'parameter' => $subParameter] + $context ); } + + /** + * @param array $context + */ + private function applyBetween(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation, array $context, Parameter $parameter, mixed $value): void + { + if (!\is_string($value)) { + return; + } + + $bounds = explode('..', $value, 2); + if (2 !== \count($bounds) || !is_numeric($bounds[0]) || !is_numeric($bounds[1])) { + return; + } + + // coerce to int|float so the bound is bound as a number, not a string + $subParameter = (clone $parameter)->setValue([$bounds[0] + 0, $bounds[1] + 0]); + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['operator' => self::OPERATOR_BETWEEN, 'parameter' => $subParameter] + $context + ); + } } diff --git a/src/Doctrine/Orm/Filter/DateFilter.php b/src/Doctrine/Orm/Filter/DateFilter.php index b7f7af569b8..1632645eabc 100644 --- a/src/Doctrine/Orm/Filter/DateFilter.php +++ b/src/Doctrine/Orm/Filter/DateFilter.php @@ -15,6 +15,13 @@ use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\JsonSchemaFilterInterface; @@ -25,8 +32,15 @@ use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\DBAL\Types\Type as DBALType; use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\Mapping\ClassMetadata as LegacyClassMetadata; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * The date filter allows to filter a collection by date intervals. @@ -125,9 +139,12 @@ * @author Kévin Dunglas * @author Théo FIDRY */ -final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class DateFilter implements DateFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface { use DateFilterTrait; + use ManagerRegistryAwareTrait; + use NameConverterAwareTrait; + use PropertyAwareFilterTrait; public const DOCTRINE_DATE_TYPES = [ Types::DATE_MUTABLE => true, @@ -140,6 +157,85 @@ final class DateFilter extends AbstractFilter implements DateFilterInterface, Js Types::TIME_IMMUTABLE => true, ]; + private LoggerInterface $logger; + + /** + * Resolved from the QueryBuilder in apply(); metadata is read from it so the active filter path + * never touches the injected ManagerRegistry (kept only for the deprecated getDescription() and + * for BC injection through ManagerRegistryAwareInterface). + */ + private ?EntityManagerInterface $entityManager = null; + + /** + * @param array|null $properties + */ + public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null) + { + $this->managerRegistry = $managerRegistry; + $this->logger = $logger ?? new NullLogger(); + $this->properties = $properties; + $this->nameConverter = $nameConverter; + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $this->entityManager = $queryBuilder->getEntityManager(); + + foreach ($context['filters'] ?? [] as $property => $value) { + $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + } + + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + + protected function isPropertyEnabled(string $property, string $resourceClass): bool + { + if (null === $this->properties) { + // to ensure sanity, nested properties must still be explicitly enabled + return !$this->isPropertyNested($property, $resourceClass); + } + + return \array_key_exists($property, $this->properties); + } + + protected function getClassMetadata(string $resourceClass): LegacyClassMetadata + { + if ($this->entityManager instanceof EntityManagerInterface) { + return $this->entityManager->getClassMetadata($resourceClass); + } + + // Legacy getDescription() runs without a QueryBuilder: fall back to the injected registry. + if ($this->hasManagerRegistry() && ($manager = $this->getManagerRegistry()->getManagerForClass($resourceClass))) { + return $manager->getClassMetadata($resourceClass); + } + + return new ClassMetadata($resourceClass); + } + + /** + * @return array{0: string, 1: string, 2: string[]} + */ + protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $joinType): array + { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $parentAlias = $rootAlias; + $alias = null; + + foreach ($propertyParts['associations'] as $association) { + $alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $parentAlias, $association, $joinType); + $parentAlias = $alias; + } + + if (null === $alias) { + throw new InvalidArgumentException(\sprintf('Cannot add joins for property "%s" - property is not nested.', $property)); + } + + return [$alias, $propertyParts['field'], $propertyParts['associations']]; + } + /** * {@inheritdoc} */ diff --git a/src/Doctrine/Orm/Filter/EndSearchFilter.php b/src/Doctrine/Orm/Filter/EndSearchFilter.php new file mode 100644 index 00000000000..242e79152df --- /dev/null +++ b/src/Doctrine/Orm/Filter/EndSearchFilter.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * Filters the collection by the end of a string property, using a `LIKE '%value'` clause. + */ +final class EndSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = false) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + $field = $alias.'.'.$property; + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($values)); + + $likeExpression = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpression); + + return; + } + + $likeExpressions = []; + foreach ($values as $val) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions[] = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($val)); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$likeExpressions) + ); + } + + private function formatLikeValue(string $value): string + { + return '%'.addcslashes($value, '\\%_'); + } +} diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index f7fbf4cf54b..15714c225c9 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -51,6 +51,24 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + if (ComparisonFilter::OPERATOR_BETWEEN === ($context['operator'] ?? null)) { + $whereClause = $context['whereClause'] ?? 'andWhere'; + + // equal bounds collapse to an equality so the optimizer skips the range scan + if ($value[0] === $value[1]) { + $queryBuilder->{$whereClause}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)) + ->setParameter($parameterName, $value[0]); + + return; + } + + $queryBuilder->{$whereClause}(\sprintf('%1$s.%2$s BETWEEN :%3$s_1 AND :%3$s_2', $alias, $property, $parameterName)) + ->setParameter($parameterName.'_1', $value[0]) + ->setParameter($parameterName.'_2', $value[1]); + + return; + } + if (\is_array($value)) { $queryBuilder ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName)); diff --git a/src/Doctrine/Orm/Filter/ExistsFilter.php b/src/Doctrine/Orm/Filter/ExistsFilter.php index b9f23857fb3..28fad250c6e 100644 --- a/src/Doctrine/Orm/Filter/ExistsFilter.php +++ b/src/Doctrine/Orm/Filter/ExistsFilter.php @@ -15,13 +15,21 @@ use ApiPlatform\Doctrine\Common\Filter\ExistsFilterInterface; use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterTrait; use ApiPlatform\Doctrine\Common\Filter\PropertyPlaceholderOpenApiParameterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping; @@ -29,7 +37,9 @@ use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\Mapping\ClassMetadata as LegacyClassMetadata; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -117,16 +127,83 @@ * * @author Teoh Han Hui */ -final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class ExistsFilter implements ExistsFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface { use ExistsFilterTrait; + use ManagerRegistryAwareTrait; + use NameConverterAwareTrait; + use PropertyAwareFilterTrait; use PropertyPlaceholderOpenApiParameterTrait; + private LoggerInterface $logger; + + /** + * Resolved from the QueryBuilder in apply(); metadata is read from it so the active filter path + * never touches the injected ManagerRegistry (kept only for the deprecated getDescription() and + * for BC injection through ManagerRegistryAwareInterface). + */ + private ?EntityManagerInterface $entityManager = null; + + /** + * @param array|null $properties + */ public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) { - parent::__construct($managerRegistry, $logger, $properties, $nameConverter); - + $this->managerRegistry = $managerRegistry; + $this->logger = $logger ?? new NullLogger(); $this->existsParameterName = $existsParameterName; + $this->properties = $properties; + $this->nameConverter = $nameConverter; + } + + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + + protected function isPropertyEnabled(string $property, string $resourceClass): bool + { + if (null === $this->properties) { + // to ensure sanity, nested properties must still be explicitly enabled + return !$this->isPropertyNested($property, $resourceClass); + } + + return \array_key_exists($property, $this->properties); + } + + protected function getClassMetadata(string $resourceClass): LegacyClassMetadata + { + if ($this->entityManager instanceof EntityManagerInterface) { + return $this->entityManager->getClassMetadata($resourceClass); + } + + // Legacy getDescription() runs without a QueryBuilder: fall back to the injected registry. + if ($this->hasManagerRegistry() && ($manager = $this->getManagerRegistry()->getManagerForClass($resourceClass))) { + return $manager->getClassMetadata($resourceClass); + } + + return new ClassMetadata($resourceClass); + } + + /** + * @return array{0: string, 1: string, 2: string[]} + */ + protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $joinType): array + { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $parentAlias = $rootAlias; + $alias = null; + + foreach ($propertyParts['associations'] as $association) { + $alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $parentAlias, $association, $joinType); + $parentAlias = $alias; + } + + if (null === $alias) { + throw new InvalidArgumentException(\sprintf('Cannot add joins for property "%s" - property is not nested.', $property)); + } + + return [$alias, $propertyParts['field'], $propertyParts['associations']]; } /** @@ -134,6 +211,7 @@ public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInt */ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { + $this->entityManager = $queryBuilder->getEntityManager(); $parameter = $context['parameter'] ?? null; $propertyKey = $parameter?->getProperty(); diff --git a/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php index a269ac41137..5f4b76e96a4 100644 --- a/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php +++ b/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php @@ -31,27 +31,54 @@ final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAware use ManagerRegistryAwareTrait; /** - * @param list $properties an array of properties, defaults to `parameter->getProperties()` + * @param FilterInterface|array $filter a filter applied to every property, + * or a map of `property => filter` to use a + * dedicated filter per property + * @param list|null $properties an array of properties, defaults to + * the map keys when `$filter` is a map, + * otherwise to `parameter->getProperties()` */ - public function __construct(private readonly FilterInterface $filter, private readonly ?array $properties = null) + public function __construct(private readonly FilterInterface|array $filter, private readonly ?array $properties = null) { } public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - if ($this->filter instanceof ManagerRegistryAwareInterface) { - $this->filter->setManagerRegistry($this->getManagerRegistry()); - } + $filterMap = \is_array($this->filter) ? $this->filter : null; + + if (null === $filterMap) { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } - if ($this->filter instanceof LoggerAwareInterface) { - $this->filter->setLogger($this->getLogger()); + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } } $parameter = $context['parameter']; $qb = clone $queryBuilder; $qb->resetDQLPart('where'); $qb->setParameters(new ArrayCollection()); - foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $properties = $this->properties ?? (null !== $filterMap ? array_keys($filterMap) : $parameter->getProperties()) ?? []; + + foreach ($properties as $property) { + $filter = null !== $filterMap ? ($filterMap[$property] ?? null) : $this->filter; + + if (null === $filter) { + continue; + } + + if (null !== $filterMap) { + if ($filter instanceof ManagerRegistryAwareInterface) { + $filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($filter instanceof LoggerAwareInterface) { + $filter->setLogger($this->getLogger()); + } + } + $subParameter = $parameter->withProperty($property); $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; @@ -62,7 +89,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q : [], ]); - $this->filter->apply( + $filter->apply( $qb, $queryNameGenerator, $resourceClass, diff --git a/src/Doctrine/Orm/Filter/NumericFilter.php b/src/Doctrine/Orm/Filter/NumericFilter.php index 661e96a5a9d..a02b6c64ebf 100644 --- a/src/Doctrine/Orm/Filter/NumericFilter.php +++ b/src/Doctrine/Orm/Filter/NumericFilter.php @@ -107,6 +107,8 @@ * * @author Amrouche Hamza * @author Teoh Han Hui + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a numeric `nativeType` (int/float) instead. Removed in 6.0. */ final class NumericFilter extends AbstractFilter implements JsonSchemaFilterInterface { diff --git a/src/Doctrine/Orm/Filter/OrFilter.php b/src/Doctrine/Orm/Filter/OrFilter.php index d8e020221a7..792eeb5e541 100644 --- a/src/Doctrine/Orm/Filter/OrFilter.php +++ b/src/Doctrine/Orm/Filter/OrFilter.php @@ -26,8 +26,6 @@ /** * @author Vincent Amstoutz - * - * @experimental */ final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface { diff --git a/src/Doctrine/Orm/Filter/OrderFilter.php b/src/Doctrine/Orm/Filter/OrderFilter.php index 54de60267cd..bdf59d6b283 100644 --- a/src/Doctrine/Orm/Filter/OrderFilter.php +++ b/src/Doctrine/Orm/Filter/OrderFilter.php @@ -198,6 +198,8 @@ * * @author Kévin Dunglas * @author Théo FIDRY + * + * @deprecated since API Platform 4.4: use {@see SortFilter} instead. Removed in 6.0. */ final class OrderFilter extends AbstractFilter implements OrderFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { diff --git a/src/Doctrine/Orm/Filter/RangeFilter.php b/src/Doctrine/Orm/Filter/RangeFilter.php index 240010077e3..7410b9d9941 100644 --- a/src/Doctrine/Orm/Filter/RangeFilter.php +++ b/src/Doctrine/Orm/Filter/RangeFilter.php @@ -108,6 +108,8 @@ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?price[between]=12.99..15.99`. * * @author Lee Siong Chan + * + * @deprecated since API Platform 4.4: use {@see ComparisonFilter} instead, which now covers the full range syntax (`[gt]`/`[gte]`/`[lt]`/`[lte]` and `[between]=X..Y`). This filter is removed in 6.0; the upgrade codemod rewrites it to a QueryParameter declared with `ComparisonFilter`. */ final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface { diff --git a/src/Doctrine/Orm/Filter/SearchFilter.php b/src/Doctrine/Orm/Filter/SearchFilter.php index a93a8c197c9..398d76c82db 100644 --- a/src/Doctrine/Orm/Filter/SearchFilter.php +++ b/src/Doctrine/Orm/Filter/SearchFilter.php @@ -132,6 +132,8 @@ * * * @author Kévin Dunglas + * + * @deprecated since API Platform 4.4: use the per-strategy QueryParameter-based filters instead — {@see ExactFilter} (`exact`), {@see PartialSearchFilter} (`partial`), {@see StartSearchFilter} (`start`), {@see EndSearchFilter} (`end`); for relation properties matched by IRI use {@see IriFilter}. Removed in 6.0. */ final class SearchFilter extends AbstractFilter implements SearchFilterInterface { diff --git a/src/Doctrine/Orm/Filter/SortFilter.php b/src/Doctrine/Orm/Filter/SortFilter.php index c1bf315bdfe..d77da9131e2 100644 --- a/src/Doctrine/Orm/Filter/SortFilter.php +++ b/src/Doctrine/Orm/Filter/SortFilter.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\SortFilterInterface; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -35,7 +36,7 @@ * * @author Antoine Bluchet */ -final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface, SortFilterInterface { use BackwardCompatibleFilterDescriptionTrait; use NestedPropertyHelperTrait; diff --git a/src/Doctrine/Orm/Filter/StartSearchFilter.php b/src/Doctrine/Orm/Filter/StartSearchFilter.php new file mode 100644 index 00000000000..4cca0231e01 --- /dev/null +++ b/src/Doctrine/Orm/Filter/StartSearchFilter.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * Filters the collection by the beginning of a string property, using a `LIKE 'value%'` clause. + */ +final class StartSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = false) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + $field = $alias.'.'.$property; + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($values)); + + $likeExpression = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpression); + + return; + } + + $likeExpressions = []; + foreach ($values as $val) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions[] = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($val)); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$likeExpressions) + ); + } + + private function formatLikeValue(string $value): string + { + return addcslashes($value, '\\%_').'%'; + } +} diff --git a/src/Doctrine/Orm/Filter/WordStartSearchFilter.php b/src/Doctrine/Orm/Filter/WordStartSearchFilter.php new file mode 100644 index 00000000000..e872b2d5204 --- /dev/null +++ b/src/Doctrine/Orm/Filter/WordStartSearchFilter.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * Filters the collection by a word boundary prefix, matching fields that contain a word starting with the value, + * using a `LIKE 'value%' OR LIKE '% value%'` clause. + */ +final class WordStartSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = false) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + $field = $alias.'.'.$property; + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $values = [$values]; + } + + $expressions = []; + foreach ($values as $val) { + $startName = $queryNameGenerator->generateParameterName($property); + $wordName = $queryNameGenerator->generateParameterName($property); + + $expressions[] = $queryBuilder->expr()->orX( + $this->createLikeExpression($field, $startName), + $this->createLikeExpression($field, $wordName), + ); + + $queryBuilder->setParameter($startName, $this->formatStartValue($val)); + $queryBuilder->setParameter($wordName, $this->formatWordValue($val)); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$expressions) + ); + } + + private function createLikeExpression(string $field, string $parameterName): string + { + return $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + } + + private function formatStartValue(string $value): string + { + return addcslashes($value, '\\%_').'%'; + } + + private function formatWordValue(string $value): string + { + return '% '.addcslashes($value, '\\%_').'%'; + } +} diff --git a/src/Doctrine/Orm/State/CollectionProvider.php b/src/Doctrine/Orm/State/CollectionProvider.php index 3815447a8d3..5bc181abb57 100644 --- a/src/Doctrine/Orm/State/CollectionProvider.php +++ b/src/Doctrine/Orm/State/CollectionProvider.php @@ -23,6 +23,7 @@ use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\StateOptionsTrait; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; @@ -56,11 +57,25 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $manager = $this->managerRegistry->getManagerForClass($entityClass); $repository = $manager->getRepository($entityClass); - if (!method_exists($repository, 'createQueryBuilder')) { - throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + + if ($method = $this->getStateOptionsRepositoryMethod($operation)) { + if (!method_exists($repository, $method)) { + throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method)); + } + + $queryBuilder = $repository->{$method}(); + + if (!$queryBuilder instanceof QueryBuilder) { + throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, QueryBuilder::class)); + } + } else { + if (!method_exists($repository, 'createQueryBuilder')) { + throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + } + + $queryBuilder = $repository->createQueryBuilder('o'); } - $queryBuilder = $repository->createQueryBuilder('o'); $queryNameGenerator = new QueryNameGenerator(); if ($handleLinks = $this->getLinksHandler($operation)) { diff --git a/src/Doctrine/Orm/State/ItemProvider.php b/src/Doctrine/Orm/State/ItemProvider.php index 369996d4c9c..9b1e317d064 100644 --- a/src/Doctrine/Orm/State/ItemProvider.php +++ b/src/Doctrine/Orm/State/ItemProvider.php @@ -23,6 +23,7 @@ use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\StateOptionsTrait; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; @@ -64,11 +65,25 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $repository = $manager->getRepository($entityClass); - if (!method_exists($repository, 'createQueryBuilder')) { - throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + + if ($method = $this->getStateOptionsRepositoryMethod($operation)) { + if (!method_exists($repository, $method)) { + throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method)); + } + + $queryBuilder = $repository->{$method}(); + + if (!$queryBuilder instanceof QueryBuilder) { + throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, QueryBuilder::class)); + } + } else { + if (!method_exists($repository, 'createQueryBuilder')) { + throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + } + + $queryBuilder = $repository->createQueryBuilder('o'); } - $queryBuilder = $repository->createQueryBuilder('o'); $queryNameGenerator = new QueryNameGenerator(); if ($handleLinks = $this->getLinksHandler($operation)) { diff --git a/src/Doctrine/Orm/State/Options.php b/src/Doctrine/Orm/State/Options.php index 3a9a46c3825..00f791da563 100644 --- a/src/Doctrine/Orm/State/Options.php +++ b/src/Doctrine/Orm/State/Options.php @@ -26,8 +26,9 @@ class Options extends CommonOptions implements OptionsInterface public function __construct( protected ?string $entityClass = null, mixed $handleLinks = null, + ?string $repositoryMethod = null, ) { - parent::__construct(handleLinks: $handleLinks); + parent::__construct(handleLinks: $handleLinks, repositoryMethod: $repositoryMethod); } public function getEntityClass(): ?string diff --git a/src/Doctrine/Orm/Tests/Metadata/Resource/UnwiredLegacyFilterParameterTest.php b/src/Doctrine/Orm/Tests/Metadata/Resource/UnwiredLegacyFilterParameterTest.php new file mode 100644 index 00000000000..d6bf62583b3 --- /dev/null +++ b/src/Doctrine/Orm/Tests/Metadata/Resource/UnwiredLegacyFilterParameterTest.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Orm\Tests\Metadata\Resource; + +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Metadata\Resource\Factory\AttributesResourceMetadataCollectionFactory; +use ApiPlatform\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +final class UnwiredLegacyFilterParameterTest extends TestCase +{ + public function testUnwiredRegistryAwareFilterIsNotLogged(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('alert'); + $logger->expects($this->never())->method('debug'); + + $this->createFactory($logger)->create(ResourceWithInlineDateFilter::class); + } + + public function testFilterFailureUnrelatedToTheManagerRegistryStillAlerts(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('alert'); + $logger->expects($this->never())->method('debug'); + + $this->createFactory($logger)->create(ResourceWithThrowingFilter::class); + } + + private function createFactory(LoggerInterface $logger): ParameterResourceMetadataCollectionFactory + { + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection(['id', 'updatedAt'])); + + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturn(new ApiProperty(readable: true)); + + return new ParameterResourceMetadataCollectionFactory( + $propertyNameCollectionFactory, + $propertyMetadataFactory, + new AttributesResourceMetadataCollectionFactory(), + null, + null, + $logger, + ); + } +} + +final class ThrowingFilter implements FilterInterface +{ + public function getDescription(string $resourceClass): array + { + throw new RuntimeException('Something unexpected happened.'); + } +} + +#[ApiResource(operations: [ + new GetCollection(parameters: ['updatedAt' => new QueryParameter(filter: new DateFilter())]), +])] +final class ResourceWithInlineDateFilter +{ + public $id; + public $updatedAt; +} + +#[ApiResource(operations: [ + new GetCollection(parameters: ['updatedAt' => new QueryParameter(filter: new ThrowingFilter())]), +])] +final class ResourceWithThrowingFilter +{ + public $id; + public $updatedAt; +} diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 3f78c59b5f1..bb768304b74 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -24,10 +24,10 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.2.23", - "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.16", - "api-platform/state": "^4.2.4", + "api-platform/doctrine-common": "^5.0.0-alpha.2", + "api-platform/metadata": "^5.0.0-alpha.3", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "composer/semver": "^3.4", "doctrine/orm": "^2.17 || ^3.0.1" }, @@ -37,15 +37,15 @@ "phpunit/phpunit": "^11.5 || ^12.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", - "symfony/cache": "^6.4 || ^7.0 || ^8.0", - "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/cache": "^7.4 || ^8.0", + "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -63,13 +63,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Documentation/composer.json b/src/Documentation/composer.json index 0b46b329fa5..b92f2ce6f81 100644 --- a/src/Documentation/composer.json +++ b/src/Documentation/composer.json @@ -21,17 +21,17 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3" + "api-platform/metadata": "^5.0@alpha" }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Elasticsearch/Exception/IndexNotFoundException.php b/src/Elasticsearch/Exception/IndexNotFoundException.php index a92528a0deb..24c4ed9e98d 100644 --- a/src/Elasticsearch/Exception/IndexNotFoundException.php +++ b/src/Elasticsearch/Exception/IndexNotFoundException.php @@ -16,8 +16,6 @@ /** * Index not found exception. * - * @experimental - * * @author Baptiste Meyer */ final class IndexNotFoundException extends \Exception implements ExceptionInterface diff --git a/src/Elasticsearch/Exception/NonUniqueIdentifierException.php b/src/Elasticsearch/Exception/NonUniqueIdentifierException.php index 624ff936c01..9d8d7710e9f 100644 --- a/src/Elasticsearch/Exception/NonUniqueIdentifierException.php +++ b/src/Elasticsearch/Exception/NonUniqueIdentifierException.php @@ -16,8 +16,6 @@ /** * Non unique identifier exception. * - * @experimental - * * @author Baptiste Meyer */ final class NonUniqueIdentifierException extends \Exception implements ExceptionInterface diff --git a/src/Elasticsearch/Extension/AbstractFilterExtension.php b/src/Elasticsearch/Extension/AbstractFilterExtension.php index 13e82800882..ac9ec377685 100644 --- a/src/Elasticsearch/Extension/AbstractFilterExtension.php +++ b/src/Elasticsearch/Extension/AbstractFilterExtension.php @@ -19,8 +19,6 @@ /** * Abstract class for easing the implementation of a filter extension. * - * @experimental - * * @author Baptiste Meyer */ abstract class AbstractFilterExtension implements RequestBodySearchCollectionExtensionInterface diff --git a/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php b/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php index d04eeb156ab..1736ec0e3b2 100644 --- a/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php +++ b/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php @@ -20,8 +20,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-constant-score-query.html * - * @experimental - * * @author Baptiste Meyer */ final class ConstantScoreFilterExtension extends AbstractFilterExtension diff --git a/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php b/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php index 5556a16ca98..0752938e9d7 100644 --- a/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php +++ b/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php @@ -20,8 +20,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html * - * @experimental - * * @author Baptiste Meyer */ interface RequestBodySearchCollectionExtensionInterface diff --git a/src/Elasticsearch/Extension/SortExtension.php b/src/Elasticsearch/Extension/SortExtension.php index e327f7908f4..84da66a136e 100644 --- a/src/Elasticsearch/Extension/SortExtension.php +++ b/src/Elasticsearch/Extension/SortExtension.php @@ -25,8 +25,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html * - * @experimental - * * @author Baptiste Meyer */ final class SortExtension implements RequestBodySearchCollectionExtensionInterface diff --git a/src/Elasticsearch/Extension/SortFilterExtension.php b/src/Elasticsearch/Extension/SortFilterExtension.php index 84aec9efe6c..d6ef1c1a46f 100644 --- a/src/Elasticsearch/Extension/SortFilterExtension.php +++ b/src/Elasticsearch/Extension/SortFilterExtension.php @@ -20,8 +20,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html * - * @experimental - * * @author Baptiste Meyer */ final class SortFilterExtension extends AbstractFilterExtension diff --git a/src/Elasticsearch/Filter/AbstractFilter.php b/src/Elasticsearch/Filter/AbstractFilter.php index a305a57e03b..e05adabf959 100644 --- a/src/Elasticsearch/Filter/AbstractFilter.php +++ b/src/Elasticsearch/Filter/AbstractFilter.php @@ -19,8 +19,6 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -31,8 +29,6 @@ /** * Abstract class with helpers for easing the implementation of a filter. * - * @experimental - * * @author Baptiste Meyer */ abstract class AbstractFilter implements FilterInterface @@ -83,10 +79,6 @@ protected function hasProperty(string $resourceClass, string $property): bool */ protected function getMetadata(string $resourceClass, string $property): array { - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - return $this->getLegacyMetadata($resourceClass, $property); - } - $noop = [null, null, null, null]; if (!$this->hasProperty($resourceClass, $property)) { @@ -178,95 +170,4 @@ protected function getMetadata(string $resourceClass, string $property): array return [$type, $hasAssociation, $currentResourceClass, $currentProperty]; } - - protected function getLegacyMetadata(string $resourceClass, string $property): array - { - $noop = [null, null, null, null]; - - if (!$this->hasProperty($resourceClass, $property)) { - return $noop; - } - - $properties = explode('.', $property); - $totalProperties = \count($properties); - $currentResourceClass = $resourceClass; - $hasAssociation = false; - $currentProperty = null; - $type = null; - - foreach ($properties as $index => $currentProperty) { - try { - $propertyMetadata = $this->propertyMetadataFactory->create($currentResourceClass, $currentProperty); - } catch (PropertyNotFoundException) { - return $noop; - } - - $types = $propertyMetadata->getBuiltinTypes(); - - if (null === $types) { - return $noop; - } - - ++$index; - - // check each type before deciding if it's noop or not - // e.g: maybe the first type is noop, but the second is valid - $isNoop = false; - - foreach ($types as $type) { - $builtinType = $type->getBuiltinType(); - - if (LegacyType::BUILTIN_TYPE_OBJECT !== $builtinType && LegacyType::BUILTIN_TYPE_ARRAY !== $builtinType) { - if ($totalProperties === $index) { - break 2; - } - - $isNoop = true; - - continue; - } - - if ($type->isCollection() && null === $type = $type->getCollectionValueTypes()[0] ?? null) { - $isNoop = true; - - continue; - } - - if (LegacyType::BUILTIN_TYPE_ARRAY === $builtinType && LegacyType::BUILTIN_TYPE_OBJECT !== $type->getBuiltinType()) { - if ($totalProperties === $index) { - break 2; - } - - $isNoop = true; - - continue; - } - - if (null === $className = $type->getClassName()) { - $isNoop = true; - - continue; - } - - if ($isResourceClass = $this->resourceClassResolver->isResourceClass($className)) { - $currentResourceClass = $className; - } elseif ($totalProperties !== $index) { - $isNoop = true; - - continue; - } - - $hasAssociation = $totalProperties === $index && $isResourceClass; - $isNoop = false; - - break; - } - - if ($isNoop) { - return $noop; - } - } - - return [$type, $hasAssociation, $currentResourceClass, $currentProperty]; - } } diff --git a/src/Elasticsearch/Filter/AbstractSearchFilter.php b/src/Elasticsearch/Filter/AbstractSearchFilter.php index a20fe911f97..c6e90cdf776 100644 --- a/src/Elasticsearch/Filter/AbstractSearchFilter.php +++ b/src/Elasticsearch/Filter/AbstractSearchFilter.php @@ -21,7 +21,6 @@ use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; @@ -30,8 +29,6 @@ /** * Abstract class with helpers for easing the implementation of a search filter like a term filter or a match filter. * - * @experimental - * * @internal * * @author Baptiste Meyer @@ -112,27 +109,8 @@ public function getDescription(string $resourceClass): array */ abstract protected function getQuery(string $property, array $values, ?string $nestedPath): array; - protected function getPhpType(LegacyType|Type $type): string + protected function getPhpType(Type $type): string { - if ($type instanceof LegacyType) { - switch ($builtinType = $type->getBuiltinType()) { - case LegacyType::BUILTIN_TYPE_ARRAY: - case LegacyType::BUILTIN_TYPE_INT: - case LegacyType::BUILTIN_TYPE_FLOAT: - case LegacyType::BUILTIN_TYPE_BOOL: - case LegacyType::BUILTIN_TYPE_STRING: - return $builtinType; - case LegacyType::BUILTIN_TYPE_OBJECT: - if (null !== ($className = $type->getClassName()) && is_a($className, \DateTimeInterface::class, true)) { - return \DateTimeInterface::class; - } - - // no break - default: - return 'string'; - } - } - if ($type->isIdentifiedBy(TypeIdentifier::ARRAY, TypeIdentifier::INT, TypeIdentifier::FLOAT, TypeIdentifier::BOOL, TypeIdentifier::STRING)) { while ($type instanceof WrappingTypeInterface) { $type = $type->getWrappedType(); @@ -180,22 +158,8 @@ protected function getIdentifierValue(string $iri, string $property): mixed return $iri; } - protected function hasValidValues(array $values, LegacyType|Type $type): bool + protected function hasValidValues(array $values, Type $type): bool { - if ($type instanceof LegacyType) { - foreach ($values as $value) { - if ( - null !== $value - && LegacyType::BUILTIN_TYPE_INT === $type->getBuiltinType() - && false === filter_var($value, \FILTER_VALIDATE_INT) - ) { - return false; - } - } - - return true; - } - foreach ($values as $value) { if ( null !== $value diff --git a/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php b/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php index 638be2d10a8..0c390414aa6 100644 --- a/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php +++ b/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php @@ -16,8 +16,6 @@ /** * Elasticsearch filter interface for a constant score query. * - * @experimental - * * @author Baptiste Meyer */ interface ConstantScoreFilterInterface extends FilterInterface diff --git a/src/Elasticsearch/Filter/FilterInterface.php b/src/Elasticsearch/Filter/FilterInterface.php index 13d4df2a0b1..bf2bdd35ee3 100644 --- a/src/Elasticsearch/Filter/FilterInterface.php +++ b/src/Elasticsearch/Filter/FilterInterface.php @@ -19,8 +19,6 @@ /** * Elasticsearch filter interface. * - * @experimental - * * @author Baptiste Meyer */ interface FilterInterface extends BaseFilterInterface diff --git a/src/Elasticsearch/Filter/OrderFilter.php b/src/Elasticsearch/Filter/OrderFilter.php index 481de5a1fd0..d0c1a7fc0ff 100644 --- a/src/Elasticsearch/Filter/OrderFilter.php +++ b/src/Elasticsearch/Filter/OrderFilter.php @@ -105,8 +105,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html * - * @experimental - * * @author Baptiste Meyer */ final class OrderFilter extends AbstractFilter implements SortFilterInterface diff --git a/src/Elasticsearch/Filter/SortFilterInterface.php b/src/Elasticsearch/Filter/SortFilterInterface.php index 0434889c3ae..b94f6080683 100644 --- a/src/Elasticsearch/Filter/SortFilterInterface.php +++ b/src/Elasticsearch/Filter/SortFilterInterface.php @@ -16,8 +16,6 @@ /** * Elasticsearch filter interface for sorting. * - * @experimental - * * @author Baptiste Meyer */ interface SortFilterInterface extends FilterInterface diff --git a/src/Elasticsearch/Filter/TermFilter.php b/src/Elasticsearch/Filter/TermFilter.php index fba2c549c64..ff86bb0cf00 100644 --- a/src/Elasticsearch/Filter/TermFilter.php +++ b/src/Elasticsearch/Filter/TermFilter.php @@ -98,8 +98,6 @@ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-term-query.html * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-query.html * - * @experimental - * * @author Baptiste Meyer */ final class TermFilter extends AbstractSearchFilter diff --git a/src/Elasticsearch/Paginator.php b/src/Elasticsearch/Paginator.php index 2a1c6edc70e..9b6e7ee46e8 100644 --- a/src/Elasticsearch/Paginator.php +++ b/src/Elasticsearch/Paginator.php @@ -21,8 +21,6 @@ /** * Paginator for Elasticsearch. * - * @experimental - * * @author Baptiste Meyer */ final class Paginator implements \IteratorAggregate, PaginatorInterface diff --git a/src/Elasticsearch/Serializer/DocumentNormalizer.php b/src/Elasticsearch/Serializer/DocumentNormalizer.php index 189561f800b..6188b15606f 100644 --- a/src/Elasticsearch/Serializer/DocumentNormalizer.php +++ b/src/Elasticsearch/Serializer/DocumentNormalizer.php @@ -32,8 +32,6 @@ /** * Document denormalizer for Elasticsearch. * - * @experimental - * * @author Baptiste Meyer */ final class DocumentNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface diff --git a/src/Elasticsearch/Serializer/ItemNormalizer.php b/src/Elasticsearch/Serializer/ItemNormalizer.php index e3cece34f23..10a53d5af28 100644 --- a/src/Elasticsearch/Serializer/ItemNormalizer.php +++ b/src/Elasticsearch/Serializer/ItemNormalizer.php @@ -22,8 +22,6 @@ /** * Item normalizer decorator that prevents {@see \ApiPlatform\Serializer\ItemNormalizer} * from taking over for the {@see DocumentNormalizer::FORMAT} format because of priorities. - * - * @experimental */ final class ItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface { diff --git a/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php b/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php index dbf5b306e61..6ad041fa238 100644 --- a/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php +++ b/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php @@ -19,8 +19,6 @@ /** * Converts inner fields with a inner name converter. * - * @experimental - * * @author Baptiste Meyer */ final class InnerFieldsNameConverter implements NameConverterInterface diff --git a/src/Elasticsearch/Util/FieldDatatypeTrait.php b/src/Elasticsearch/Util/FieldDatatypeTrait.php index 25a0fe81bc8..c5e22643419 100644 --- a/src/Elasticsearch/Util/FieldDatatypeTrait.php +++ b/src/Elasticsearch/Util/FieldDatatypeTrait.php @@ -17,8 +17,6 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\TypeHelper; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -27,8 +25,6 @@ * * @internal * - * @experimental - * * @author Baptiste Meyer */ trait FieldDatatypeTrait @@ -68,35 +64,6 @@ private function getNestedFieldPath(string $resourceClass, string $property): ?s return null; } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - - foreach ($types as $type) { - if ( - LegacyType::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() - && null !== ($nextResourceClass = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($nextResourceClass) - ) { - $nestedPath = $this->getNestedFieldPath($nextResourceClass, implode('.', $properties)); - - return null === $nestedPath ? $nestedPath : "$currentProperty.$nestedPath"; - } - - if ( - null !== ($type = $type->getCollectionValueTypes()[0] ?? null) - && LegacyType::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() - && null !== ($className = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - $nestedPath = $this->getNestedFieldPath($className, implode('.', $properties)); - - return null === $nestedPath ? $currentProperty : "$currentProperty.$nestedPath"; - } - } - - return null; - } - $type = $propertyMetadata->getNativeType(); if (null === $type) { diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index a956d6acea0..3050b7eea27 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -24,17 +24,17 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3.12", - "api-platform/state": "^4.3", + "api-platform/metadata": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", - "symfony/cache": "^6.4 || ^7.0 || ^8.0", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0" + "symfony/cache": "^7.4 || ^8.0", + "symfony/console": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0" }, "suggest": { "opensearch-project/opensearch-php": "Required to use OpenSearch instead of Elasticsearch (^2.5)" @@ -64,13 +64,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/GraphQl/Resolver/Factory/ResolverFactory.php b/src/GraphQl/Resolver/Factory/ResolverFactory.php index 302bdea66eb..2728c4cdc2c 100644 --- a/src/GraphQl/Resolver/Factory/ResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ResolverFactory.php @@ -27,7 +27,6 @@ use ApiPlatform\State\ProviderInterface; use GraphQL\Type\Definition\ResolveInfo; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\TypeInfo\Type\CollectionType; class ResolverFactory implements ResolverFactoryInterface @@ -66,20 +65,11 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul $propertyMetadata = $rootClass ? $propertyMetadataFactory?->create($rootClass, $info->fieldName) : null; - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata?->getNativeType(); + $type = $propertyMetadata?->getNativeType(); - // Data already fetched and normalized (field or nested resource) - if ($body || null === $resourceClass || ($type && !$type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType))) { - return $body; - } - } else { - $type = $propertyMetadata?->getBuiltinTypes()[0] ?? null; - - // Data already fetched and normalized (field or nested resource) - if ($body || null === $resourceClass || ($type && !$type->isCollection())) { - return $body; - } + // Data already fetched and normalized (field or nested resource) + if ($body || null === $resourceClass || ($type && !$type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType))) { + return $body; } } diff --git a/src/GraphQl/Serializer/ItemDenormalizer.php b/src/GraphQl/Serializer/ItemDenormalizer.php new file mode 100644 index 00000000000..cd7aa0b3a1b --- /dev/null +++ b/src/GraphQl/Serializer/ItemDenormalizer.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\GraphQl\Serializer; + +use ApiPlatform\Serializer\AbstractItemNormalizer; + +/** + * Converts GraphQL inputs to objects (denormalization only). + * + * @author Kévin Dunglas + */ +final class ItemDenormalizer extends AbstractItemNormalizer +{ + public const FORMAT = 'graphql'; + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return false; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); + } + + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; + } + + protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool + { + $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); + + if (($context['api_denormalize'] ?? false) && \is_array($allowedAttributes) && false !== ($indexId = array_search('id', $allowedAttributes, true))) { + $allowedAttributes[] = '_id'; + array_splice($allowedAttributes, (int) $indexId, 1); + } + + return $allowedAttributes; + } + + protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void + { + if ('_id' === $attribute) { + $attribute = 'id'; + } + + parent::setAttributeValue($object, $attribute, $value, $format, $context); + } +} diff --git a/src/GraphQl/State/Provider/DenormalizeProvider.php b/src/GraphQl/State/Provider/DenormalizeProvider.php index 481bb8bb6b0..45743f5fc64 100644 --- a/src/GraphQl/State/Provider/DenormalizeProvider.php +++ b/src/GraphQl/State/Provider/DenormalizeProvider.php @@ -13,7 +13,7 @@ namespace ApiPlatform\GraphQl\State\Provider; -use ApiPlatform\GraphQl\Serializer\ItemNormalizer; +use ApiPlatform\GraphQl\Serializer\ItemDenormalizer; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\Operation; @@ -47,7 +47,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $denormalizationContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; } - $item = $this->denormalizer->denormalize($context['args']['input'], $operation->getClass(), ItemNormalizer::FORMAT, $denormalizationContext); + $item = $this->denormalizer->denormalize($context['args']['input'], $operation->getClass(), ItemDenormalizer::FORMAT, $denormalizationContext); if (!\is_object($item)) { throw new \UnexpectedValueException('Expected item to be an object.'); diff --git a/src/GraphQl/Tests/Serializer/ItemDenormalizerTest.php b/src/GraphQl/Tests/Serializer/ItemDenormalizerTest.php new file mode 100644 index 00000000000..d36a23f8fbf --- /dev/null +++ b/src/GraphQl/Tests/Serializer/ItemDenormalizerTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\GraphQl\Tests\Serializer; + +use ApiPlatform\GraphQl\Serializer\ItemDenormalizer; +use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +class ItemDenormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportsDenormalizationOnlyForGraphQlFormat(): void + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($denormalizer->supportsNormalization(new Dummy(), ItemDenormalizer::FORMAT)); + $this->assertTrue($denormalizer->supportsDenormalization([], Dummy::class, ItemDenormalizer::FORMAT)); + $this->assertFalse($denormalizer->supportsDenormalization([], Dummy::class, 'jsonld')); + } + + public function testDenormalize(): void + { + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withWritable(true)->withReadable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['name' => 'hello'], Dummy::class, ItemDenormalizer::FORMAT, $context)); + } +} diff --git a/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php b/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php index e528ee4e941..94ad5540266 100644 --- a/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php +++ b/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php @@ -28,7 +28,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -253,39 +252,4 @@ public function testNormalizeNoResolverData(): void 'no_resolver_data' => true, ])); } - - public function testDenormalize(): void - { - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; - - $propertyNameCollection = new PropertyNameCollection(['name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withWritable(true)->withReadable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $identifiersExtractorProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['name' => 'hello'], Dummy::class, ItemNormalizer::FORMAT, $context)); - } } diff --git a/src/GraphQl/Tests/Type/FieldsBuilderTest.php b/src/GraphQl/Tests/Type/FieldsBuilderTest.php index 268c7d56295..5bae2e0e9b4 100644 --- a/src/GraphQl/Tests/Type/FieldsBuilderTest.php +++ b/src/GraphQl/Tests/Type/FieldsBuilderTest.php @@ -485,11 +485,6 @@ public function testGetResourceObjectTypeFields(string $resourceClass, Operation }); $typeConverter = new class implements TypeConverterInterface { - public function convertType(\Symfony\Component\PropertyInfo\Type $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth): GraphQLType|string|null - { - return null; - } - public function resolveType(string $type): ?GraphQLType { return null; diff --git a/src/GraphQl/Tests/Type/TypeBuilderTest.php b/src/GraphQl/Tests/Type/TypeBuilderTest.php index ddef6ba5b4e..5143ee53564 100644 --- a/src/GraphQl/Tests/Type/TypeBuilderTest.php +++ b/src/GraphQl/Tests/Type/TypeBuilderTest.php @@ -37,14 +37,11 @@ use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type as GraphQLType; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyInfo\Type as LegacyType; -use Symfony\Component\TypeInfo\Type; /** * @author Alan Poulain @@ -608,37 +605,4 @@ public function testGetEnumType(): void 'values' => $enumValues, ]), $this->typeBuilder->getEnumType($operation)); } - - #[IgnoreDeprecations] - public function testIsCollectionLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - - $this->expectUserDeprecationMessage('Since api-platform/graphql 4.2: The "ApiPlatform\GraphQl\Type\TypeBuilder::isCollection()" method is deprecated and will be removed.'); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_BOOL))); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT))); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_RESOURCE, false, null, false))); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, null, true))); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true))); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT)))); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'className', true))); - $this->assertTrue($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, null, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'className')))); - $this->assertTrue($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'className')))); - } - - public static function typesProvider(): array - { - return [ - [Type::bool(), false], - [Type::object(), false], - [Type::resource(), false], - [Type::collection(Type::object(\Stringable::class)), false], - [Type::array(), false], - [Type::array(Type::object()), false], - [Type::collection(Type::object(\Traversable::class), Type::object(\Stringable::class)), true], - [Type::array(Type::object(\Stringable::class)), true], - ]; - } } diff --git a/src/GraphQl/Tests/Type/TypeConverterTest.php b/src/GraphQl/Tests/Type/TypeConverterTest.php index 9e601c15fd0..62f87bbdeec 100644 --- a/src/GraphQl/Tests/Type/TypeConverterTest.php +++ b/src/GraphQl/Tests/Type/TypeConverterTest.php @@ -31,12 +31,10 @@ use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type as GraphQLType; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; /** @@ -64,45 +62,6 @@ protected function setUp(): void $this->typeConverter = new TypeConverter($this->typeBuilderProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal()); } - #[IgnoreDeprecations] - public function testConvertTypeLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - - $testCases = [ - [new LegacyType(LegacyType::BUILTIN_TYPE_BOOL), false, 0, GraphQLType::boolean()], - [new LegacyType(LegacyType::BUILTIN_TYPE_INT), false, 0, GraphQLType::int()], - [new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), false, 0, GraphQLType::float()], - [new LegacyType(LegacyType::BUILTIN_TYPE_STRING), false, 0, GraphQLType::string()], - [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY), false, 0, 'Iterable'], - [new LegacyType(LegacyType::BUILTIN_TYPE_ITERABLE), false, 0, 'Iterable'], - [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, \DateTimeInterface::class), false, 0, GraphQLType::string()], - [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class), false, 0, new EnumType(['name' => 'GenderTypeEnum', 'values' => []])], - [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT), false, 0, null], - [new LegacyType(LegacyType::BUILTIN_TYPE_CALLABLE), false, 0, null], - [new LegacyType(LegacyType::BUILTIN_TYPE_NULL), false, 0, null], - [new LegacyType(LegacyType::BUILTIN_TYPE_RESOURCE), false, 0, null], - ]; - - foreach ($testCases as [$type, $input, $depth, $expectedGraphqlType]) { - /* @var LegacyType $type */ - /* @var bool $input */ - /* @var int $depth */ - /* @var GraphQLType|string|null $expectedGraphqlType */ - $this->expectUserDeprecationMessage('Since api-platform/graphql 4.2: The "ApiPlatform\GraphQl\Type\TypeConverter::convertType()" method is deprecated, use "ApiPlatform\GraphQl\Type\TypeConverter::convertPhpType()" instead.'); - - $this->typeBuilderProphecy->isCollection($type)->willReturn(false); - $this->resourceMetadataCollectionFactoryProphecy->create(Argument::type('string'))->willReturn(new ResourceMetadataCollection('resourceClass')); - $this->typeBuilderProphecy->getEnumType(Argument::type(Operation::class))->willReturn($expectedGraphqlType); - - $operation = (new Query())->withName('test'); - $graphqlType = $this->typeConverter->convertType($type, $input, $operation, 'resourceClass', 'rootClass', null, $depth); - $this->assertSame($expectedGraphqlType, $graphqlType); - } - } - #[DataProvider('convertTypeProvider')] public function testConvertType(Type $type, bool $input, int $depth, GraphQLType|string|null $expectedGraphqlType): void { @@ -132,23 +91,6 @@ public static function convertTypeProvider(): array ]; } - #[IgnoreDeprecations] - public function testConvertTypeNoGraphQlResourceMetadataLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - - $type = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'dummy'); - - $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(false); - $this->resourceMetadataCollectionFactoryProphecy->create('dummy')->shouldBeCalled()->willReturn(new ResourceMetadataCollection('dummy', [new ApiResource()])); - - $operation = (new Query())->withName('test'); - $graphqlType = $this->typeConverter->convertType($type, false, $operation, 'resourceClass', 'rootClass', null, 0); - $this->assertNull($graphqlType); - } - public function testConvertTypeNoGraphQlResourceMetadata(): void { $type = Type::object('dummy'); @@ -160,24 +102,6 @@ public function testConvertTypeNoGraphQlResourceMetadata(): void $this->assertNull($graphqlType); } - #[IgnoreDeprecations] - public function testConvertTypeNodeResourceLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $type = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'node'); - - $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(false); - $this->resourceMetadataCollectionFactoryProphecy->create('node')->shouldBeCalled()->willReturn(new ResourceMetadataCollection('node', [(new ApiResource())->withShortName('Node')->withGraphQlOperations(['test' => new Query()])])); - - $this->expectException(\UnexpectedValueException::class); - $this->expectExceptionMessage('A "Node" resource cannot be used with GraphQL because the type is already used by the Relay specification.'); - - $operation = (new Query())->withName('test'); - $this->typeConverter->convertType($type, false, $operation, 'resourceClass', 'rootClass', null, 0); - } - public function testConvertTypeNodeResource(): void { $type = Type::object('node'); @@ -191,22 +115,6 @@ public function testConvertTypeNodeResource(): void $this->typeConverter->convertPhpType($type, false, $operation, 'resourceClass', 'rootClass', null, 0); } - #[IgnoreDeprecations] - public function testConvertTypeResourceClassNotFoundLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $type = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'dummy'); - - $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(false); - $this->resourceMetadataCollectionFactoryProphecy->create('dummy')->shouldBeCalled()->willThrow(new ResourceClassNotFoundException()); - - $operation = (new Query())->withName('test'); - $graphqlType = $this->typeConverter->convertType($type, false, $operation, 'resourceClass', 'rootClass', null, 0); - $this->assertNull($graphqlType); - } - public function testConvertTypeResourceClassNotFound(): void { $type = Type::object('dummy'); @@ -218,24 +126,6 @@ public function testConvertTypeResourceClassNotFound(): void $this->assertNull($graphqlType); } - #[IgnoreDeprecations] - public function testConvertTypeResourceIriLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $type = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'dummy'); - - $graphqlResourceMetadata = new ResourceMetadataCollection('dummy', [(new ApiResource())->withGraphQlOperations(['test' => new Query()])]); - $this->resourceMetadataCollectionFactoryProphecy->create('dummy')->willReturn($graphqlResourceMetadata); - $this->typeBuilderProphecy->isCollection($type)->willReturn(false); - $this->propertyMetadataFactoryProphecy->create('rootClass', 'dummyProperty', Argument::type('array'))->shouldBeCalled()->willReturn((new ApiProperty())->withWritableLink(false)); - - $operation = (new Query())->withName('test'); - $graphqlType = $this->typeConverter->convertType($type, true, $operation, 'dummy', 'rootClass', 'dummyProperty', 1); - $this->assertSame(GraphQLType::string(), $graphqlType); - } - public function testConvertTypeResourceIri(): void { $type = Type::object('dummy'); @@ -249,27 +139,6 @@ public function testConvertTypeResourceIri(): void $this->assertSame(GraphQLType::string(), $graphqlType); } - #[IgnoreDeprecations] - public function testConvertTypeInputResourceLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $type = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'dummy'); - $operation = new Query(); - $propertyMetadata = (new ApiProperty())->withWritableLink(true); - $graphqlResourceMetadata = new ResourceMetadataCollection('dummy', [(new ApiResource())->withGraphQlOperations(['item_query' => $operation])]); - $expectedGraphqlType = new ObjectType(['name' => 'resourceObjectType', 'fields' => []]); - - $this->resourceMetadataCollectionFactoryProphecy->create('dummy')->willReturn($graphqlResourceMetadata); - $this->typeBuilderProphecy->isCollection($type)->willReturn(false); - $this->propertyMetadataFactoryProphecy->create('rootClass', 'dummyProperty', Argument::type('array'))->shouldBeCalled()->willReturn((new ApiProperty())->withWritableLink(true)); - $this->typeBuilderProphecy->getResourceObjectType($graphqlResourceMetadata, $operation, $propertyMetadata, ['input' => true, 'wrapped' => false, 'depth' => 1])->shouldBeCalled()->willReturn($expectedGraphqlType); - - $graphqlType = $this->typeConverter->convertType($type, true, $operation, 'dummy', 'rootClass', 'dummyProperty', 1); - $this->assertSame($expectedGraphqlType, $graphqlType); - } - public function testConvertTypeInputResource(): void { $type = Type::object('dummy'); @@ -286,37 +155,6 @@ public function testConvertTypeInputResource(): void $this->assertSame($expectedGraphqlType, $graphqlType); } - #[IgnoreDeprecations] - public function testConvertTypeCollectionResourceLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $fixtures = [ - [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, null, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'dummyValue')), new ObjectType(['name' => 'resourceObjectType', 'fields' => []])], - [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'dummyValue')), new ObjectType(['name' => 'resourceObjectType', 'fields' => []])], - ]; - - foreach ($fixtures as [$type, $expectedGraphqlType]) { - $collectionOperation = new QueryCollection(); - $graphqlResourceMetadata = new ResourceMetadataCollection('dummyValue', [ - (new ApiResource())->withShortName('DummyValue')->withGraphQlOperations(['collection_query' => $collectionOperation]), - ]); - - $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(true); - $this->resourceMetadataCollectionFactoryProphecy->create('dummyValue')->shouldBeCalled()->willReturn($graphqlResourceMetadata); - $this->typeBuilderProphecy->getResourceObjectType($graphqlResourceMetadata, $collectionOperation, null, [ - 'input' => false, - 'wrapped' => false, - 'depth' => 0, - ])->shouldBeCalled()->willReturn($expectedGraphqlType); - - $rootOperation = (new Query())->withName('test'); - $graphqlType = $this->typeConverter->convertType($type, false, $rootOperation, 'resourceClass', 'rootClass', null, 0); - $this->assertSame($expectedGraphqlType, $graphqlType); - } - } - #[DataProvider('convertTypeResourceProvider')] public function testConvertTypeCollectionResource(Type $type, ObjectType $expectedGraphqlType): void { @@ -345,23 +183,6 @@ public static function convertTypeResourceProvider(): array ]; } - #[IgnoreDeprecations] - public function testConvertTypeCollectionEnumLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $type = new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)); - $expectedGraphqlType = new EnumType(['name' => 'GenderTypeEnum', 'values' => []]); - $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(true); - $this->resourceMetadataCollectionFactoryProphecy->create(GenderTypeEnum::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(GenderTypeEnum::class, [])); - $this->typeBuilderProphecy->getEnumType(Argument::type(Operation::class))->willReturn($expectedGraphqlType); - - $rootOperation = (new Query())->withName('test'); - $graphqlType = $this->typeConverter->convertType($type, false, $rootOperation, 'resourceClass', 'rootClass', null, 0); - $this->assertSame($expectedGraphqlType, $graphqlType); - } - public function testConvertTypeCollectionEnum(): void { $type = Type::array(Type::object(GenderTypeEnum::class)); diff --git a/src/GraphQl/Type/ContextAwareTypeBuilderInterface.php b/src/GraphQl/Type/ContextAwareTypeBuilderInterface.php index d945ff175e5..cff80e405cb 100644 --- a/src/GraphQl/Type/ContextAwareTypeBuilderInterface.php +++ b/src/GraphQl/Type/ContextAwareTypeBuilderInterface.php @@ -18,7 +18,6 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\Type as GraphQLType; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; /** @@ -55,11 +54,4 @@ public function getPaginatedCollectionType(GraphQLType $resourceType, Operation * Gets the type corresponding to an enum. */ public function getEnumType(Operation $operation): GraphQLType; - - /** - * Returns true if a type is a collection. - * - * @deprecated since 4.2 - */ - public function isCollection(LegacyType $type): bool; } diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 7f63a2d1ba5..7c39aa21690 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -22,12 +22,14 @@ use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\InflectorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\SortFilterInterface; use ApiPlatform\Metadata\Util\Inflector; -use ApiPlatform\Metadata\Util\PropertyInfoToTypeInfoHelper; use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Util\StateOptionsTrait; @@ -38,8 +40,6 @@ use GraphQL\Type\Definition\Type as GraphQLType; use GraphQL\Type\Definition\WrappingType; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -221,37 +221,16 @@ public function getResourceObjectTypeFields(?string $resourceClass, Operation $o ]; $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $context); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyTypes = $propertyMetadata->getBuiltinTypes(); - - if ( - !$propertyTypes - || (!$input && false === $propertyMetadata->isReadable()) - || ($input && false === $propertyMetadata->isWritable()) - ) { - continue; - } - - // guess union/intersect types: check each type until finding a valid one - foreach ($propertyTypes as $propertyType) { - if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) { - $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; - // stop at the first valid type - break; - } - } - } else { - if ( - !($propertyType = $propertyMetadata->getNativeType()) - || (!$input && false === $propertyMetadata->isReadable()) - || ($input && false === $propertyMetadata->isWritable()) - ) { - continue; - } + if ( + !($propertyType = $propertyMetadata->getNativeType()) + || (!$input && false === $propertyMetadata->isReadable()) + || ($input && false === $propertyMetadata->isWritable()) + ) { + continue; + } - if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) { - $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; - } + if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) { + $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; } } } @@ -310,101 +289,13 @@ public function resolveResourceArgs(array $args, Operation $operation): array return $args; } - /** - * Transform the result of a parse_str to a GraphQL object type. - * We should consider merging getFilterArgs and this, `getFilterArgs` uses `convertType` whereas we assume that parameters have only scalar types. - * Note that this method has a lower complexity then the `getFilterArgs` one. - * TODO: Is there a use case with an argument being a complex type (eg: a Resource, Enum etc.)? - * - * @param array $flattenFields - */ - private function parameterToObjectType(array $flattenFields, string $name): InputObjectType - { - $fields = []; - foreach ($flattenFields as $field) { - $key = $field['name']; - $type = \in_array($field['type'], TypeIdentifier::values(), true) ? Type::builtin($field['type']) : Type::object($field['type']); - if (!$field['required']) { - $type = Type::nullable($type); - } - - $type = $this->getParameterType($type); - if (\is_array($l = $field['leafs'])) { - if (0 === key($l)) { - $key = $key; - $type = GraphQLType::listOf($type); - } else { - $n = []; - foreach ($field['leafs'] as $l => $value) { - $n[] = ['required' => null, 'name' => $l, 'leafs' => $value, 'type' => 'string', 'description' => null]; - } - - $type = $this->parameterToObjectType($n, $key); - if (isset($fields[$key]) && ($t = $fields[$key]['type']) instanceof InputObjectType) { - $t = $fields[$key]['type']; - $t->config['fields'] = array_merge($t->config['fields'], $type->config['fields']); - $type = $t; - } - } - } - - if ($field['required']) { - $type = GraphQLType::nonNull($type); - } - - if (isset($fields[$key])) { - if ($type instanceof ListOfType) { - $key .= '_list'; - } elseif ($fields[$key]['type'] instanceof InputObjectType && !$type instanceof InputObjectType) { - continue; - } - } - - $fields[$key] = ['type' => $type, 'name' => $key]; - } - - return new InputObjectType(['name' => $name, 'fields' => $fields]); - } - - /** - * A simplified version of convert type that does not support resources. - */ - private function getParameterType(Type $type): GraphQLType - { - if ($type->isIdentifiedBy(TypeIdentifier::BOOL)) { - return GraphQLType::boolean(); - } - - if ($type->isIdentifiedBy(TypeIdentifier::INT)) { - return GraphQLType::int(); - } - - if ($type->isIdentifiedBy(TypeIdentifier::FLOAT)) { - return GraphQLType::float(); - } - - if ($type->isIdentifiedBy(TypeIdentifier::STRING, TypeIdentifier::OBJECT)) { - return GraphQLType::string(); - } - - if ($type instanceof CollectionType) { - return GraphQLType::listOf($this->getParameterType($type->getCollectionValueType())); - } - - return GraphQLType::string(); - } - /** * Get the field configuration of a resource. * * @see http://webonyx.github.io/graphql-php/type-system/object-types/ */ - private function getResourceFieldConfiguration(?string $property, ?string $fieldDescription, ?string $deprecationReason, Type|LegacyType $type, string $rootResource, bool $input, Operation $rootOperation, int $depth = 0, bool $forceNullable = false): ?array + private function getResourceFieldConfiguration(?string $property, ?string $fieldDescription, ?string $deprecationReason, Type $type, string $rootResource, bool $input, Operation $rootOperation, int $depth = 0, bool $forceNullable = false): ?array { - if ($type instanceof LegacyType) { - $type = PropertyInfoToTypeInfoHelper::convertLegacyTypesToType([$type]); - } - try { $isCollectionType = $type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType) && ($v = TypeHelper::getCollectionValueType($type)) && TypeHelper::getClassName($v); @@ -455,16 +346,7 @@ private function getResourceFieldConfiguration(?string $property, ?string $field $args = $this->getGraphQlPaginationArgs($resourceOperation); } - $args = $this->getFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth); - - // Also register parameter args in the types container - // Note: This is a workaround, for more information read the comment on the parameterToObjectType function. - foreach ($this->getParameterArgs($rootOperation) as $key => $arg) { - if ($arg instanceof InputObjectType || (\is_array($arg) && isset($arg['name']))) { - $this->typesContainer->set(\is_array($arg) ? $arg['name'] : $arg->name(), $arg); - } - $args[$key] = $arg; - } + $args = $this->getCollectionFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth); } } @@ -488,71 +370,6 @@ private function getResourceFieldConfiguration(?string $property, ?string $field return null; } - /* - * This function is @experimental, read the comment on the parameterToObjectType function for additional information. - * @experimental - */ - private function getParameterArgs(Operation $operation, array $args = []): array - { - $groups = []; - - foreach ($operation->getParameters() ?? [] as $parameter) { - $key = $parameter->getKey(); - - if (str_contains($key, '[')) { - $key = str_replace('.', $this->nestingSeparator, $key); - parse_str($key, $values); - $rootKey = key($values); - - $leafs = $values[$rootKey]; - $name = key($leafs); - - $filterLeafs = []; - if ($filter = $this->resolveFilter($parameter->getFilter())) { - $property = $parameter->getProperty() ?? $name; - $property = str_replace('.', $this->nestingSeparator, $property); - $description = $filter->getDescription($operation->getClass()); - - foreach ($description as $descKey => $descValue) { - $descKey = str_replace('.', $this->nestingSeparator, $descKey); - parse_str($descKey, $descValues); - if (isset($descValues[$property]) && \is_array($descValues[$property])) { - $filterLeafs = array_merge($filterLeafs, $descValues[$property]); - } - } - } - - if ($filterLeafs) { - $leafs[$name] = $filterLeafs; - } - - $groups[$rootKey][] = [ - 'name' => $name, - 'leafs' => $leafs[$name], - 'required' => $parameter->getRequired(), - 'description' => $parameter->getDescription(), - 'type' => 'string', - ]; - continue; - } - - $args[$key] = ['type' => GraphQLType::string()]; - - if ($parameter->getRequired()) { - $args[$key]['type'] = GraphQLType::nonNull($args[$key]['type']); - } - } - - foreach ($groups as $key => $flattenFields) { - $name = $key.$operation->getShortName().$operation->getName(); - $inputObject = $this->parameterToObjectType($flattenFields, $name); - $this->typesContainer->set($name, $inputObject); - $args[$key] = $inputObject; - } - - return $args; - } - private function getGraphQlPaginationArgs(Operation $queryOperation): array { $paginationType = $this->pagination->getGraphQlPaginationType($queryOperation); @@ -597,12 +414,41 @@ private function getGraphQlPaginationArgs(Operation $queryOperation): array return $args; } - private function getFilterArgs(array $args, ?string $resourceClass, string $rootResource, Operation $resourceOperation, Operation $rootOperation, ?string $property, int $depth): array + /** + * Single entry point for GraphQL collection-field arguments. + * + * Builds one intermediate "arg tree" from BOTH the legacy `Operation::getFilters()` + * descriptions and the canonical `Operation::getParameters()` (#[QueryParameter]), + * then materializes it into GraphQL types once via {@see argTreeToGraphQLType()}. + * + * It is called with the *resource* operation (not the root one): for a nested + * relation field, $resourceOperation is the related resource's collection_query, + * so its own parameters/filters surface as nested arguments on that sub-field. + */ + private function getCollectionFilterArgs(array $args, ?string $resourceClass, string $rootResource, Operation $resourceOperation, Operation $rootOperation, ?string $property, int $depth): array { if (null === $resourceClass) { return $args; } + $tree = []; + $this->buildFilterArgTree($tree, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth); + $this->buildParameterArgTree($tree, $resourceOperation); + + return $args + $this->argTreeToGraphQLType($tree); + } + + /** + * Feeds the arg tree from the legacy `Operation::getFilters()` descriptions. + * + * A leaf is a GraphQLType; a nested node is an array carrying a reserved `#name` + * (the generated InputObjectType name). Nested filter nodes are list-wrapped to + * preserve the historical GraphQL filter shape (e.g. `order: [..]`, `availableAt: [..]`). + * + * @param array $tree + */ + private function buildFilterArgTree(array &$tree, string $resourceClass, string $rootResource, Operation $resourceOperation, Operation $rootOperation, ?string $property, int $depth): void + { foreach ($resourceOperation->getFilters() ?? [] as $filterId) { if (!($filter = $this->resolveFilter($filterId))) { continue; @@ -631,70 +477,316 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root array_walk_recursive($parsed, static function (&$v) use ($graphqlFilterType): void { $v = $graphqlFilterType; }); - $args = $this->mergeFilterArgs($args, $parsed, $resourceOperation, $key); + $this->mergeArgTree($tree, $parsed, $resourceOperation->getShortName(), $key); + } + } + } + + /** + * Feeds the arg tree from the canonical `Operation::getParameters()`. + * + * Each parameter's shape is derived from its JSON Schema (via + * {@see JsonSchemaFilterInterface::getSchema()}, e.g. ComparisonFilter exposing + * gt/gte/lt/lte/ne) and falls back to its `getNativeType()` for plain scalars. + * Bracketed keys (`order[:property]` → `order[name]`) collapse into a single + * list-wrapped input object; dotted keys (`colors.price`) flatten to a nested + * key (`colors__price`) so the runtime `__`→`.` contract is preserved. + * + * @param array $tree + */ + private function buildParameterArgTree(array &$tree, Operation $operation): void + { + foreach ($operation->getParameters() ?? [] as $parameter) { + $key = $parameter->getKey(); + if (null === $key) { + continue; } + + $filter = $this->resolveFilter($parameter->getFilter()); + $schema = ($filter instanceof JsonSchemaFilterInterface ? $filter->getSchema($parameter) : null) ?? $parameter->getSchema(); + $leafType = $this->parameterLeafType($parameter, $schema); + + if (str_contains($key, '[')) { + // Bracketed key (order[name], order[:property] expanded). The portion + // before the first bracket becomes one input object whose fields are + // the bracketed accessors, list-shaped for :property-template filters. + $rootKey = substr($key, 0, (int) strpos($key, '[')); + preg_match_all('/\[([^\[\]]+)\]/', $key, $matches); + $accessors = $matches[1]; + + $name = $rootKey.$operation->getShortName().$operation->getName(); + $node = $tree[$rootKey] ?? ['#name' => $name, '#list' => $this->isListParameter($filter)]; + if (!\is_array($node)) { + // A scalar leaf (written by a filter) already holds this key; a + // bracketed parameter cannot merge into a non-object argument. + continue; + } + $entityClass = $this->getStateOptionsClass($operation, $operation->getClass() ?? ''); + $cursor = &$node; + foreach ($accessors as $i => $accessor) { + if ($i === \count($accessors) - 1) { + if (!isset($cursor[$accessor])) { + $cursor[$accessor] = $this->bracketLeaf($parameter, $filter, $accessor, $leafType, $name, $entityClass); + } + break; + } + $cursor[$accessor] ??= ['#name' => $name.'_'.$accessor, '#list' => false]; + $cursor = &$cursor[$accessor]; + } + unset($cursor); + $tree[$rootKey] = $node; + continue; + } + + // Dotted key: flatten to the nesting-separator form (colors.price -> colors__price) + // so it matches the legacy runtime contract (ReadProvider converts __ back to .). + $argKey = str_replace('.', $this->nestingSeparator, $key); + + if (\is_array($schema) && 'object' === ($schema['type'] ?? null) && \is_array($schema['properties'] ?? null)) { + // Operator form (e.g. ComparisonFilter gt/gte/lt/lte/ne): a non-list input object. + $name = $operation->getShortName().$operation->getName().'_'.strtr($argKey, ['.' => '__']); + $node = ['#name' => $name, '#list' => false, '#nonNull' => (bool) $parameter->getRequired()]; + foreach ($schema['properties'] as $prop => $propSchema) { + $propSchema = \is_array($propSchema) ? $propSchema : []; + // The operator's inner schema is often a bare {type:string} placeholder + // (ComparisonFilter wraps an untyped equality filter); prefer the + // parameter's native type so an int property yields GraphQL Int. + $node[$prop] = 'string' === ($propSchema['type'] ?? 'string') ? $leafType : $this->jsonSchemaToGraphQLType($propSchema); + } + $tree[$argKey] = $node; + continue; + } + + $type = $leafType; + if ($parameter->getRequired()) { + $type = GraphQLType::nonNull($type); + } + $tree[$argKey] = $type; } + } - return $this->convertFilterArgsToTypes($args); + /** + * Whether a bracketed parameter exposes a list-shaped GraphQL argument + * (e.g. `order: [{name: "DESC"}, {description: "ASC"}]`) instead of a single + * input object. + * + * Only sort filters are sequence-sensitive: GraphQL input-object fields are + * unordered, so multi-key ordering cannot be expressed as one object and must + * be a list. Every other bracketed filter (search, comparison, date, exists) + * is a single input object. Recognized through the backend-agnostic + * {@see SortFilterInterface}, keeping this component free of any persistence + * dependency. + */ + private function isListParameter(?FilterInterface $filter): bool + { + return $filter instanceof SortFilterInterface; } - private function mergeFilterArgs(array $args, array $parsed, ?Operation $operation = null, string $original = ''): array + /** + * Computes the leaf for a bracketed-parameter accessor. + * + * A scalar by default, but enriched from the filter's `getDescription()`: when the + * description for the accessor's property exposes sub-keys it becomes either a + * `listOf` (sequential `foo[]` form) or a nested non-list input object (e.g. a date + * filter's `createdAt[before]`/`[after]`), preserving the historical shape. + * + * @return GraphQLType|array + */ + private function bracketLeaf(Parameter $parameter, ?FilterInterface $filter, string $accessor, GraphQLType $leafType, string $parentName, string $entityClass): GraphQLType|array + { + if (!$filter instanceof FilterInterface) { + return $leafType; + } + + $property = $parameter->getProperty() ?? $accessor; + $property = str_replace('.', $this->nestingSeparator, $property); + + $descriptionLeafs = []; + foreach ($filter->getDescription($entityClass) as $descKey => $descValue) { + $descKey = str_replace('.', $this->nestingSeparator, $descKey); + parse_str($descKey, $descValues); + if (isset($descValues[$property]) && \is_array($descValues[$property])) { + $descriptionLeafs = array_merge($descriptionLeafs, $descValues[$property]); + } + } + + if (!$descriptionLeafs) { + return $leafType; + } + + // Sequential array (e.g. foo[]) => list of the scalar leaf. + if (0 === key($descriptionLeafs)) { + return GraphQLType::listOf($leafType); + } + + // Associative sub-keys (e.g. before/after) => nested non-list input object. + $node = ['#name' => $parentName.'_'.$accessor, '#list' => false]; + foreach (array_keys($descriptionLeafs) as $subKey) { + $node[$subKey] = GraphQLType::string(); + } + + return $node; + } + + /** + * Merges a parsed legacy-filter subtree into the shared arg tree, tagging nested + * nodes with the generated `#name` used for InputObjectType dedup. + * + * @param array $tree + * @param array $parsed + */ + private function mergeArgTree(array &$tree, array $parsed, string $shortName, string $original): void { foreach ($parsed as $key => $value) { - // Never override keys that cannot be merged - if (isset($args[$key]) && !\is_array($args[$key])) { + // Never override keys that cannot be merged. + if (isset($tree[$key]) && !\is_array($tree[$key])) { continue; } if (\is_array($value)) { - $value = $this->mergeFilterArgs($args[$key] ?? [], $value); - if (!isset($value['#name'])) { + $sub = $tree[$key] ?? []; + $this->mergeArgTree($sub, $value, $shortName, $original); + if (!isset($sub['#name'])) { $name = (false === $pos = strrpos($original, '[')) ? $original : substr($original, 0, (int) $pos); - $value['#name'] = ($operation ? $operation->getShortName() : '').'Filter_'.strtr($name, ['[' => '_', ']' => '', '.' => '__']); + $sub['#name'] = $shortName.'Filter_'.strtr($name, ['[' => '_', ']' => '', '.' => '__']); + $sub['#list'] = true; } + $tree[$key] = $sub; + continue; } - $args[$key] = $value; + $tree[$key] = $value; + } + } + + /** + * Materializes an arg tree into GraphQL argument definitions. + * + * Leaves are GraphQLType instances. A nested node (array) is converted to an + * `InputObjectType` named by its `#name` marker, list-wrapped when `#list` is + * true. Generated input objects are registered in the TypesContainer and reused + * on name collision (dedup). + * + * @param array $tree + * + * @return array + */ + private function argTreeToGraphQLType(array $tree): array + { + $args = []; + foreach ($tree as $key => $value) { + if ($value instanceof GraphQLType) { + $args[$key] = $value; + continue; + } + + if (\is_array($value) && isset($value['#name'])) { + $args[$key] = $this->buildInputObjectType($value); + } } return $args; } - private function convertFilterArgsToTypes(array $args): array + /** + * @param array $node + */ + private function buildInputObjectType(array $node): GraphQLType { - foreach ($args as $key => $value) { - if (strpos($key, '.')) { - // Declare relations/nested fields in a GraphQL compatible syntax. - $args[str_replace('.', $this->nestingSeparator, $key)] = $value; - unset($args[$key]); - } + $name = $node['#name']; + $list = $node['#list'] ?? true; + $nonNull = $node['#nonNull'] ?? false; + + if ($this->typesContainer->has($name)) { + return $this->typesContainer->get($name); } - foreach ($args as $key => $value) { - if (!\is_array($value) || !isset($value['#name'])) { + unset($node['#name'], $node['#list'], $node['#nonNull']); + + $fields = []; + foreach ($node as $fieldKey => $fieldValue) { + if ($fieldValue instanceof GraphQLType) { + $fields[$fieldKey] = $fieldValue; continue; } - $name = $value['#name']; - - if ($this->typesContainer->has($name)) { - $args[$key] = $this->typesContainer->get($name); - continue; + if (\is_array($fieldValue) && isset($fieldValue['#name'])) { + $fields[$fieldKey] = $this->buildInputObjectType($fieldValue); } + } + + $inputObject = new InputObjectType(['name' => $name, 'fields' => $fields]); + $type = $list ? GraphQLType::listOf($inputObject) : $inputObject; + if ($nonNull) { + $type = GraphQLType::nonNull($type); + } - unset($value['#name']); + $this->typesContainer->set($name, $type); - $filterArgType = GraphQLType::listOf(new InputObjectType([ - 'name' => $name, - 'fields' => $this->convertFilterArgsToTypes($value), - ])); + return $type; + } - $this->typesContainer->set($name, $filterArgType); + /** + * Resolves the scalar GraphQL leaf type for a parameter, from its JSON Schema + * scalar type when available, otherwise from its native (PHP) type. + * + * @param array|null $schema + */ + private function parameterLeafType(Parameter $parameter, ?array $schema): GraphQLType + { + if (\is_array($schema) && isset($schema['type']) && \is_string($schema['type']) && 'object' !== $schema['type'] && 'array' !== $schema['type']) { + return $this->jsonSchemaToGraphQLType($schema); + } - $args[$key] = $filterArgType; + if ($nativeType = $parameter->getNativeType()) { + return $this->nativeTypeToGraphQLType($nativeType); } - return $args; + return GraphQLType::string(); + } + + /** + * @param array $schema + */ + private function jsonSchemaToGraphQLType(array $schema): GraphQLType + { + if ('array' === ($schema['type'] ?? null)) { + $items = \is_array($schema['items'] ?? null) ? $schema['items'] : ['type' => 'string']; + + return GraphQLType::listOf($this->jsonSchemaToGraphQLType($items)); + } + + return match ($schema['type'] ?? 'string') { + 'integer' => GraphQLType::int(), + 'number' => GraphQLType::float(), + 'boolean' => GraphQLType::boolean(), + default => GraphQLType::string(), + }; + } + + private function nativeTypeToGraphQLType(Type $type): GraphQLType + { + if ($type->isIdentifiedBy(TypeIdentifier::BOOL)) { + return GraphQLType::boolean(); + } + + if ($type->isIdentifiedBy(TypeIdentifier::INT)) { + return GraphQLType::int(); + } + + if ($type->isIdentifiedBy(TypeIdentifier::FLOAT)) { + return GraphQLType::float(); + } + + if ($type->isIdentifiedBy(TypeIdentifier::STRING, TypeIdentifier::OBJECT)) { + return GraphQLType::string(); + } + + if ($type instanceof CollectionType) { + return GraphQLType::listOf($this->nativeTypeToGraphQLType($type->getCollectionValueType())); + } + + return GraphQLType::string(); } /** @@ -702,12 +794,8 @@ private function convertFilterArgsToTypes(array $args): array * * @throws InvalidTypeException */ - private function convertType(Type|LegacyType $type, bool $input, Operation $resourceOperation, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false): GraphQLType|ListOfType|NonNull + private function convertType(Type $type, bool $input, Operation $resourceOperation, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false): GraphQLType|ListOfType|NonNull { - if ($type instanceof LegacyType) { - $type = PropertyInfoToTypeInfoHelper::convertLegacyTypesToType([$type]); - } - $graphqlType = $this->typeConverter->convertPhpType($type, $input, $rootOperation, $resourceClass, $rootResource, $property, $depth); if (null === $graphqlType) { diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index a0f346a54d4..1433c546404 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -30,7 +30,6 @@ use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type as GraphQLType; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; /** @@ -221,16 +220,6 @@ public function getEnumType(Operation $operation): GraphQLType return $enumType; } - /** - * {@inheritdoc} - */ - public function isCollection(LegacyType $type): bool - { - trigger_deprecation('api-platform/graphql', '4.2', 'The "%s()" method is deprecated and will be removed.', __METHOD__, self::class); - - return $type->isCollection() && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null) && null !== $collectionValueType->getClassName(); - } - private function getCursorBasedPaginationFields(GraphQLType $resourceType): array { $namedType = GraphQLType::getNamedType($resourceType); diff --git a/src/GraphQl/Type/TypeConverter.php b/src/GraphQl/Type/TypeConverter.php index ca74645aa51..e8273dd5009 100644 --- a/src/GraphQl/Type/TypeConverter.php +++ b/src/GraphQl/Type/TypeConverter.php @@ -29,7 +29,6 @@ use GraphQL\Language\Parser; use GraphQL\Type\Definition\NullableType; use GraphQL\Type\Definition\Type as GraphQLType; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -46,40 +45,6 @@ public function __construct(private readonly ContextAwareTypeBuilderInterface $t { } - /** - * {@inheritdoc} - */ - public function convertType(LegacyType $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth): GraphQLType|string|null - { - trigger_deprecation('api-platform/graphql', '4.2', 'The "%s()" method is deprecated, use "%s::convertPhpType()" instead.', __METHOD__, self::class); - - switch ($type->getBuiltinType()) { - case LegacyType::BUILTIN_TYPE_BOOL: - return GraphQLType::boolean(); - case LegacyType::BUILTIN_TYPE_INT: - return GraphQLType::int(); - case LegacyType::BUILTIN_TYPE_FLOAT: - return GraphQLType::float(); - case LegacyType::BUILTIN_TYPE_STRING: - return GraphQLType::string(); - case LegacyType::BUILTIN_TYPE_ARRAY: - case LegacyType::BUILTIN_TYPE_ITERABLE: - if ($resourceType = $this->getResourceType($type, $input, $rootOperation, $rootResource, $property, $depth)) { - return $resourceType; - } - - return 'Iterable'; - case LegacyType::BUILTIN_TYPE_OBJECT: - if (is_a($type->getClassName(), \DateTimeInterface::class, true)) { - return GraphQLType::string(); - } - - return $this->getResourceType($type, $input, $rootOperation, $rootResource, $property, $depth); - default: - return null; - } - } - /** * {@inheritdoc} */ @@ -134,35 +99,22 @@ public function resolveType(string $type): GraphQLType throw new InvalidArgumentException(\sprintf('The type "%s" was not resolved.', $type)); } - private function getResourceType(Type|LegacyType $type, bool $input, Operation $rootOperation, string $rootResource, ?string $property, int $depth): ?GraphQLType + private function getResourceType(Type $type, bool $input, Operation $rootOperation, string $rootResource, ?string $property, int $depth): ?GraphQLType { - if ($type instanceof Type) { - $isCollection = $type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType); - - if ($isCollection) { - $type = TypeHelper::getCollectionValueType($type); - } + $isCollection = $type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType); - /** @var class-string|null $resourceClass */ - $resourceClass = null; - $typeIsResourceClass = static function (Type $type) use (&$resourceClass): bool { - return $type instanceof ObjectType && $resourceClass = $type->getClassName(); - }; + if ($isCollection) { + $type = TypeHelper::getCollectionValueType($type); + } - if (!$type->isSatisfiedBy($typeIsResourceClass)) { - return null; - } - } else { - $isCollection = $this->typeBuilder->isCollection($type); - if ($isCollection && $collectionValueType = $type->getCollectionValueTypes()[0] ?? null) { - $resourceClass = $collectionValueType->getClassName(); - } else { - $resourceClass = $type->getClassName(); - } + /** @var class-string|null $resourceClass */ + $resourceClass = null; + $typeIsResourceClass = static function (Type $type) use (&$resourceClass): bool { + return $type instanceof ObjectType && $resourceClass = $type->getClassName(); + }; - if (null === $resourceClass) { - return null; - } + if (!$type->isSatisfiedBy($typeIsResourceClass)) { + return null; } try { diff --git a/src/GraphQl/Type/TypeConverterInterface.php b/src/GraphQl/Type/TypeConverterInterface.php index 99b19837e32..d858a42341b 100644 --- a/src/GraphQl/Type/TypeConverterInterface.php +++ b/src/GraphQl/Type/TypeConverterInterface.php @@ -15,25 +15,20 @@ use ApiPlatform\Metadata\GraphQl\Operation; use GraphQL\Type\Definition\Type as GraphQLType; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; /** * Converts a type to its GraphQL equivalent. * * @author Alan Poulain - * - * @method GraphQLType|string|null convertPhpType(Type $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth) */ interface TypeConverterInterface { /** - * @deprecated since 4.1, use "convertPhpType" instead - * * Converts a built-in type to its GraphQL equivalent. * A string can be returned for a custom registered type. */ - public function convertType(LegacyType $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth): GraphQLType|string|null; + public function convertPhpType(Type $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth): GraphQLType|string|null; /** * Resolves a type written with the GraphQL type system to its object representation. diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index c996c568ea5..71100d764ba 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -21,21 +21,21 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "api-platform/state": "^4.3", - "api-platform/serializer": "^4.3.12", - "symfony/property-info": "^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.1 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", + "api-platform/metadata": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", "webonyx/graphql-php": "^15.0", "willdurand/negotiation": "^3.1" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "api-platform/validator": "^4.3.1", + "api-platform/validator": "^5.0@alpha", "twig/twig": "^1.42.3 || ^2.12 || ^3.0", "symfony/mercure-bundle": "^0.4.3|^0.5", - "symfony/routing": "^6.4 || ^7.0 || ^8.0", + "symfony/routing": "^7.4 || ^8.0", "phpunit/phpunit": "^11.5 || ^12.2" }, "autoload": { @@ -64,13 +64,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index 61af0539b0e..64ab681d6ab 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -27,8 +27,6 @@ use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Exception\CircularReferenceException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -184,14 +182,10 @@ private function getComponents(object $object, ?string $format, array $context): foreach ($attributes as $attribute) { $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options); - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getNativeType(); - $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]); - /** @var class-string|null $className */ - $className = null; - } else { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - } + $type = $propertyMetadata->getNativeType(); + $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]); + /** @var class-string|null $className */ + $className = null; // prevent declaring $attribute as attribute if it's already declared as relationship $isRelationship = false; @@ -202,23 +196,10 @@ private function getComponents(object $object, ?string $format, array $context): foreach ($types as $type) { $isOne = $isMany = false; - /** @var Type|LegacyType|null $valueType */ - $valueType = null; - - if ($type instanceof LegacyType) { - if ($type->isCollection()) { - $valueType = $type->getCollectionValueTypes()[0] ?? null; - $isMany = null !== $valueType && ($className = $valueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } else { - $className = $type->getClassName(); - $isOne = $className && $this->resourceClassResolver->isResourceClass($className); - } - } elseif ($type instanceof Type) { - if ($type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { - $isMany = TypeHelper::getCollectionValueType($type)?->isSatisfiedBy($typeIsResourceClass); - } else { - $isOne = $type->isSatisfiedBy($typeIsResourceClass); - } + if ($type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { + $isMany = TypeHelper::getCollectionValueType($type)?->isSatisfiedBy($typeIsResourceClass); + } else { + $isOne = $type->isSatisfiedBy($typeIsResourceClass); } if (!$isOne && !$isMany) { diff --git a/src/Hal/composer.json b/src/Hal/composer.json index 1d8bd5f3685..b90d8f00780 100644 --- a/src/Hal/composer.json +++ b/src/Hal/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/state": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/documentation": "^4.3", - "api-platform/serializer": "^4.3.12", - "symfony/type-info": "^7.3 || ^8.0" + "api-platform/state": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/documentation": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -48,13 +48,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", @@ -65,7 +65,7 @@ "test": "./vendor/bin/phpunit" }, "require-dev": { - "api-platform/json-schema": "^4.3", + "api-platform/json-schema": "^5.0@alpha", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2" }, diff --git a/src/HttpCache/composer.json b/src/HttpCache/composer.json index 13f838942bd..2a99b61421a 100644 --- a/src/HttpCache/composer.json +++ b/src/HttpCache/composer.json @@ -23,16 +23,16 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "api-platform/state": "^4.3", - "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0" + "api-platform/metadata": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", + "symfony/http-foundation": "^7.4 || ^8.0" }, "require-dev": { "guzzlehttp/guzzle": "^6.0 || ^7.0 || ^8.0", - "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^7.4 || ^8.0", "phpspec/prophecy-phpunit": "^2.2", - "symfony/http-client": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", + "symfony/http-client": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", "phpunit/phpunit": "^11.5 || ^12.2" }, "autoload": { @@ -55,13 +55,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 428c6da7a94..5c4ceafeef1 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -29,8 +29,6 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\TypeHelper; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -108,14 +106,10 @@ private function populateEntrypointProperties(ApiResource $resourceMetadata, str '@type' => $hydraPrefix.'Link', 'domain' => '#Entrypoint', 'owl:maxCardinality' => 1, - 'range' => [ - ['@id' => 'hydra:Collection'], - [ - 'owl:equivalentClass' => [ - 'owl:onProperty' => ['@id' => 'hydra:member'], - 'owl:allValuesFrom' => ['@id' => $prefixedShortName], - ], - ], + 'range' => 'hydra:Collection', + $hydraPrefix.'memberAssertion' => [ + $hydraPrefix.'property' => ['@id' => 'rdf:type'], + $hydraPrefix.'object' => ['@id' => $prefixedShortName], ], $hydraPrefix.'supportedOperation' => $hydraCollectionOperations, ], @@ -367,108 +361,53 @@ private function getRange(ApiProperty $propertyMetadata): array|string|null $types = []; - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $nativeType = $propertyMetadata->getNativeType(); - if (null === $nativeType) { - return null; - } - - if ($nativeType->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { - $nativeType = TypeHelper::getCollectionValueType($nativeType); - } - - // Check for specific types after potentially unwrapping the collection - if (null === $nativeType) { - return null; // Should not happen if collection had a value type, but safety check - } + $nativeType = $propertyMetadata->getNativeType(); + if (null === $nativeType) { + return null; + } - if ($nativeType->isIdentifiedBy(TypeIdentifier::STRING)) { - $types[] = 'xsd:string'; - } + if ($nativeType->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { + $nativeType = TypeHelper::getCollectionValueType($nativeType); + } - if ($nativeType->isIdentifiedBy(TypeIdentifier::INT)) { - $types[] = 'xsd:integer'; - } + // Check for specific types after potentially unwrapping the collection + if (null === $nativeType) { + return null; // Should not happen if collection had a value type, but safety check + } - if ($nativeType->isIdentifiedBy(TypeIdentifier::FLOAT)) { - $types[] = 'xsd:decimal'; - } + if ($nativeType->isIdentifiedBy(TypeIdentifier::STRING)) { + $types[] = 'xsd:string'; + } - if ($nativeType->isIdentifiedBy(TypeIdentifier::BOOL)) { - $types[] = 'xsd:boolean'; - } + if ($nativeType->isIdentifiedBy(TypeIdentifier::INT)) { + $types[] = 'xsd:integer'; + } - if ($nativeType->isIdentifiedBy(\DateTimeInterface::class)) { - $types[] = 'xsd:dateTime'; - } + if ($nativeType->isIdentifiedBy(TypeIdentifier::FLOAT)) { + $types[] = 'xsd:decimal'; + } - /** @var class-string|null $className */ - $className = null; + if ($nativeType->isIdentifiedBy(TypeIdentifier::BOOL)) { + $types[] = 'xsd:boolean'; + } - $typeIsResourceClass = function (Type $type) use (&$className): bool { - return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); - }; + if ($nativeType->isIdentifiedBy(\DateTimeInterface::class)) { + $types[] = 'xsd:dateTime'; + } - if ($nativeType->isSatisfiedBy($typeIsResourceClass) && $className) { - $resourceMetadata = $this->resourceMetadataFactory->create($className); - $operation = $resourceMetadata->getOperation(); + /** @var class-string|null $className */ + $className = null; - if (!\in_array("#{$operation->getShortName()}", $types, true)) { - $types[] = "#{$operation->getShortName()}"; - } - } - // TODO: remove in 5.x - } else { - $builtInTypes = $propertyMetadata->getBuiltinTypes() ?? []; + $typeIsResourceClass = function (Type $type) use (&$className): bool { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); + }; - foreach ($builtInTypes as $type) { - if ($type->isCollection() && null !== $collectionType = $type->getCollectionValueTypes()[0] ?? null) { - $type = $collectionType; - } + if ($nativeType->isSatisfiedBy($typeIsResourceClass) && $className) { + $resourceMetadata = $this->resourceMetadataFactory->create($className); + $operation = $resourceMetadata->getOperation(); - switch ($type->getBuiltinType()) { - case LegacyType::BUILTIN_TYPE_STRING: - if (!\in_array('xsd:string', $types, true)) { - $types[] = 'xsd:string'; - } - break; - case LegacyType::BUILTIN_TYPE_INT: - if (!\in_array('xsd:integer', $types, true)) { - $types[] = 'xsd:integer'; - } - break; - case LegacyType::BUILTIN_TYPE_FLOAT: - if (!\in_array('xsd:decimal', $types, true)) { - $types[] = 'xsd:decimal'; - } - break; - case LegacyType::BUILTIN_TYPE_BOOL: - if (!\in_array('xsd:boolean', $types, true)) { - $types[] = 'xsd:boolean'; - } - break; - case LegacyType::BUILTIN_TYPE_OBJECT: - if (null === $className = $type->getClassName()) { - continue 2; - } - - if (is_a($className, \DateTimeInterface::class, true)) { - if (!\in_array('xsd:dateTime', $types, true)) { - $types[] = 'xsd:dateTime'; - } - break; - } - - if ($this->resourceClassResolver->isResourceClass($className)) { - $resourceMetadata = $this->resourceMetadataFactory->create($className); - $operation = $resourceMetadata->getOperation(); - - if (!\in_array("#{$operation->getShortName()}", $types, true)) { - $types[] = "#{$operation->getShortName()}"; - } - break; - } - } + if (!\in_array("#{$operation->getShortName()}", $types, true)) { + $types[] = "#{$operation->getShortName()}"; } } @@ -483,38 +422,20 @@ private function getRange(ApiProperty $propertyMetadata): array|string|null private function isSingleRelation(ApiProperty $propertyMetadata): bool { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $nativeType = $propertyMetadata->getNativeType(); - if (null === $nativeType) { - return false; - } - - if ($nativeType instanceof CollectionType) { - return false; - } - - $typeIsResourceClass = function (Type $type) use (&$className): bool { - return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); - }; - - return $nativeType->isSatisfiedBy($typeIsResourceClass); + $nativeType = $propertyMetadata->getNativeType(); + if (null === $nativeType) { + return false; } - // TODO: remove in 5.x - $builtInTypes = $propertyMetadata->getBuiltinTypes() ?? []; - - foreach ($builtInTypes as $type) { - $className = $type->getClassName(); - if ( - !$type->isCollection() - && null !== $className - && $this->resourceClassResolver->isResourceClass($className) - ) { - return true; - } + if ($nativeType instanceof CollectionType) { + return false; } - return false; + $typeIsResourceClass = function (Type $type) use (&$className): bool { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); + }; + + return $nativeType->isSatisfiedBy($typeIsResourceClass); } /** diff --git a/src/Hydra/State/JsonStreamerProcessor.php b/src/Hydra/State/JsonStreamerProcessor.php index 10eed4d80de..16e8e0392ae 100644 --- a/src/Hydra/State/JsonStreamerProcessor.php +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -59,6 +59,7 @@ public function __construct( private readonly string $enabledParameterName = 'pagination', private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + private readonly bool $enableHeadRequestOptimization = true, ) { $this->resourceClassResolver = $resourceClassResolver; $this->iriConverter = $iriConverter; @@ -79,6 +80,16 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return $this->processor?->process($data, $operation, $uriVariables, $context); } + if ($this->enableHeadRequestOptimization && $request->isMethod('HEAD')) { + $response = new Response( + null, + $this->getStatus($request, $operation, $context), + $this->getHeaders($request, $operation, $context) + ); + + return $this->processor ? $this->processor->process($response, $operation, $uriVariables, $context) : $response; + } + if ($operation instanceof CollectionOperationInterface) { $requestUri = $request->getRequestUri() ?? ''; $collection = new Collection(); diff --git a/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php b/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php index f9ca871d9e2..1780a949f8a 100644 --- a/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php @@ -333,16 +333,12 @@ private function doTestNormalize($resourceMetadataFactory = null): void '@id' => '#Entrypoint/dummy', '@type' => 'hydra:Link', 'domain' => '#Entrypoint', - 'range' => [ - ['@id' => 'hydra:Collection'], - [ - 'owl:equivalentClass' => [ - 'owl:onProperty' => ['@id' => 'hydra:member'], - 'owl:allValuesFrom' => ['@id' => '#dummy'], - ], - ], - ], 'owl:maxCardinality' => 1, + 'range' => 'hydra:Collection', + 'hydra:memberAssertion' => [ + 'hydra:property' => ['@id' => 'rdf:type'], + 'hydra:object' => ['@id' => '#dummy'], + ], 'hydra:supportedOperation' => [ [ '@type' => ['hydra:Operation', 'schema:FindAction'], @@ -898,16 +894,12 @@ public function testNormalizeWithoutPrefix(): void '@id' => '#Entrypoint/dummy', '@type' => 'Link', 'domain' => '#Entrypoint', - 'range' => [ - ['@id' => 'hydra:Collection'], - [ - 'owl:equivalentClass' => [ - 'owl:onProperty' => ['@id' => 'hydra:member'], - 'owl:allValuesFrom' => ['@id' => '#dummy'], - ], - ], - ], 'owl:maxCardinality' => 1, + 'range' => 'hydra:Collection', + 'memberAssertion' => [ + 'property' => ['@id' => 'rdf:type'], + 'object' => ['@id' => '#dummy'], + ], 'supportedOperation' => [ [ '@type' => ['Operation', 'schema:FindAction'], diff --git a/src/Hydra/composer.json b/src/Hydra/composer.json index 273754f45f3..e93c86ac159 100644 --- a/src/Hydra/composer.json +++ b/src/Hydra/composer.json @@ -25,19 +25,19 @@ ], "require": { "php": ">=8.2", - "api-platform/state": "^4.3", - "api-platform/documentation": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/jsonld": "^4.3", - "api-platform/json-schema": "^4.3", - "api-platform/serializer": "^4.3.12", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "api-platform/state": "^5.0@alpha", + "api-platform/documentation": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/jsonld": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "symfony/web-link": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { - "api-platform/doctrine-odm": "^4.3", - "api-platform/doctrine-orm": "^4.3", - "api-platform/doctrine-common": "^4.3", + "api-platform/doctrine-odm": "^5.0@alpha", + "api-platform/doctrine-orm": "^5.0@alpha", + "api-platform/doctrine-common": "^5.0@alpha", "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2" @@ -62,13 +62,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php b/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php index c20fedffd09..ce0c728c12f 100644 --- a/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php +++ b/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php @@ -14,7 +14,6 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -94,15 +93,8 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio $fieldName = $this->nameConverter->normalize($fieldName, $class, self::FORMAT); } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if ($type && null !== $type->getClassName()) { - return "data/relationships/$fieldName"; - } - } else { - if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { - return "data/relationships/$fieldName"; - } + if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { + return "data/relationships/$fieldName"; } return "data/attributes/$fieldName"; diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index 3b6d2fba917..2076a1c1d8a 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -45,10 +45,9 @@ public function normalize(mixed $data, ?string $format = null, array $context = $error['code'] = $data->getId(); } - // TODO: change this 5.x - // if (isset($error['status'])) { - // $error['status'] = (string) $error['status']; - // } + if (isset($error['status'])) { + $error['status'] = (string) $error['status']; + } if (!isset($error['violations'])) { return ['errors' => [$error]]; diff --git a/src/JsonApi/Serializer/ItemDenormalizer.php b/src/JsonApi/Serializer/ItemDenormalizer.php new file mode 100644 index 00000000000..8ed3a0ac319 --- /dev/null +++ b/src/JsonApi/Serializer/ItemDenormalizer.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonApi\Serializer; + +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Serializer\AbstractItemNormalizer; +use ApiPlatform\Serializer\OperationResourceClassResolverInterface; +use ApiPlatform\Serializer\TagCollectorInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Converts JSON:API documents to objects (denormalization only). + * + * @author Kévin Dunglas + * @author Amrouche Hamza + * @author Baptiste Meyer + */ +final class ItemDenormalizer extends AbstractItemNormalizer +{ + use ItemNormalizerTrait; + + public const FORMAT = 'jsonapi'; + + public function __construct( + PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + PropertyMetadataFactoryInterface $propertyMetadataFactory, + IriConverterInterface $iriConverter, + ResourceClassResolverInterface $resourceClassResolver, + ?PropertyAccessorInterface $propertyAccessor = null, + ?NameConverterInterface $nameConverter = null, + ?ClassMetadataFactoryInterface $classMetadataFactory = null, + array $defaultContext = [], + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + ?ResourceAccessCheckerInterface $resourceAccessChecker = null, + protected ?TagCollectorInterface $tagCollector = null, + ?OperationResourceClassResolverInterface $operationResourceResolver = null, + private readonly bool $useIriAsId = true, + ) { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return false; + } +} diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index b3bcd2c40dc..fbe92ffd249 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -15,8 +15,6 @@ use ApiPlatform\JsonApi\Util\ResourceLinkageResolver; use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\Exception\ItemNotFoundException; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -34,15 +32,13 @@ use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\LogicException; -use Symfony\Component\Serializer\Exception\NotNormalizableValueException; -use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** - * Converts between objects and array. + * Converts objects to JSON:API documents (normalization only). * * @author Kévin Dunglas * @author Amrouche Hamza @@ -52,6 +48,9 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } public const FORMAT = 'jsonapi'; @@ -89,25 +88,23 @@ public function __construct( $this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver); } - /** - * {@inheritdoc} - */ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context) && !($data instanceof \Exception || $data instanceof FlattenException); } - /** - * {@inheritdoc} - */ public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; } - /** - * {@inheritdoc} - */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + trigger_deprecation('api-platform/core', '4.4', 'Calling "denormalize()" on "%s" is deprecated, use "%s" instead.', self::class, ItemDenormalizer::class); + + return $this->doDenormalize($data, $type, $format, $context); + } + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { $resourceClass = $this->getObjectClass($data); @@ -140,7 +137,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $normalizedData; } - // Get and populate relations ['relationships' => $allRelationshipsData, 'links' => $links] = $this->getComponents($data, $format, $context); $populatedRelationContext = $context; $relationshipsData = $this->getPopulatedRelations($data, $format, $populatedRelationContext, $allRelationshipsData); @@ -163,7 +159,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = 'type' => $resourceShortName, ]; - // TODO: consider always adding links.self — it's valid per the JSON:API spec even when id is the IRI if (!$this->useIriAsId) { $resourceData['links'] = ['self' => $iri]; } @@ -191,135 +186,12 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $document; } - /** - * {@inheritdoc} - */ - public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool - { - return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); - } - - /** - * {@inheritdoc} - * - * @throws NotNormalizableValueException - */ - public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed - { - // When re-entering for input DTO denormalization, data has already been - // unwrapped from the JSON:API structure by the first pass. Skip extraction. - if (isset($context['api_platform_input'])) { - return parent::denormalize($data, $type, $format, $context); - } - - $operation = $context['operation'] ?? null; - $isPostOperation = $operation instanceof HttpOperation && 'POST' === $operation->getMethod(); - $allowClientGeneratedId = true === ($context[self::ALLOW_CLIENT_GENERATED_ID] ?? $this->defaultContext[self::ALLOW_CLIENT_GENERATED_ID] ?? false); - - // Avoid issues with proxies if we populated the object - if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { - if ($isPostOperation) { - if (!$allowClientGeneratedId) { - throw new NotNormalizableValueException(\sprintf('Client-generated IDs are not allowed on this operation. Set the "%s" denormalization context flag (or the bundle "allow_client_generated_id" configuration) to enable it.', self::ALLOW_CLIENT_GENERATED_ID)); - } - // Fall through: client id is merged into the denormalized payload below. - } elseif (true !== ($context['api_allow_update'] ?? true)) { - throw new NotNormalizableValueException('Update is not allowed for this operation.'); - } else { - $context += ['fetch_data' => false]; - if ($this->useIriAsId) { - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( - $data['data']['id'], - $context - ); - } elseif ($operation instanceof HttpOperation) { - $iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation); - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context); - } - } - } - - // Merge attributes and relationships, into format expected by the parent normalizer - $dataToDenormalize = array_merge( - $data['data']['attributes'] ?? [], - $data['data']['relationships'] ?? [] - ); - - // Surface the client-generated id so the entity setter receives it. - if ($isPostOperation && $allowClientGeneratedId && isset($data['data']['id'])) { - $dataToDenormalize['id'] = $data['data']['id']; - } - - return parent::denormalize( - $dataToDenormalize, - $type, - $format, - $context - ); - } - - /** - * {@inheritdoc} - */ protected function getAttributes(object $object, ?string $format = null, array $context = []): array { return $this->getComponents($object, $format, $context)['attributes']; } /** - * {@inheritdoc} - */ - protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void - { - parent::setAttributeValue($object, $attribute, \is_array($value) && \array_key_exists('data', $value) ? $value['data'] : $value, $format, $context); - } - - /** - * {@inheritdoc} - * - * @see http://jsonapi.org/format/#document-resource-object-linkage - * - * @throws RuntimeException - * @throws UnexpectedValueException - */ - protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object - { - if (!\is_array($value) || !isset($value['id'], $value['type'])) { - throw new UnexpectedValueException('Only resource linkage supported currently, see: http://jsonapi.org/format/#document-resource-object-linkage.'); - } - - try { - $context += ['fetch_data' => true]; - if ($this->useIriAsId) { - return $this->iriConverter->getResourceFromIri($value['id'], $context); - } - - /** @var HttpOperation $getOperation */ - $getOperation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(httpOperation: true); - $iri = $this->reconstructIri($className, (string) $value['id'], $getOperation); - - return $this->iriConverter->getResourceFromIri($iri, $context); - } catch (ItemNotFoundException $e) { - if (!isset($context['not_normalizable_value_exceptions'])) { - throw new RuntimeException($e->getMessage(), $e->getCode(), $e); - } - $context['not_normalizable_value_exceptions'][] = NotNormalizableValueException::createForUnexpectedDataType( - $e->getMessage(), - $value, - [$className], - $context['deserialization_path'] ?? null, - true, - $e->getCode(), - $e - ); - - return null; - } - } - - /** - * {@inheritdoc} - * * @see http://jsonapi.org/format/#document-resource-object-linkage */ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $relatedObject, string $resourceClass, ?string $format, array $context): \ArrayObject|array|string|null @@ -352,13 +224,11 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel $id = $this->getIdStringFromIdentifiers($identifiers); } - $relationData = [ - 'type' => $this->getResourceShortName($resourceClass), - 'id' => $id, - ]; - $context['data'] = [ - 'data' => $relationData, + 'data' => [ + 'type' => $this->getResourceShortName($resourceClass), + 'id' => $id, + ], ]; $context['iri'] = $iri; @@ -373,14 +243,6 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel return $context['data']; } - /** - * {@inheritdoc} - */ - protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool - { - return preg_match('/^\\w[-\\w_]*$/', $attribute) && parent::isAllowedAttribute($classOrObject, $attribute, $format, $context); - } - /** * Gets JSON API components of the resource: attributes, relationships, meta and links. */ @@ -450,8 +312,6 @@ private function getComponents(object $object, ?string $format, array $context): } /** - * Populates relationships keys. - * * @throws UnexpectedValueException */ private function getPopulatedRelations(object $object, ?string $format, array $context, array $relationships): array @@ -472,11 +332,8 @@ private function getPopulatedRelations(object $object, ?string $format, array $c $relationshipName = $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context); } - // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { - $data[$relationshipName] = [ - 'data' => null, - ]; + $data[$relationshipName] = ['data' => null]; if (!$attributeValue) { continue; @@ -488,10 +345,7 @@ private function getPopulatedRelations(object $object, ?string $format, array $c continue; } - // Many to many relationship - $data[$relationshipName] = [ - 'data' => [], - ]; + $data[$relationshipName] = ['data' => []]; if (!$attributeValue) { continue; @@ -509,9 +363,6 @@ private function getPopulatedRelations(object $object, ?string $format, array $c return $data; } - /** - * Populates included keys. - */ private function getRelatedResources(object $object, ?string $format, array $context, array $relationships): array { if (!isset($context['api_included'])) { @@ -535,9 +386,7 @@ private function getRelatedResources(object $object, ?string $format, array $con continue; } - // Many to many relationship $attributeValues = $attributeValue; - // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { $attributeValues = [$attributeValue]; } @@ -557,9 +406,6 @@ private function getRelatedResources(object $object, ?string $format, array $con return $included; } - /** - * Add data to included array if it's not already included. - */ private function addIncluded(array $data, array &$included, array &$context): void { $trackingKey = ($data['type'] ?? '').':'.($data['id'] ?? ''); @@ -569,9 +415,6 @@ private function addIncluded(array $data, array &$included, array &$context): vo } } - /** - * Figures out if the relationship is in the api_included hash or has included nested resources (path). - */ private function shouldIncludeRelation(string $relationshipName, array $context): bool { $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; @@ -579,9 +422,6 @@ private function shouldIncludeRelation(string $relationshipName, array $context) return \in_array($normalizedName, $context['api_included'], true) || \count($this->getIncludedNestedResources($relationshipName, $context)) > 0; } - /** - * Returns the names of the nested resources from a path relationship. - */ private function getIncludedNestedResources(string $relationshipName, array $context): array { $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; @@ -600,27 +440,6 @@ private function getIdStringFromIdentifiers(array $identifiers): string return CompositeIdentifierParser::stringify($identifiers); } - /** - * Reconstructs an IRI from a resource class and a raw JSON:API id string. - * - * Maps the id to the operation's single URI variable parameter name and generates - * the IRI via IriConverter. Composite identifiers on a single Link work naturally - * since the composite string (e.g. "field1=val1;field2=val2") is passed as-is. - */ - private function reconstructIri(string $resourceClass, string $id, HttpOperation $operation): string - { - $uriVariables = $operation->getUriVariables() ?? []; - - if (\count($uriVariables) > 1) { - throw new UnexpectedValueException(\sprintf('JSON:API entity identifier mode requires operations with a single URI variable, operation "%s" has %d. Consider adding a NotExposed Get operation on the resource.', $operation->getName() ?? $operation->getUriTemplate(), \count($uriVariables))); - } - - $parameterName = array_key_first($uriVariables) ?? 'id'; - - return $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => [$parameterName => $id]]); - } - - // TODO: this code is similar to the one used in JsonLd private function getResourceShortName(string $resourceClass): string { if ($this->resourceClassResolver->isResourceClass($resourceClass)) { diff --git a/src/JsonApi/Serializer/ItemNormalizerTrait.php b/src/JsonApi/Serializer/ItemNormalizerTrait.php new file mode 100644 index 00000000000..e2279ca8868 --- /dev/null +++ b/src/JsonApi/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,160 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonApi\Serializer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Serializer\AbstractItemNormalizer; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\RuntimeException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; + +/** + * Shared support gates and denormalization logic for the JSON:API item (de)normalizer. + * + * @author Kévin Dunglas + * + * @internal + */ +trait ItemNormalizerTrait +{ + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); + } + + /** + * @throws NotNormalizableValueException + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + // When re-entering for input DTO denormalization, data has already been + // unwrapped from the JSON:API structure by the first pass. Skip extraction. + if (isset($context['api_platform_input'])) { + return parent::denormalize($data, $type, $format, $context); + } + + $operation = $context['operation'] ?? null; + $isPostOperation = $operation instanceof HttpOperation && 'POST' === $operation->getMethod(); + $allowClientGeneratedId = true === ($context[ItemNormalizer::ALLOW_CLIENT_GENERATED_ID] ?? $this->defaultContext[ItemNormalizer::ALLOW_CLIENT_GENERATED_ID] ?? false); + + // Avoid issues with proxies if we populated the object + if (!isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { + if ($isPostOperation) { + if (!$allowClientGeneratedId) { + throw new NotNormalizableValueException(\sprintf('Client-generated IDs are not allowed on this operation. Set the "%s" denormalization context flag (or the bundle "allow_client_generated_id" configuration) to enable it.', ItemNormalizer::ALLOW_CLIENT_GENERATED_ID)); + } + // Fall through: client id is merged into the denormalized payload below. + } elseif (true !== ($context['api_allow_update'] ?? true)) { + throw new NotNormalizableValueException('Update is not allowed for this operation.'); + } else { + $context += ['fetch_data' => false]; + if ($this->useIriAsId) { + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['data']['id'], $context); + } elseif ($operation instanceof HttpOperation) { + $iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation); + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context); + } + } + } + + $dataToDenormalize = array_merge( + $data['data']['attributes'] ?? [], + $data['data']['relationships'] ?? [] + ); + + // Surface the client-generated id so the entity setter receives it. + if ($isPostOperation && $allowClientGeneratedId && isset($data['data']['id'])) { + $dataToDenormalize['id'] = $data['data']['id']; + } + + return parent::denormalize($dataToDenormalize, $type, $format, $context); + } + + protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool + { + return preg_match('/^\\w[-\\w_]*$/', $attribute) && parent::isAllowedAttribute($classOrObject, $attribute, $format, $context); + } + + protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void + { + parent::setAttributeValue($object, $attribute, \is_array($value) && \array_key_exists('data', $value) ? $value['data'] : $value, $format, $context); + } + + /** + * @see http://jsonapi.org/format/#document-resource-object-linkage + * + * @throws RuntimeException + * @throws UnexpectedValueException + */ + protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object + { + if (!\is_array($value) || !isset($value['id'], $value['type'])) { + throw new UnexpectedValueException('Only resource linkage supported currently, see: http://jsonapi.org/format/#document-resource-object-linkage.'); + } + + try { + $context += ['fetch_data' => true]; + if ($this->useIriAsId) { + return $this->iriConverter->getResourceFromIri($value['id'], $context); + } + + /** @var HttpOperation $getOperation */ + $getOperation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(httpOperation: true); + $iri = $this->reconstructIri($className, (string) $value['id'], $getOperation); + + return $this->iriConverter->getResourceFromIri($iri, $context); + } catch (ItemNotFoundException $e) { + if (!isset($context['not_normalizable_value_exceptions'])) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + $context['not_normalizable_value_exceptions'][] = NotNormalizableValueException::createForUnexpectedDataType( + $e->getMessage(), + $value, + [$className], + $context['deserialization_path'] ?? null, + true, + $e->getCode(), + $e + ); + + return null; + } + } + + /** + * Maps the id to the operation's single URI variable parameter and generates the IRI. + * Composite identifiers on a single Link work naturally since the composite string + * (e.g. "field1=val1;field2=val2") is passed as-is. + */ + private function reconstructIri(string $resourceClass, string $id, HttpOperation $operation): string + { + $uriVariables = $operation->getUriVariables() ?? []; + + if (\count($uriVariables) > 1) { + throw new UnexpectedValueException(\sprintf('JSON:API entity identifier mode requires operations with a single URI variable, operation "%s" has %d. Consider adding a NotExposed Get operation on the resource.', $operation->getName() ?? $operation->getUriTemplate(), \count($uriVariables))); + } + + $parameterName = array_key_first($uriVariables) ?? 'id'; + + return $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => [$parameterName => $id]]); + } +} diff --git a/src/JsonApi/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php b/src/JsonApi/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php index 0b0d46b6452..a1865ab1942 100644 --- a/src/JsonApi/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php +++ b/src/JsonApi/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php @@ -62,7 +62,7 @@ protected function setUp(): void ); } - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), diff --git a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php index 648cfd87701..2c11df844d5 100644 --- a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php +++ b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php @@ -59,7 +59,7 @@ protected function setUp(): void $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_INPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -316,7 +316,7 @@ private function buildSchemaFactoryWithPolymorphicRelation(): SchemaFactory $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); $resourceClassResolver->isResourceClass(OtherRelatedDummy::class)->willReturn(true); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -377,7 +377,7 @@ private function buildSchemaFactoryWithRelation(): SchemaFactory $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), diff --git a/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php new file mode 100644 index 00000000000..24956eea3cb --- /dev/null +++ b/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonApi\Tests\Serializer; + +use ApiPlatform\JsonApi\Serializer\ItemDenormalizer; +use ApiPlatform\JsonApi\Serializer\ItemNormalizer; +use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; + +class ItemDenormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportsDenormalizationOnlyForJsonApiFormat(): void + { + $dummy = new Dummy(); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($denormalizer->supportsNormalization($dummy, ItemNormalizer::FORMAT)); + $this->assertTrue($denormalizer->supportsDenormalization($dummy, Dummy::class, ItemNormalizer::FORMAT)); + $this->assertFalse($denormalizer->supportsDenormalization($dummy, Dummy::class, 'jsonld')); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testDenormalizeOnLegacyItemNormalizerIsDeprecated(): void + { + $this->expectUserDeprecationMessage('Since api-platform/core 4.4: Calling "denormalize()" on "ApiPlatform\JsonApi\Serializer\ItemNormalizer" is deprecated, use "ApiPlatform\JsonApi\Serializer\ItemDenormalizer" instead.'); + $this->expectException(NotNormalizableValueException::class); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $normalizer->denormalize( + ['data' => ['id' => '/dummies/1']], + Dummy::class, + ItemNormalizer::FORMAT, + ['api_allow_update' => false] + ); + } +} diff --git a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php index 374675a7e65..4472b372915 100644 --- a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php @@ -34,10 +34,10 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\HttpFoundation\EventStreamResponse; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -51,6 +51,7 @@ /** * @author Amrouche Hamza */ +#[IgnoreDeprecations] class ItemNormalizerTest extends TestCase { use ProphecyTrait; @@ -216,11 +217,9 @@ public function testNormalizeCircularReference(): void $normalizer->setSerializer($this->prophesize(SerializerInterface::class)->reveal()); - // Symfony >= 7.3 - $splObject = class_exists(EventStreamResponse::class) ? spl_object_id($circularReferenceEntity) : spl_object_hash($circularReferenceEntity); $context = [ 'circular_reference_limit' => 2, - 'circular_reference_limit_counters' => [$splObject => 2], + 'circular_reference_limit_counters' => [spl_object_id($circularReferenceEntity) => 2], 'cache_error' => static function (): void {}, ]; diff --git a/src/JsonApi/Util/ResourceLinkageResolver.php b/src/JsonApi/Util/ResourceLinkageResolver.php index 73cacb9d67c..9c4757347cb 100644 --- a/src/JsonApi/Util/ResourceLinkageResolver.php +++ b/src/JsonApi/Util/ResourceLinkageResolver.php @@ -16,7 +16,6 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\TypeHelper; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -48,25 +47,6 @@ public function getRelationships(ApiProperty $propertyMetadata): array { $relationships = []; - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $type) { - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - if ($collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { - $relationships[] = [$className, true]; - } - - continue; - } - - if (($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { - $relationships[] = [$className, false]; - } - } - - return $relationships; - } - if (null === $type = $propertyMetadata->getNativeType()) { return $relationships; } diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index ef3ba825579..ee99a11f556 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -22,20 +22,20 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.3", - "api-platform/json-schema": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3.12", - "api-platform/state": "^4.3", - "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", - "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "api-platform/documentation": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/metadata": "^5.0.0-alpha.3", + "api-platform/serializer": "^5.0.0-alpha.2", + "api-platform/state": "^5.0@alpha", + "symfony/error-handler": "^7.4 || ^8.0", + "symfony/http-foundation": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -57,13 +57,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/JsonLd/Action/ContextAction.php b/src/JsonLd/Action/ContextAction.php index 74c144ed81b..faa3b0b67f2 100644 --- a/src/JsonLd/Action/ContextAction.php +++ b/src/JsonLd/Action/ContextAction.php @@ -32,11 +32,6 @@ */ final class ContextAction { - public const RESERVED_SHORT_NAMES = [ - 'ConstraintViolationList' => true, - 'Error' => true, - ]; - public function __construct( private readonly ContextBuilderInterface $contextBuilder, private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, @@ -90,11 +85,6 @@ private function getContext(string $shortName): ?array return ['@context' => $this->contextBuilder->getEntrypointContext()]; } - // TODO: remove this, exceptions are resources since 3.2 - if (isset(self::RESERVED_SHORT_NAMES[$shortName])) { - return ['@context' => $this->contextBuilder->getBaseContext()]; - } - foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 18ec68f475a..65fa1f58985 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -82,19 +82,8 @@ public function getResourceContext(string $resourceClass, int $referenceType = U { /** @var HttpOperation $operation */ $operation = $this->resourceMetadataFactory->create($resourceClass)->getOperation(null, false, true); - if (null === $shortName = $operation->getShortName()) { - return []; - } - - $context = $operation->getNormalizationContext(); - if ($context['iri_only'] ?? false) { - $context = $this->getBaseContext($referenceType); - $context[$this->getHydraPrefix($context).'member']['@type'] = '@id'; - - return $context; - } - return $this->getResourceContextWithShortname($resourceClass, $referenceType, $shortName, $operation); + return $this->getResourceContextFromOperation($operation, $resourceClass, $referenceType); } /** @@ -103,11 +92,8 @@ public function getResourceContext(string $resourceClass, int $referenceType = U public function getResourceContextUri(string $resourceClass, ?int $referenceType = null): string { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass)[0]; - if (null === $referenceType) { - $referenceType = $resourceMetadata->getUrlGenerationStrategy(); - } - return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $resourceMetadata->getShortName()], $referenceType ?? UrlGeneratorInterface::ABS_PATH); + return $this->generateContextUri($resourceMetadata->getShortName(), $referenceType ?? $resourceMetadata->getUrlGenerationStrategy()); } /** @@ -155,12 +141,6 @@ public function getAnonymousResourceContext(object $object, array $context = [], unset($jsonLdContext['@context']); } - // here the object can be different from the resource given by the $context['api_resource'] value - // TODO: this is probably not used anymore and is slow we get that @type way earlier, remove this - if (isset($context['api_resource'])) { - $jsonLdContext['@type'] = $this->resourceMetadataFactory->create($this->getObjectClass($context['api_resource']))[0]->getShortName(); - } - return $jsonLdContext; } @@ -169,11 +149,7 @@ public function getAnonymousResourceContext(object $object, array $context = [], */ public function getResourceContextUriFromOperation(HttpOperation $operation, ?int $referenceType = null): string { - if (null === $referenceType) { - $referenceType = $operation->getUrlGenerationStrategy(); - } - - return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $operation->getShortName()], $referenceType ?? UrlGeneratorInterface::ABS_PATH); + return $this->generateContextUri($operation->getShortName(), $referenceType ?? $operation->getUrlGenerationStrategy()); } /** @@ -196,9 +172,19 @@ public function getResourceContextFromOperation(HttpOperation $operation, string return $this->getResourceContextWithShortname($resourceClass, $referenceType, $shortName, $operation); } + private function generateContextUri(?string $shortName, ?int $referenceType): string + { + return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $shortName], $referenceType ?? UrlGeneratorInterface::ABS_PATH); + } + private function getResourceContextWithShortname(string $resourceClass, int $referenceType, string $shortName, ?HttpOperation $operation = null): array { $context = $this->getBaseContext($referenceType); + + if ($operation && $jsonldContext = $operation->getJsonldContext()) { + $context = array_merge($context, $jsonldContext); + } + $propertyContext = $operation ? ['normalization_groups' => $operation->getNormalizationContext()['groups'] ?? null, 'denormalization_groups' => $operation->getDenormalizationContext()['groups'] ?? null] : ['normalization_groups' => [], 'denormalization_groups' => []]; foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { diff --git a/src/JsonLd/Serializer/ItemDenormalizer.php b/src/JsonLd/Serializer/ItemDenormalizer.php new file mode 100644 index 00000000000..c1f3d53acd0 --- /dev/null +++ b/src/JsonLd/Serializer/ItemDenormalizer.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonLd\Serializer; + +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Serializer\AbstractItemNormalizer; +use ApiPlatform\Serializer\OperationResourceClassResolverInterface; +use ApiPlatform\Serializer\TagCollectorInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Converts JSON-LD data to objects (denormalization only). + * + * @author Kévin Dunglas + */ +final class ItemDenormalizer extends AbstractItemNormalizer +{ + use ItemNormalizerTrait; + + public const FORMAT = 'jsonld'; + + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) + { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return false; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); + } + + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; + } +} diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 54b01cdac20..65436d63955 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -15,7 +15,6 @@ use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\JsonLd\ContextBuilderInterface; -use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; @@ -32,7 +31,6 @@ use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\LogicException; -use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -45,32 +43,12 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } use JsonLdContextTrait; public const FORMAT = 'jsonld'; - private const JSONLD_KEYWORDS = [ - '@context', - '@direction', - '@graph', - '@id', - '@import', - '@included', - '@index', - '@json', - '@language', - '@list', - '@nest', - '@none', - '@prefix', - '@propagate', - '@protected', - '@reverse', - '@set', - '@type', - '@value', - '@version', - '@vocab', - ]; public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, private ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) { @@ -152,6 +130,18 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $normalizedData; } + if (!isset($metadata['@type']) && null !== ($type = $this->resolveType($resourceClass, $isResourceClass, $context))) { + $metadata['@type'] = $type; + } + + return $metadata + $normalizedData; + } + + /** + * @return string|array|null + */ + private function resolveType(string $resourceClass, bool $isResourceClass, array $context): string|array|null + { $operation = $context['operation'] ?? null; if ($this->operationMetadataFactory && isset($context['item_uri_template']) && !$operation) { @@ -162,30 +152,30 @@ public function normalize(mixed $data, ?string $format = null, array $context = $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); } - if (!isset($metadata['@type']) && $operation) { - $types = $operation instanceof HttpOperation ? $operation->getTypes() : null; - if (null === $types) { - // TODO: 5.x break on this as this looks wrong, CollectionReferencingItem returns an IRI that point through - // ItemReferencedInCollection but it returns a CollectionReferencingItem therefore we should use the current - // object's class Type and not rely on operation ? - if (isset($context['item_uri_template'])) { - // When the operation comes from item_uri_template, use its shortName directly - // as $resourceClass refers to the collection resource, not the item resource + if (!$operation) { + return null; + } + + $types = $operation instanceof HttpOperation ? $operation->getTypes() : null; + if (null === $types) { + $typeClass = $isResourceClass ? $resourceClass : ($operation->getClass() ?? $resourceClass); + if (isset($context['item_uri_template']) || $operation->getClass() === $typeClass) { + // The operation serves the class being normalized: use its shortName so @type matches the + // (possibly deduplicated) @context. For item_uri_template, $resourceClass is the collection + // resource, so the operation remains authoritative for the item type. + $types = [$operation->getShortName()]; + } else { + // Embedded/related resource: the operation belongs to another class, so fall back to its + // resource-level shortName instead of an operation-specific override. + try { + $types = [$this->resourceMetadataCollectionFactory->create($typeClass)[0]->getShortName()]; + } catch (\Exception) { $types = [$operation->getShortName()]; - } else { - // Use resource-level shortName to avoid operation-specific overrides - $typeClass = $isResourceClass ? $resourceClass : ($operation->getClass() ?? $resourceClass); - try { - $types = [$this->resourceMetadataCollectionFactory->create($typeClass)[0]->getShortName()]; - } catch (\Exception) { - $types = [$operation->getShortName()]; - } } } - $metadata['@type'] = 1 === \count($types) ? $types[0] : $types; } - return $metadata + $normalizedData; + return 1 === \count($types) ? $types[0] : $types; } /** @@ -196,43 +186,10 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); } - /** - * {@inheritdoc} - * - * @throws NotNormalizableValueException - */ public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { - // Avoid issues with proxies if we populated the object - if (isset($data['@id']) && !isset($context[self::OBJECT_TO_POPULATE])) { - if (true !== ($context['api_allow_update'] ?? true)) { - throw new NotNormalizableValueException('Update is not allowed for this operation.'); - } - - try { - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['@id'], $context + ['fetch_data' => true], $context['operation'] ?? null); - } catch (ItemNotFoundException $e) { - $operation = $context['operation'] ?? null; - - if (!('PUT' === $operation?->getMethod() && ($operation->getExtraProperties()['standard_put'] ?? true))) { - throw $e; - } - } - } elseif (isset($data['@id']) && ($context['deep_object_to_populate'] ?? false)) { - // the object to populate is the relation currently linked to the parent, an explicit @id must replace it instead of mutating it in place - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['@id'], $context + ['fetch_data' => true], $context['operation'] ?? null); - } - - return parent::denormalize($data, $type, $format, $context); - } - - protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool - { - $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); - if (\is_array($allowedAttributes) && ($context['api_denormalize'] ?? false)) { - $allowedAttributes = array_merge($allowedAttributes, self::JSONLD_KEYWORDS); - } + trigger_deprecation('api-platform/core', '4.4', 'Calling "denormalize()" on "%s" is deprecated, use "%s" instead.', self::class, ItemDenormalizer::class); - return $allowedAttributes; + return $this->doDenormalize($data, $type, $format, $context); } } diff --git a/src/JsonLd/Serializer/ItemNormalizerTrait.php b/src/JsonLd/Serializer/ItemNormalizerTrait.php new file mode 100644 index 00000000000..08fe7b448ab --- /dev/null +++ b/src/JsonLd/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonLd\Serializer; + +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Serializer\AbstractItemNormalizer; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; + +/** + * Shared denormalization logic for the JSON-LD item (de)normalizer. + * + * @author Kévin Dunglas + * + * @internal + */ +trait ItemNormalizerTrait +{ + private const JSONLD_KEYWORDS = [ + '@context', + '@direction', + '@graph', + '@id', + '@import', + '@included', + '@index', + '@json', + '@language', + '@list', + '@nest', + '@none', + '@prefix', + '@propagate', + '@protected', + '@reverse', + '@set', + '@type', + '@value', + '@version', + '@vocab', + ]; + + /** + * @throws NotNormalizableValueException + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + // Avoid issues with proxies if we populated the object + if (isset($data['@id']) && !isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) { + if (true !== ($context['api_allow_update'] ?? true)) { + throw new NotNormalizableValueException('Update is not allowed for this operation.'); + } + + try { + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['@id'], $context + ['fetch_data' => true], $context['operation'] ?? null); + } catch (ItemNotFoundException $e) { + $operation = $context['operation'] ?? null; + + if (!('PUT' === $operation?->getMethod() && ($operation->getExtraProperties()['standard_put'] ?? true))) { + throw $e; + } + } + } elseif (isset($data['@id']) && ($context['deep_object_to_populate'] ?? false)) { + // the object to populate is the relation currently linked to the parent, an explicit @id must replace it instead of mutating it in place + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['@id'], $context + ['fetch_data' => true], $context['operation'] ?? null); + } + + return parent::denormalize($data, $type, $format, $context); + } + + protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool + { + $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); + if (\is_array($allowedAttributes) && ($context['api_denormalize'] ?? false)) { + $allowedAttributes = array_merge($allowedAttributes, self::JSONLD_KEYWORDS); + } + + return $allowedAttributes; + } +} diff --git a/src/JsonLd/Serializer/JsonLdContextTrait.php b/src/JsonLd/Serializer/JsonLdContextTrait.php index 34d7e8bbe18..9a7320caa8a 100644 --- a/src/JsonLd/Serializer/JsonLdContextTrait.php +++ b/src/JsonLd/Serializer/JsonLdContextTrait.php @@ -59,9 +59,7 @@ private function addJsonLdContext(ContextBuilderInterface $contextBuilder, strin private function createJsonLdContext(AnonymousContextBuilderInterface $contextBuilder, object $object, array &$context): array { - $anonymousContext = ($context['output'] ?? []) + [ - 'api_resource' => $context['api_resource'] ?? null, - ]; + $anonymousContext = $context['output'] ?? []; if (isset($context['item_uri_template'])) { $anonymousContext['item_uri_template'] = $context['item_uri_template']; diff --git a/src/JsonLd/Serializer/ObjectNormalizer.php b/src/JsonLd/Serializer/ObjectNormalizer.php index 24755c41e3b..9631ff755ea 100644 --- a/src/JsonLd/Serializer/ObjectNormalizer.php +++ b/src/JsonLd/Serializer/ObjectNormalizer.php @@ -14,7 +14,6 @@ namespace ApiPlatform\JsonLd\Serializer; use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; -use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\IriConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -53,11 +52,6 @@ public function getSupportedTypes(?string $format): array */ public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { - if (isset($context['api_resource'])) { - $originalResource = $context['api_resource']; - unset($context['api_resource']); - } - /* * Converts the normalized data array of a resource into an IRI, if the * normalized data array is empty. @@ -75,15 +69,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $normalizedData; } - if (isset($originalResource)) { - try { - $context['output']['iri'] = $this->iriConverter->getIriFromResource($originalResource); - } catch (InvalidArgumentException) { - // The original resource has no identifiers - } - $context['api_resource'] = $originalResource; - } - $metadata = $this->createJsonLdContext($this->anonymousContextBuilder, $data, $context); return $metadata + $normalizedData; diff --git a/src/JsonLd/composer.json b/src/JsonLd/composer.json index 7c87f7ac5a7..892a53438f9 100644 --- a/src/JsonLd/composer.json +++ b/src/JsonLd/composer.json @@ -24,9 +24,9 @@ ], "require": { "php": ">=8.2", - "api-platform/state": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3.12" + "api-platform/state": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha" }, "autoload": { "psr-4": { @@ -51,13 +51,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", @@ -68,7 +68,7 @@ "test": "./vendor/bin/phpunit" }, "require-dev": { - "symfony/type-info": "^7.3 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", "phpunit/phpunit": "^11.5 || ^12.2" }, "minimum-stability": "beta", diff --git a/src/JsonSchema/DefinitionNameFactory.php b/src/JsonSchema/DefinitionNameFactory.php index 360f223357f..ce54a253011 100644 --- a/src/JsonSchema/DefinitionNameFactory.php +++ b/src/JsonSchema/DefinitionNameFactory.php @@ -26,13 +26,6 @@ final class DefinitionNameFactory implements DefinitionNameFactoryInterface private array $prefixCache = []; - public function __construct(private ?array $distinctFormats = null) - { - if ($distinctFormats) { - trigger_deprecation('api-platform/json-schema', '4.2', 'The distinctFormats argument is deprecated and will be removed in 5.0.'); - } - } - public function create(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): string { if ($operation) { @@ -50,10 +43,7 @@ public function create(string $className, string $format = 'json', ?string $inpu $prefix .= self::GLUE.$this->createPrefixFromClass($inputOrOutputClass); } - // TODO: remove in 5.0 - $v = $this->distinctFormats ? ($this->distinctFormats[$format] ?? false) : true; - - if (!\in_array($format, ['json', 'merge-patch+json'], true) && $v) { + if (!\in_array($format, ['json', 'merge-patch+json'], true)) { // JSON is the default, and so isn't included in the definition name // JSON merge patch is postfixed at the end $prefix .= self::GLUE.$format; diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 540a9230771..50c2b66de7d 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -19,10 +19,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; -use Doctrine\Common\Collections\ArrayCollection; use Ramsey\Uuid\UuidInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -76,23 +73,8 @@ public function create(string $resourceClass, string $property, array $options = // on output a non-resource object is serialized by the standard object normalizer, which embeds non-resource properties regardless of readableLink (see AbstractItemNormalizer::supportsNormalization()) // For resource-typed properties however, the circular reference handler (see AbstractItemNormalizer::$defaultContext) may produce an IRI, so isReadableLink should determine the schema if (!$isInput && !$this->isResourceClass($resourceClass)) { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - if (!$propertyMetadata->getNativeType()?->isSatisfiedBy(fn (Type $t) => $t instanceof ObjectType && $this->resourceClassResolver->isResourceClass($t->getClassName()))) { - $link = true; - } - } else { - $propertyTypeIsResource = false; - foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $builtinType) { - $className = $builtinType->isCollection() ? ($builtinType->getCollectionValueTypes()[0] ?? null)?->getClassName() : $builtinType->getClassName(); - if ($className && $this->resourceClassResolver->isResourceClass($className)) { - $propertyTypeIsResource = true; - break; - } - } - - if (!$propertyTypeIsResource) { - $link = true; - } + if (!$propertyMetadata->getNativeType()?->isSatisfiedBy(fn (Type $t) => $t instanceof ObjectType && $this->resourceClassResolver->isResourceClass($t->getClassName()))) { + $link = true; } } @@ -121,10 +103,6 @@ public function create(string $resourceClass, string $property, array $options = $propertySchema['externalDocs'] = ['url' => $iri]; } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - return $propertyMetadata->withSchema($this->getLegacyTypeSchema($propertyMetadata, $propertySchema, $resourceClass, $property, $link)); - } - return $propertyMetadata->withSchema($this->getTypeSchema($propertyMetadata, $propertySchema, $link)); } @@ -368,235 +346,6 @@ private function getClassSchemaDefinition(?string $className, ?bool $readableLin return ['type' => Schema::UNKNOWN_TYPE]; } - private function getLegacyTypeSchema(ApiProperty $propertyMetadata, array $propertySchema, string $resourceClass, string $property, ?bool $link): array - { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - $className = ($types[0] ?? null)?->getClassName() ?? null; - - if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) && !$className) { - $propertySchema['readOnly'] = true; - } - - if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!$className || !$this->isResourceClass($className))) { - if ($default instanceof \BackedEnum) { - $default = $default->value; - } - $propertySchema['default'] = $default; - } - - if (!\array_key_exists('example', $propertySchema) && !empty($example = $propertyMetadata->getExample())) { - $propertySchema['example'] = $example; - } - - // never override the following keys if at least one is already set or if there's a custom openapi context - if ( - [] === $types - || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) - || \array_key_exists('type', $propertyMetadata->getOpenapiContext() ?? []) - ) { - return $propertySchema; - } - - if ($propertyMetadata->getUriTemplate()) { - return $propertySchema + [ - 'type' => 'string', - 'format' => 'iri-reference', - 'example' => 'https://example.com/', - ]; - } - - $valueSchema = []; - foreach ($types as $type) { - // Temp fix for https://github.com/symfony/symfony/pull/52699 - if (ArrayCollection::class === $type->getClassName()) { - $type = new LegacyType($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), true, $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); - } - - if ($isCollection = $type->isCollection()) { - $keyType = $type->getCollectionKeyTypes()[0] ?? null; - $valueType = $type->getCollectionValueTypes()[0] ?? null; - } else { - $keyType = null; - $valueType = $type; - } - - if (null === $valueType) { - $builtinType = 'string'; - $className = null; - } else { - $builtinType = $valueType->getBuiltinType(); - $className = $valueType->getClassName(); - } - - if ($isCollection && null !== $propertyMetadata->getUriTemplate()) { - $keyType = null; - $isCollection = false; - } - - $propertyType = $this->getLegacyType(new LegacyType($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $link); - if (!\in_array($propertyType, $valueSchema, true)) { - $valueSchema[] = $propertyType; - } - } - - if (1 === \count($valueSchema)) { - return $propertySchema + $valueSchema[0]; - } - - // multiple builtInTypes detected: determine oneOf/allOf if union vs intersect types - try { - $reflectionClass = new \ReflectionClass($resourceClass); - $reflectionProperty = $reflectionClass->getProperty($property); - $composition = $reflectionProperty->getType() instanceof \ReflectionUnionType ? 'oneOf' : 'allOf'; - } catch (\ReflectionException) { - // cannot detect types - $composition = 'anyOf'; - } - - return $propertySchema + [$composition => $valueSchema]; - } - - private function getLegacyType(LegacyType $type, ?bool $readableLink = null): array - { - if (!$type->isCollection()) { - return $this->addNullabilityToTypeDefinition($this->legacyTypeToArray($type, $readableLink), $type); - } - - $keyType = $type->getCollectionKeyTypes()[0] ?? null; - $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new LegacyType($type->getBuiltinType(), false, $type->getClassName(), false); - - if (null !== $keyType && LegacyType::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { - return $this->addNullabilityToTypeDefinition([ - 'type' => 'object', - 'additionalProperties' => $this->getLegacyType($subType, $readableLink), - ], $type); - } - - return $this->addNullabilityToTypeDefinition([ - 'type' => 'array', - 'items' => $this->getLegacyType($subType, $readableLink), - ], $type); - } - - private function legacyTypeToArray(LegacyType $type, ?bool $readableLink = null): array - { - return match ($type->getBuiltinType()) { - LegacyType::BUILTIN_TYPE_INT => ['type' => 'integer'], - LegacyType::BUILTIN_TYPE_FLOAT => ['type' => 'number'], - LegacyType::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], - LegacyType::BUILTIN_TYPE_OBJECT => $this->getLegacyClassType($type->getClassName(), $type->isNullable(), $readableLink), - default => ['type' => 'string'], - }; - } - - /** - * Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided. - * - * Note: if the class is not part of exceptions listed above, any class is considered as a resource. - * - * @throws PropertyNotFoundException - * - * @return array - */ - private function getLegacyClassType(?string $className, bool $nullable, ?bool $readableLink): array - { - if (null === $className) { - return ['type' => 'string']; - } - - if (is_a($className, \DateTimeInterface::class, true)) { - return [ - 'type' => 'string', - 'format' => 'date-time', - ]; - } - - if (is_a($className, \DateInterval::class, true)) { - return [ - 'type' => 'string', - 'format' => 'duration', - ]; - } - - if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) { - return [ - 'type' => 'string', - 'format' => 'uuid', - ]; - } - - if (is_a($className, Ulid::class, true)) { - return [ - 'type' => 'string', - 'format' => 'ulid', - ]; - } - - if (is_a($className, \SplFileInfo::class, true)) { - return [ - 'type' => 'string', - 'format' => 'binary', - ]; - } - - if (is_a($className, \BcMath\Number::class, true)) { - return [ - 'type' => 'string', - 'format' => 'string', - ]; - } - - $isResourceClass = $this->isResourceClass($className); - if (!$isResourceClass && is_a($className, \BackedEnum::class, true)) { - $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases()); - - $type = \is_string($enumCases[0] ?? '') ? 'string' : 'integer'; - - if ($nullable) { - $enumCases[] = null; - } - - return [ - 'type' => $type, - 'enum' => $enumCases, - ]; - } - - if (false === $readableLink && $isResourceClass) { - return [ - 'type' => 'string', - 'format' => 'iri-reference', - 'example' => 'https://example.com/', - ]; - } - - // When this is set, we compute the schema at SchemaFactory::buildPropertySchema as it - // will end up being a $ref to another class schema, we don't have enough informations here - return ['type' => Schema::UNKNOWN_TYPE]; - } - - /** - * @param array $jsonSchema - * - * @return array - */ - private function addNullabilityToTypeDefinition(array $jsonSchema, LegacyType $type): array - { - if (!$type->isNullable()) { - return $jsonSchema; - } - - if (\array_key_exists('$ref', $jsonSchema)) { - return ['anyOf' => [$jsonSchema, ['type' => 'null']]]; - } - - return [...$jsonSchema, ...[ - 'type' => \is_array($jsonSchema['type']) - ? array_merge($jsonSchema['type'], ['null']) - : [$jsonSchema['type'], 'null'], - ]]; - } - private function getSchemaValue(array $schema, string $key): array|string|null { if (isset($schema['items'])) { diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index b92d3754a12..fda6ad7773c 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -23,7 +23,6 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\TypeHelper; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\TypeInfo\Type\BuiltinType; @@ -49,10 +48,10 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; public const PARTIAL_UPDATE = 'partial_update'; - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, ?array $distinctFormats = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) { if (!$definitionNameFactory) { - $this->definitionNameFactory = new DefinitionNameFactory($distinctFormats); + $this->definitionNameFactory = new DefinitionNameFactory(); } $this->resourceMetadataFactory = $resourceMetadataFactory; @@ -164,150 +163,12 @@ public function buildSchema(string $className, string $format = 'json', string $ $definition['required'][] = $normalizedPropertyName; } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->buildLegacyPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type); - } else { - $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type); - } + $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type); } return $schema; } - /** - * Builds the JSON Schema for a property using the legacy PropertyInfo component. - */ - private function buildLegacyPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void - { - $version = $schema->getVersion(); - if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { - $additionalPropertySchema = $propertyMetadata->getOpenapiContext(); - } else { - $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext(); - } - - $propertySchema = array_merge( - $propertyMetadata->getSchema() ?? [], - $additionalPropertySchema ?? [] - ); - - // @see https://github.com/api-platform/core/issues/6299 - if (Schema::UNKNOWN_TYPE === ($propertySchema['type'] ?? null) && isset($propertySchema['$ref'])) { - unset($propertySchema['type']); - } - - $extraProperties = $propertyMetadata->getExtraProperties(); - // see AttributePropertyMetadataFactory - if (true === ($extraProperties[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) { - // schema seems to have been declared by the user: do not override nor complete user value - $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); - - return; - } - - $types = $propertyMetadata->getBuiltinTypes() ?? []; - - // never override the following keys if at least one is already set - // or if property has no type(s) defined - // or if property schema is already fully defined (type=string + format || enum) - $propertySchemaType = $propertySchema['type'] ?? false; - - $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType - || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null)) - || ('object' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['additionalProperties']['type'] ?? null)); - - // Scalar properties - if ( - !$isUnknown && ( - [] === $types - || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) - || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType) - || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false) - ) - ) { - if (isset($propertySchema['$ref'])) { - unset($propertySchema['type']); - } - - $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); - - return; - } - - // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref) - // complete property schema with resource reference ($ref) only if it's related to an object - $version = $schema->getVersion(); - $refs = []; - $isNullable = null; - - foreach ($types as $type) { - $subSchema = new Schema($version); - $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema - - $isCollection = $type->isCollection(); - if ($isCollection) { - $valueType = $type->getCollectionValueTypes()[0] ?? null; - } else { - $valueType = $type; - } - - $className = $valueType?->getClassName(); - if (null === $className) { - continue; - } - - $childSerializerContext = $serializerContext + [self::FORCE_SUBSCHEMA => true, 'gen_id' => $propertyMetadata->getGenId() ?? true]; - if (isset($serializerContext[AbstractNormalizer::ATTRIBUTES])) { - $attributes = $serializerContext[AbstractNormalizer::ATTRIBUTES]; - if (\is_array($attributes) && \array_key_exists($normalizedPropertyName, $attributes) && \is_array($attributes[$normalizedPropertyName])) { - $childSerializerContext[AbstractNormalizer::ATTRIBUTES] = $attributes[$normalizedPropertyName]; - } else { - unset($childSerializerContext[AbstractNormalizer::ATTRIBUTES]); - } - } - - $subSchemaFactory = $this->schemaFactory ?: $this; - $subSchema = $subSchemaFactory->buildSchema( - $className, - $format, - $parentType, - null, - $subSchema, - $childSerializerContext, - false, - ); - - if (!isset($subSchema['$ref'])) { - continue; - } - - if ($isCollection) { - $key = ($propertySchema['type'] ?? null) === 'object' ? 'additionalProperties' : 'items'; - $propertySchema[$key]['$ref'] = $subSchema['$ref']; - unset($propertySchema[$key]['type']); - break; - } - - $refs[] = ['$ref' => $subSchema['$ref']]; - $isNullable = $isNullable ?? $type->isNullable(); - } - - if ($isNullable) { - $refs[] = ['type' => 'null']; - } - - $c = \count($refs); - if ($c > 1) { - $propertySchema['anyOf'] = $refs; - unset($propertySchema['type']); - } elseif (1 === $c) { - $propertySchema['$ref'] = $refs[0]['$ref']; - unset($propertySchema['type']); - } - - $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); - } - private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void { $version = $schema->getVersion(); diff --git a/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php b/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php index c89fa1ecdce..901b7ed7efc 100644 --- a/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php +++ b/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php @@ -24,30 +24,12 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; class SchemaPropertyMetadataFactoryTest extends TestCase { - #[IgnoreDeprecations] - public function testEnumLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api_platform/metadata 4.2: The "builtinTypes" argument of "ApiPlatform\Metadata\ApiProperty" is deprecated, use "nativeType" instead.'); - $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); - $apiProperty = new ApiProperty(builtinTypes: [new LegacyType(builtinType: 'object', nullable: true, class: IntEnumAsIdentifier::class)]); - $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); - $decorated->expects($this->once())->method('create')->with(DummyWithEnum::class, 'intEnumAsIdentifier')->willReturn($apiProperty); - $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); - $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'intEnumAsIdentifier'); - $this->assertEquals(['type' => ['integer', 'null'], 'enum' => [1, 2, null]], $apiProperty->getSchema()); - } - public function testEnum(): void { $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); @@ -59,25 +41,6 @@ public function testEnum(): void $this->assertEquals(['type' => ['integer', 'null'], 'enum' => [1, 2, null]], $apiProperty->getSchema()); } - #[IgnoreDeprecations] - public function testWithCustomOpenApiContextLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api_platform/metadata 4.2: The "builtinTypes" argument of "ApiPlatform\Metadata\ApiProperty" is deprecated, use "nativeType" instead.'); - $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); - $apiProperty = new ApiProperty( - builtinTypes: [new LegacyType(builtinType: 'object', nullable: true, class: IntEnumAsIdentifier::class)], - openapiContext: ['type' => 'object', 'properties' => ['alpha' => ['type' => 'integer']]], - ); - $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); - $decorated->expects($this->once())->method('create')->with(DummyWithCustomOpenApiContext::class, 'acme')->willReturn($apiProperty); - $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); - $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithCustomOpenApiContext::class, 'acme'); - $this->assertEquals([], $apiProperty->getSchema()); - } - public function testWithCustomOpenApiContext(): void { $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); @@ -92,40 +55,6 @@ public function testWithCustomOpenApiContext(): void $this->assertEquals([], $apiProperty->getSchema()); } - #[IgnoreDeprecations] - public function testWithCustomOpenApiContextWithoutTypeDefinitionLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api_platform/metadata 4.2: The "builtinTypes" argument of "ApiPlatform\Metadata\ApiProperty" is deprecated, use "nativeType" instead.'); - $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); - $apiProperty = new ApiProperty( - openapiContext: ['description' => 'My description'], - builtinTypes: [new LegacyType(builtinType: 'bool')], - ); - $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); - $decorated->expects($this->once())->method('create')->with(DummyWithCustomOpenApiContext::class, 'foo')->willReturn($apiProperty); - $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); - $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithCustomOpenApiContext::class, 'foo'); - $this->assertEquals([ - 'type' => 'boolean', - ], $apiProperty->getSchema()); - - $apiProperty = new ApiProperty( - openapiContext: ['iris' => 'https://schema.org/Date'], - builtinTypes: [new LegacyType(builtinType: 'object', class: \DateTimeImmutable::class)], - ); - $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); - $decorated->expects($this->once())->method('create')->with(DummyWithCustomOpenApiContext::class, 'bar')->willReturn($apiProperty); - $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); - $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithCustomOpenApiContext::class, 'bar'); - $this->assertEquals([ - 'type' => 'string', - 'format' => 'date-time', - ], $apiProperty->getSchema()); - } - public function testWithCustomOpenApiContextWithoutTypeDefinition(): void { $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); diff --git a/src/JsonSchema/Tests/SchemaFactoryTest.php b/src/JsonSchema/Tests/SchemaFactoryTest.php index aa608484854..d61a8adf6ab 100644 --- a/src/JsonSchema/Tests/SchemaFactoryTest.php +++ b/src/JsonSchema/Tests/SchemaFactoryTest.php @@ -38,12 +38,10 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\TypeInfo\Type; @@ -51,86 +49,6 @@ class SchemaFactoryTest extends TestCase { use ProphecyTrait; - #[IgnoreDeprecations] - public function testBuildSchemaForNonResourceClassLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api-platform/metadata 4.2: The "ApiPlatform\Metadata\ApiProperty::withBuiltinTypes()" method is deprecated, use "ApiPlatform\Metadata\ApiProperty::withNativeType()" instead.'); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar', 'genderType'])); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]) - ->withReadable(true) - ->withSchema(['type' => 'string']) - ); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]) - ->withReadable(true) - ->withDefault('default_bar') - ->withExample('example_bar') - ->withSchema(['type' => 'integer', 'default' => 'default_bar', 'example' => 'example_bar']) - ); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'genderType', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT)]) - ->withReadable(true) - ->withDefault('male') - ->withSchema(['type' => 'object', 'default' => 'male', 'example' => 'male']) - ); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - - $definitionNameFactory = new DefinitionNameFactory(); - - $schemaFactory = new SchemaFactory( - resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), - propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), - propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), - resourceClassResolver: $resourceClassResolverProphecy->reveal(), - definitionNameFactory: $definitionNameFactory, - ); - $resultSchema = $schemaFactory->buildSchema(NotAResource::class); - - $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); - $definitions = $resultSchema->getDefinitions(); - - $this->assertSame((new \ReflectionClass(NotAResource::class))->getShortName(), $rootDefinitionKey); - $this->assertTrue(isset($definitions[$rootDefinitionKey])); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); - $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); - $this->assertArrayNotHasKey('additionalProperties', $definitions[$rootDefinitionKey]); - $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); - $this->assertArrayHasKey('foo', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['foo']); - $this->assertArrayNotHasKey('default', $definitions[$rootDefinitionKey]['properties']['foo']); - $this->assertArrayNotHasKey('example', $definitions[$rootDefinitionKey]['properties']['foo']); - $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['foo']['type']); - $this->assertArrayHasKey('bar', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['bar']); - $this->assertArrayHasKey('default', $definitions[$rootDefinitionKey]['properties']['bar']); - $this->assertArrayHasKey('example', $definitions[$rootDefinitionKey]['properties']['bar']); - $this->assertSame('integer', $definitions[$rootDefinitionKey]['properties']['bar']['type']); - $this->assertSame('default_bar', $definitions[$rootDefinitionKey]['properties']['bar']['default']); - $this->assertSame('example_bar', $definitions[$rootDefinitionKey]['properties']['bar']['example']); - - $this->assertArrayHasKey('genderType', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['genderType']); - $this->assertArrayHasKey('default', $definitions[$rootDefinitionKey]['properties']['genderType']); - $this->assertArrayHasKey('example', $definitions[$rootDefinitionKey]['properties']['genderType']); - $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['genderType']['type']); - $this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['default']); - $this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['example']); - } - public function testBuildSchemaForNonResourceClass(): void { if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true @@ -233,80 +151,6 @@ public function testBuildSchemaForNonResourceClass(): void $this->assertSame('#/definitions/GenericChild', $definitions[$rootDefinitionKey]['properties']['items']['$ref']); } - #[IgnoreDeprecations] - public function testBuildSchemaForNonResourceClassWithUnionIntersectTypesLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api-platform/metadata 4.2: The "ApiPlatform\Metadata\ApiProperty::withBuiltinTypes()" method is deprecated, use "ApiPlatform\Metadata\ApiProperty::withNativeType()" instead.'); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, Argument::cetera())->willReturn(new PropertyNameCollection(['ignoredProperty', 'unionType', 'intersectType'])); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'ignoredProperty', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING, nullable: true)]) - ->withReadable(true) - ->withSchema(['type' => ['string', 'null']]) - ); - $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'unionType', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING, nullable: true), new LegacyType(LegacyType::BUILTIN_TYPE_INT, nullable: true), new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT, nullable: true)]) - ->withReadable(true) - ->withSchema(['oneOf' => [ - ['type' => ['string', 'null']], - ['type' => ['integer', 'null']], - ]]) - ); - $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'intersectType', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, class: Serializable::class), new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, class: DummyResourceInterface::class)]) - ->withReadable(true) - ->withSchema(['type' => 'object']) - ); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->isResourceClass(NotAResourceWithUnionIntersectTypes::class)->willReturn(false); - - $definitionNameFactory = new DefinitionNameFactory(); - - $schemaFactory = new SchemaFactory( - resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), - propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), - propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), - resourceClassResolver: $resourceClassResolverProphecy->reveal(), - definitionNameFactory: $definitionNameFactory, - ); - $resultSchema = $schemaFactory->buildSchema(NotAResourceWithUnionIntersectTypes::class); - - $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); - $definitions = $resultSchema->getDefinitions(); - - $this->assertSame((new \ReflectionClass(NotAResourceWithUnionIntersectTypes::class))->getShortName(), $rootDefinitionKey); - $this->assertTrue(isset($definitions[$rootDefinitionKey])); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); - $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); - $this->assertArrayNotHasKey('additionalProperties', $definitions[$rootDefinitionKey]); - $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); - - $this->assertArrayHasKey('ignoredProperty', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['ignoredProperty']); - $this->assertSame(['string', 'null'], $definitions[$rootDefinitionKey]['properties']['ignoredProperty']['type']); - $this->assertArrayHasKey('unionType', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('oneOf', $definitions[$rootDefinitionKey]['properties']['unionType']); - $this->assertCount(2, $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][0]); - $this->assertSame(['string', 'null'], $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][0]['type']); - $this->assertSame(['integer', 'null'], $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][1]['type']); - - $this->assertArrayHasKey('intersectType', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['intersectType']); - $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['intersectType']['type']); - } - public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): void { $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); @@ -377,89 +221,6 @@ public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): voi $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['intersectType']['type']); } - #[IgnoreDeprecations] - public function testBuildSchemaWithSerializerGroupsLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api-platform/metadata 4.2: The "ApiPlatform\Metadata\ApiProperty::withBuiltinTypes()" method is deprecated, use "ApiPlatform\Metadata\ApiProperty::withNativeType()" instead.'); - $shortName = (new \ReflectionClass(OverriddenOperationDummy::class))->getShortName(); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $operation = (new Put())->withName('put')->withNormalizationContext([ - 'groups' => 'overridden_operation_dummy_put', - AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false, - ])->withShortName($shortName)->withValidationContext(['groups' => ['validation_groups_dummy_put']]); - $resourceMetadataFactoryProphecy->create(OverriddenOperationDummy::class) - ->willReturn( - new ResourceMetadataCollection(OverriddenOperationDummy::class, [ - (new ApiResource())->withOperations(new Operations(['put' => $operation])), - ]) - ); - - $serializerGroup = 'custom_operation_dummy'; - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(OverriddenOperationDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['alias', 'description', 'genderType'])); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'alias', Argument::type('array'))->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]) - ->withReadable(true) - ->withSchema(['type' => 'string']) - ); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]) - ->withReadable(true) - ->withSchema(['type' => 'string']) - ); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)]) - ->withReadable(true) - ->withDefault(GenderTypeEnum::MALE) - ->withSchema(['type' => 'object']) - ); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->isResourceClass(OverriddenOperationDummy::class)->willReturn(true); - $resourceClassResolverProphecy->isResourceClass(GenderTypeEnum::class)->willReturn(true); - - $definitionNameFactory = new DefinitionNameFactory(); - - $schemaFactory = new SchemaFactory( - resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), - propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), - propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), - resourceClassResolver: $resourceClassResolverProphecy->reveal(), - definitionNameFactory: $definitionNameFactory, - ); - $resultSchema = $schemaFactory->buildSchema(OverriddenOperationDummy::class, 'json', Schema::TYPE_OUTPUT, null, null, ['groups' => $serializerGroup, AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false]); - - $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); - $definitions = $resultSchema->getDefinitions(); - - $this->assertSame((new \ReflectionClass(OverriddenOperationDummy::class))->getShortName().'-'.$serializerGroup, $rootDefinitionKey); - $this->assertTrue(isset($definitions[$rootDefinitionKey])); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); - $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); - $this->assertFalse($definitions[$rootDefinitionKey]['additionalProperties']); - $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); - $this->assertArrayHasKey('alias', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['alias']); - $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['alias']['type']); - $this->assertArrayHasKey('description', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['description']); - $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['description']['type']); - $this->assertArrayHasKey('genderType', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['genderType']); - $this->assertArrayNotHasKey('default', $definitions[$rootDefinitionKey]['properties']['genderType']); - $this->assertArrayNotHasKey('example', $definitions[$rootDefinitionKey]['properties']['genderType']); - $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['genderType']['type']); - } - public function testBuildSchemaWithSerializerGroups(): void { $shortName = (new \ReflectionClass(OverriddenOperationDummy::class))->getShortName(); @@ -626,63 +387,6 @@ public function testBuildSchemaWithSerializerAttributes(): void $this->assertSame('string', $definitions[$childDefinitionKey]['properties']['name']['type']); } - #[IgnoreDeprecations] - public function testBuildSchemaForAssociativeArrayLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api-platform/metadata 4.2: The "ApiPlatform\Metadata\ApiProperty::withBuiltinTypes()" method is deprecated, use "ApiPlatform\Metadata\ApiProperty::withNativeType()" instead.'); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar'])); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_STRING))]) - ->withReadable(true) - ->withSchema(['type' => 'array', 'items' => ['string', 'int']]) - ); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_STRING), new LegacyType(LegacyType::BUILTIN_TYPE_STRING))]) - ->withReadable(true) - ->withSchema(['type' => 'object', 'additionalProperties' => 'string']) - ); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - - $definitionNameFactory = new DefinitionNameFactory(); - - $schemaFactory = new SchemaFactory( - resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), - propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), - propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), - resourceClassResolver: $resourceClassResolverProphecy->reveal(), - definitionNameFactory: $definitionNameFactory, - ); - $resultSchema = $schemaFactory->buildSchema(NotAResource::class); - - $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); - $definitions = $resultSchema->getDefinitions(); - - $this->assertSame((new \ReflectionClass(NotAResource::class))->getShortName(), $rootDefinitionKey); - $this->assertTrue(isset($definitions[$rootDefinitionKey])); - $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); - $this->assertArrayHasKey('foo', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['foo']); - $this->assertArrayNotHasKey('additionalProperties', $definitions[$rootDefinitionKey]['properties']['foo']); - $this->assertSame('array', $definitions[$rootDefinitionKey]['properties']['foo']['type']); - $this->assertArrayHasKey('bar', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['bar']); - $this->assertArrayHasKey('additionalProperties', $definitions[$rootDefinitionKey]['properties']['bar']); - $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['bar']['type']); - $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['bar']['additionalProperties']); - } - public function testBuildSchemaForAssociativeArray(): void { $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index 0fdfad5584f..500cb80de9a 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -25,12 +25,12 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0" + "api-platform/metadata": "^5.0@alpha", + "symfony/console": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", @@ -56,13 +56,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 09aa4f9f335..7d43ace6e04 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -27,6 +27,7 @@ use ApiPlatform\GraphQl\Serializer\Exception\HttpExceptionNormalizer as GraphQlHttpExceptionNormalizer; use ApiPlatform\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer as GraphQlRuntimeExceptionNormalizer; use ApiPlatform\GraphQl\Serializer\Exception\ValidationExceptionNormalizer as GraphQlValidationExceptionNormalizer; +use ApiPlatform\GraphQl\Serializer\ItemDenormalizer as GraphQlItemDenormalizer; use ApiPlatform\GraphQl\Serializer\ItemNormalizer as GraphQlItemNormalizer; use ApiPlatform\GraphQl\Serializer\ObjectNormalizer as GraphQlObjectNormalizer; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilder as GraphQlSerializerContextBuilder; @@ -62,12 +63,14 @@ use ApiPlatform\JsonApi\Serializer\CollectionNormalizer as JsonApiCollectionNormalizer; use ApiPlatform\JsonApi\Serializer\EntrypointNormalizer as JsonApiEntrypointNormalizer; use ApiPlatform\JsonApi\Serializer\ErrorNormalizer as JsonApiErrorNormalizer; +use ApiPlatform\JsonApi\Serializer\ItemDenormalizer as JsonApiItemDenormalizer; use ApiPlatform\JsonApi\Serializer\ItemNormalizer as JsonApiItemNormalizer; use ApiPlatform\JsonApi\Serializer\ObjectNormalizer as JsonApiObjectNormalizer; use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\JsonLd\ContextBuilder as JsonLdContextBuilder; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\JsonLd\Serializer\ItemDenormalizer as JsonLdItemDenormalizer; use ApiPlatform\JsonLd\Serializer\ItemNormalizer as JsonLdItemNormalizer; use ApiPlatform\JsonLd\Serializer\ObjectNormalizer as JsonLdObjectNormalizer; use ApiPlatform\JsonSchema\DefinitionNameFactory; @@ -105,6 +108,7 @@ use ApiPlatform\Laravel\Security\ResourceAccessChecker; use ApiPlatform\Laravel\Serializer\EloquentOperationResourceClassResolver; use ApiPlatform\Laravel\State\AccessCheckerProvider; +use ApiPlatform\Laravel\State\DenormalizationViolationFactory as LaravelDenormalizationViolationFactory; use ApiPlatform\Laravel\State\SwaggerUiProcessor; use ApiPlatform\Laravel\State\SwaggerUiProvider; use ApiPlatform\Laravel\State\ValidateProvider; @@ -149,6 +153,7 @@ use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; use ApiPlatform\OpenApi\Options; use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer; +use ApiPlatform\Serializer\ItemDenormalizer; use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\JsonEncoder; use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory as SerializerClassMetadataFactory; @@ -157,6 +162,7 @@ use ApiPlatform\Serializer\SerializerContextBuilder; use ApiPlatform\State\CallableProcessor; use ApiPlatform\State\CallableProvider; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\ErrorProvider; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Pagination\PaginationOptions; @@ -458,8 +464,18 @@ public function register(): void ); }); + $this->app->singleton(DenormalizationViolationFactoryInterface::class, static function () { + return new LaravelDenormalizationViolationFactory(); + }); + $this->app->singleton(DeserializeProvider::class, static function (Application $app) { - return new DeserializeProvider($app->make(SwaggerUiProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class)); + return new DeserializeProvider( + $app->make(SwaggerUiProvider::class), + $app->make(SerializerInterface::class), + $app->make(SerializerContextBuilderInterface::class), + null, + $app->make(DenormalizationViolationFactoryInterface::class), + ); }); $this->app->singleton(ValidateProvider::class, static function (Application $app) { @@ -541,7 +557,7 @@ public function register(): void }); $this->app->singleton(SerializeProcessor::class, static function (Application $app) { - return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class)); + return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class), $app['config']->get('api-platform.enable_head_request_optimization', true)); }); $this->app->singleton(WriteProcessor::class, static function (Application $app) { @@ -708,6 +724,28 @@ public function register(): void ); }); + $this->app->singleton(ItemDenormalizer::class, static function (Application $app) { + /** @var ConfigRepository */ + $config = $app['config']; + $defaultContext = $config->get('api-platform.serializer', []); + + return new ItemDenormalizer( + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(ClassMetadataFactoryInterface::class), + $app->make(LoggerInterface::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::class), + $defaultContext, + null, + $app->make(OperationResourceClassResolverInterface::class), + ); + }); + $this->app->bind(AnonymousContextBuilderInterface::class, JsonLdContextBuilder::class); $this->app->singleton(JsonLdObjectNormalizer::class, static function (Application $app) { @@ -781,7 +819,8 @@ public function register(): void httpAuth: $config->get('api-platform.swagger_ui.http_auth', []), tags: $config->get('api-platform.openapi.tags', []), errorResourceClass: Error::class, - validationErrorResourceClass: ValidationError::class + validationErrorResourceClass: ValidationError::class, + withCredentials: $config->get('api-platform.swagger_ui.with_credentials', false), ); }); @@ -900,16 +939,12 @@ public function register(): void }); $this->app->singleton(SchemaFactory::class, static function (Application $app) { - /** @var ConfigRepository */ - $config = $app['config']; - return new SchemaFactory( $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(PropertyNameCollectionFactoryInterface::class), $app->make(PropertyMetadataFactoryInterface::class), $app->make(NameConverterInterface::class), $app->make(ResourceClassResolverInterface::class), - $config->get('api-platform.formats'), $app->make(DefinitionNameFactoryInterface::class), ); }); @@ -1038,6 +1073,24 @@ public function register(): void ); }); + $this->app->singleton(JsonApiItemDenormalizer::class, static function (Application $app) { + $config = $app['config']; + $defaultContext = $config->get('api-platform.serializer', []); + + return new JsonApiItemDenormalizer( + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(ClassMetadataFactoryInterface::class), + $defaultContext, + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::class), + ); + }); + $this->app->singleton(JsonApiErrorNormalizer::class, static function (Application $app) { return new JsonApiErrorNormalizer( $app->make(JsonApiItemNormalizer::class), @@ -1062,6 +1115,7 @@ public function register(): void $list->insert($app->make(HalObjectNormalizer::class), -995); $list->insert($app->make(HalItemNormalizer::class), -890); $list->insert($app->make(JsonLdItemNormalizer::class), -890); + $list->insert($app->make(JsonLdItemDenormalizer::class), -889); $list->insert($app->make(JsonLdObjectNormalizer::class), -995); $list->insert($app->make(ArrayDenormalizer::class), -990); $list->insert($app->make(DateTimeZoneNormalizer::class), -915); @@ -1070,17 +1124,20 @@ public function register(): void $list->insert($app->make(BackedEnumNormalizer::class), -910); $list->insert($app->make(ObjectNormalizer::class), -1000); $list->insert($app->make(ItemNormalizer::class), -895); + $list->insert($app->make(ItemDenormalizer::class), -894); $list->insert($app->make(OpenApiNormalizer::class), -780); $list->insert($app->make(HydraDocumentationNormalizer::class), -790); $list->insert($app->make(JsonApiEntrypointNormalizer::class), -800); $list->insert($app->make(JsonApiCollectionNormalizer::class), -985); $list->insert($app->make(JsonApiItemNormalizer::class), -890); + $list->insert($app->make(JsonApiItemDenormalizer::class), -889); $list->insert($app->make(JsonApiErrorNormalizer::class), -790); $list->insert($app->make(JsonApiObjectNormalizer::class), -995); if (interface_exists(FieldsBuilderEnumInterface::class)) { $list->insert($app->make(GraphQlItemNormalizer::class), -890); + $list->insert($app->make(GraphQlItemDenormalizer::class), -889); $list->insert($app->make(GraphQlObjectNormalizer::class), -995); $list->insert($app->make(GraphQlErrorNormalizer::class), -790); $list->insert($app->make(GraphQlValidationExceptionNormalizer::class), -780); @@ -1136,6 +1193,26 @@ public function register(): void ); }); + $this->app->singleton(JsonLdItemDenormalizer::class, static function (Application $app) { + $config = $app['config']; + $defaultContext = $config->get('api-platform.serializer', []); + + return new JsonLdItemDenormalizer( + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(ClassMetadataFactoryInterface::class), + $defaultContext, + $app->make(ResourceAccessCheckerInterface::class), + null, + $app->make(OperationResourceClassResolverInterface::class), + ); + }); + $this->app->singleton(InflectorInterface::class, static function (Application $app) { return new Inflector(); }); @@ -1308,6 +1385,21 @@ private function registerGraphQl(): void ); }); + $this->app->singleton(GraphQlItemDenormalizer::class, static function (Application $app) { + return new GraphQlItemDenormalizer( + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(SerializerClassMetadataFactory::class), + [], + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::class) + ); + }); + $this->app->singleton(GraphQlObjectNormalizer::class, static function (Application $app) { return new GraphQlObjectNormalizer( $app->make(ObjectNormalizer::class), diff --git a/src/Laravel/Controller/ApiPlatformController.php b/src/Laravel/Controller/ApiPlatformController.php index d7c59b6f8f7..7e507bf2ce3 100644 --- a/src/Laravel/Controller/ApiPlatformController.php +++ b/src/Laravel/Controller/ApiPlatformController.php @@ -19,6 +19,7 @@ use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\SerializerContextBuilderInterface; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Event; @@ -77,6 +78,16 @@ public function __invoke(Request $request): Response $operation = $operation->withDeserialize(\in_array($operation->getMethod(), ['POST', 'PUT', 'PATCH'], true)); } + $denormalizationContext = $operation->getDenormalizationContext() ?? []; + if ($operation->canDeserialize() && !isset($denormalizationContext[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE])) { + $method = $operation->getMethod(); + $assignObjectToPopulate = 'POST' === $method + || 'PATCH' === $method + || ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true)); + + $operation = $operation->withDenormalizationContext($denormalizationContext + [SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE => $assignObjectToPopulate]); + } + $body = $this->provider->provide($operation, $uriVariables, $context); // The provider can change the Operation, extract it again from the Request attributes diff --git a/src/Laravel/Eloquent/Filter/OrderFilter.php b/src/Laravel/Eloquent/Filter/OrderFilter.php index 2987fe67837..7e5ade9b529 100644 --- a/src/Laravel/Eloquent/Filter/OrderFilter.php +++ b/src/Laravel/Eloquent/Filter/OrderFilter.php @@ -16,13 +16,14 @@ use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\SortFilterInterface; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasOneOrMany; -final class OrderFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class OrderFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface, SortFilterInterface { use QueryPropertyTrait; diff --git a/src/Laravel/Exception/ErrorRenderer.php b/src/Laravel/Exception/ErrorRenderer.php index ee187f55f53..21ac699043a 100644 --- a/src/Laravel/Exception/ErrorRenderer.php +++ b/src/Laravel/Exception/ErrorRenderer.php @@ -15,6 +15,7 @@ use ApiPlatform\Laravel\ApiResource\Error; use ApiPlatform\Laravel\Controller\ApiPlatformController; +use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use ApiPlatform\Metadata\Exception\InvalidUriVariableException; use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use ApiPlatform\Metadata\Exception\StatusAwareExceptionInterface; @@ -186,7 +187,7 @@ private function getStatusCode(?HttpOperation $apiOperation, ?HttpOperation $err return 403; } - if ($exception instanceof SymfonyHttpExceptionInterface) { + if ($exception instanceof SymfonyHttpExceptionInterface || $exception instanceof HttpExceptionInterface) { return $exception->getStatusCode(); } diff --git a/src/Laravel/State/DenormalizationViolationFactory.php b/src/Laravel/State/DenormalizationViolationFactory.php new file mode 100644 index 00000000000..c09c821a70a --- /dev/null +++ b/src/Laravel/State/DenormalizationViolationFactory.php @@ -0,0 +1,216 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\State; + +use ApiPlatform\Laravel\ApiResource\ValidationError; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; +use Illuminate\Contracts\Validation\Rule as LaravelRule; +use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Foundation\Http\FormRequest; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; + +/** + * Laravel-flavored denormalization violation factory — translates Symfony serializer + * type errors into a 422 {@see ValidationError} when the Operation's Laravel rules + * describe the property. + * + * Reads rules declared on the operation (string|array form, e.g. `'required|string'` + * or `['required', 'string']`). FormRequest-class rules and pure-callable rule sets + * are intentionally skipped in v1: a FormRequest-based contract typically runs in the + * validation phase against the raw request, not the denormalized body. + * + * Mapping: + * + * | Exception "current type" | Matching Laravel rule | Emitted code | + * |--------------------------|---------------------------------------------|----------------| + * | null | required, filled | blank | + * | null | present | null | + * | any wrong type | string, integer, int, numeric, boolean, | invalid_type | + * | | bool, array, date, json | | + * | any wrong type | any other rule (no `nullable`) | invalid_type | + * | null | nullable (no required/present/filled) | (no match) | + * | any | (no rule) | (no match) | + * + * In collect mode, unconstrained errors still emit a generic `invalid_type` entry so + * the response surface stays consistent with prior behavior. + * + * Codes are plain semantic strings — the Laravel package does not depend on Symfony + * Validator. + * + * @author Antoine Bluchet + */ +final class DenormalizationViolationFactory implements DenormalizationViolationFactoryInterface +{ + public const CODE_BLANK = 'blank'; + public const CODE_NULL = 'null'; + public const CODE_INVALID_TYPE = 'invalid_type'; + + private const REQUIRED_RULES = ['required' => true, 'filled' => true]; + private const PRESENT_RULES = ['present' => true]; + + public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void + { + if ($exception instanceof NotNormalizableValueException) { + $violation = $this->buildViolation($exception, $operation); + if (null === $violation) { + return; + } + + throw new ValidationError($violation['message'], $this->makeId([$violation['propertyPath']]), $exception, [$violation]); + } + + $violations = []; + $errors = method_exists($exception, 'getNotNormalizableValueErrors') ? $exception->getNotNormalizableValueErrors() : $exception->getErrors(); + foreach ($errors as $error) { + if (!$error instanceof NotNormalizableValueException) { + continue; + } + $violations[] = $this->buildViolation($error, $operation) ?? $this->buildGenericViolation($error); + } + + if (!$violations) { + return; + } + + $paths = array_filter(array_map(static fn (array $v): string => $v['propertyPath'], $violations)); + $message = implode('; ', array_map(static fn (array $v): string => $v['propertyPath'].': '.$v['message'], $violations)); + + throw new ValidationError($message, $this->makeId($paths), $exception, $violations); + } + + /** + * @return array{propertyPath: string, message: string, code: string}|null + */ + private function buildViolation(NotNormalizableValueException $exception, Operation $operation): ?array + { + $rules = $operation->getRules(); + if (\is_callable($rules)) { + $rules = $rules(); + } + + if (\is_string($rules) && is_a($rules, FormRequest::class, true)) { + return null; + } + + if (!\is_array($rules)) { + return null; + } + + $path = $exception->getPath(); + if (null === $path || '' === $path || !\array_key_exists($path, $rules)) { + return null; + } + + $propertyRules = $this->extractRuleTokens($rules[$path]); + if (!$propertyRules) { + return null; + } + + $isNull = 'null' === strtolower((string) $exception->getCurrentType()); + + if ($isNull) { + $hasRequired = (bool) array_intersect_key(self::REQUIRED_RULES, $propertyRules); + $hasPresent = (bool) array_intersect_key(self::PRESENT_RULES, $propertyRules); + + // `nullable` explicitly permits null when no required/present/filled is set. + if (isset($propertyRules['nullable']) && !$hasRequired && !$hasPresent) { + return null; + } + + if ($hasRequired) { + return $this->violation($path, 'This value should not be blank.', self::CODE_BLANK); + } + if ($hasPresent) { + return $this->violation($path, 'This value should not be null.', self::CODE_NULL); + } + } + + return $this->violation($path, $this->typeMessage($exception), self::CODE_INVALID_TYPE); + } + + /** + * @return array rule tokens as a keyed map for O(1) lookup + */ + private function extractRuleTokens(mixed $raw): array + { + if (\is_string($raw)) { + $items = explode('|', $raw); + } elseif (\is_array($raw)) { + $items = $raw; + } else { + return []; + } + + $tokens = []; + foreach ($items as $item) { + if ($item instanceof LaravelRule || $item instanceof ValidationRule || \is_object($item)) { + continue; + } + if (!\is_string($item)) { + continue; + } + $name = strtolower(strstr($item, ':', true) ?: $item); + if ('' === $name) { + continue; + } + $tokens[$name] = true; + } + + return $tokens; + } + + /** + * @return array{propertyPath: string, message: string, code: string} + */ + private function violation(string $path, string $message, string $code): array + { + return [ + 'propertyPath' => $path, + 'message' => $message, + 'code' => $code, + ]; + } + + /** + * @return array{propertyPath: string, message: string, code: string} + */ + private function buildGenericViolation(NotNormalizableValueException $exception): array + { + return $this->violation( + (string) $exception->getPath(), + $exception->canUseMessageForUser() ? $exception->getMessage() : $this->typeMessage($exception), + self::CODE_INVALID_TYPE, + ); + } + + private function typeMessage(NotNormalizableValueException $exception): string + { + $expectedTypes = $exception->getExpectedTypes() ?? []; + if (!$expectedTypes) { + return 'This value should be of the right type.'; + } + + return \sprintf('This value should be of type %s.', implode('|', $expectedTypes)); + } + + /** + * @param string[] $paths + */ + private function makeId(array $paths): string + { + return hash('xxh3', implode(',', $paths) ?: 'denormalization'); + } +} diff --git a/src/Laravel/State/ParameterValidatorProvider.php b/src/Laravel/State/ParameterValidatorProvider.php index 72276824602..89306e7bf59 100644 --- a/src/Laravel/State/ParameterValidatorProvider.php +++ b/src/Laravel/State/ParameterValidatorProvider.php @@ -25,8 +25,6 @@ * Validates parameters using the Laravel validator. * * @implements ProviderInterface - * - * @experimental */ final class ParameterValidatorProvider implements ProviderInterface { diff --git a/src/Laravel/State/SwaggerUiProcessor.php b/src/Laravel/State/SwaggerUiProcessor.php index 7ba643cb80d..a29ea79a8c9 100644 --- a/src/Laravel/State/SwaggerUiProcessor.php +++ b/src/Laravel/State/SwaggerUiProcessor.php @@ -83,6 +83,7 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable 'clientSecret' => $this->oauthClientSecret, 'pkce' => $this->oauthPkce, ], + 'withCredentials' => $this->openApiOptions->getWithCredentials(), ]; $status = 200; diff --git a/src/Laravel/State/ValidateProvider.php b/src/Laravel/State/ValidateProvider.php index 3fc959f48a9..e5af05d82c2 100644 --- a/src/Laravel/State/ValidateProvider.php +++ b/src/Laravel/State/ValidateProvider.php @@ -115,12 +115,6 @@ private function getBodyForValidation(mixed $body): array return $v; } - // hopefully this path never gets used, its there for BC-layer only - // TODO: remove in 5.0 - if ($s = json_encode($body)) { - return json_decode($s, true); - } - throw new RuntimeException('Could not transform the denormalized body in an array for validation'); } } diff --git a/src/Laravel/Tests/DenormalizationValidationTest.php b/src/Laravel/Tests/DenormalizationValidationTest.php new file mode 100644 index 00000000000..a3375374b07 --- /dev/null +++ b/src/Laravel/Tests/DenormalizationValidationTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests; + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Contracts\Config\Repository; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +/** + * @see https://github.com/api-platform/core/issues/7981 + */ +class DenormalizationValidationTest extends TestCase +{ + use ApiTestAssertionsTrait; + use RefreshDatabase; + use WithWorkbench; + + protected function defineEnvironment($app): void + { + tap($app['config'], static function (Repository $config): void { + $config->set('api-platform.formats', ['jsonld' => ['application/ld+json']]); + $config->set('api-platform.docs_formats', ['jsonld' => ['application/ld+json']]); + }); + } + + public function testWrongTypeOnTypedDtoWithRuleProduces422(): void + { + $response = $this->postJson( + '/api/issue6745/rule_validations', + ['prop' => 'abc'], + ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json'] + ); + + $response->assertStatus(422); + $body = json_decode((string) $response->getContent(), true); + $this->assertSame('ValidationError', $body['@type'] ?? null); + $this->assertNotEmpty($body['violations'] ?? []); + $this->assertSame('prop', $body['violations'][0]['propertyPath']); + } + + public function testWrongTypeWithoutRuleRethrows(): void + { + // `max` rule is `lt:2` (no required, no type rule) — but per the rule table, ANY rule + // on the property triggers a generic Type @ 422 (consistent with Symfony's + // "any wrong type | any other constraint" branch). + $response = $this->postJson( + '/api/issue6745/rule_validations', + ['max' => 'abc'], + ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json'] + ); + + $response->assertStatus(422); + } + + public function testEloquentNullOnRequiredFieldStillReturns422(): void + { + // Eloquent dynamic attrs → no denormalization error. Validation layer catches null + required. + $response = $this->postJson( + '/api/issue_6932', + ['sur_name' => null], + ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json'] + ); + + $response->assertStatus(422); + } +} diff --git a/src/Laravel/Tests/DocsTest.php b/src/Laravel/Tests/DocsTest.php index f8449f80adb..8ceb4e0aae9 100644 --- a/src/Laravel/Tests/DocsTest.php +++ b/src/Laravel/Tests/DocsTest.php @@ -85,4 +85,10 @@ public function testHtmlDocsRendersScalarWithoutFooterWhenRequested(): void $this->assertStringContainsString('init-scalar-ui.js', $content); $this->assertStringNotContainsString('id="formats"', $content); } + + public function testSwaggerDataDoesNotContainWithCredentialsByDefault(): void + { + $res = $this->get('/api/docs', headers: ['accept' => 'text/html']); + $this->assertStringNotContainsString('"withCredentials":true', (string) $res->getContent()); + } } diff --git a/src/Laravel/Tests/DocsWithCredentialsTest.php b/src/Laravel/Tests/DocsWithCredentialsTest.php new file mode 100644 index 00000000000..a3d52e939c0 --- /dev/null +++ b/src/Laravel/Tests/DocsWithCredentialsTest.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests; + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Config\Repository; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class DocsWithCredentialsTest extends TestCase +{ + use ApiTestAssertionsTrait; + use WithWorkbench; + + protected function defineEnvironment($app): void + { + tap($app['config'], static function (Repository $config): void { + $config->set('api-platform.swagger_ui.with_credentials', true); + }); + } + + public function testSwaggerDataContainsWithCredentialsTrueWhenEnabled(): void + { + $res = $this->get('/api/docs', headers: ['accept' => 'text/html']); + $res->assertOk(); + $content = (string) $res->getContent(); + $this->assertStringContainsString('"withCredentials":true', $content); + } +} diff --git a/src/Laravel/Tests/JsonApiTest.php b/src/Laravel/Tests/JsonApiTest.php index 8bb06a6f09c..1c987a4dd6c 100644 --- a/src/Laravel/Tests/JsonApiTest.php +++ b/src/Laravel/Tests/JsonApiTest.php @@ -250,13 +250,13 @@ public function testValidateJsonApi(): void [ 'detail' => 'The prop field is required.', 'title' => 'Validation Error', - 'status' => 422, + 'status' => '422', 'code' => '58350900e0fc6b8e/prop', ], [ 'detail' => 'The max field must be less than 2.', 'title' => 'Validation Error', - 'status' => 422, + 'status' => '422', 'code' => '58350900e0fc6b8e/max', ], ], @@ -294,7 +294,7 @@ public function testNotFound(): void $this->assertJsonContains([ 'links' => ['type' => '/errors/404'], 'title' => 'An error occurred', - 'status' => 404, + 'status' => '404', 'detail' => 'Not Found', ], $response->json()['errors'][0]); } diff --git a/src/Laravel/Tests/McpTest.php.orig b/src/Laravel/Tests/McpTest.php.orig new file mode 100644 index 00000000000..a25cf181fda --- /dev/null +++ b/src/Laravel/Tests/McpTest.php.orig @@ -0,0 +1,411 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests; + +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Testing\TestResponse; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Symfony\AI\McpBundle\McpBundle; +use Symfony\Component\HttpFoundation\Response; + +class McpTest extends TestCase +{ + use RefreshDatabase; + use WithWorkbench; + + private function isPsr17FactoryAvailable(): bool + { + try { + if (!class_exists('Http\Discovery\Psr17FactoryDiscovery')) { + return false; + } + + \Http\Discovery\Psr17FactoryDiscovery::findServerRequestFactory(); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * @param array $arguments + * + * @return TestResponse + */ + private function callTool(string $sessionId, string $toolName, array $arguments = []): TestResponse + { + return $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $toolName, + 'arguments' => $arguments, + ], + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + } + + private function initializeMcpSession(): string + { + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => [ + 'name' => 'ApiPlatform Test Suite', + 'version' => '1.0', + ], + 'capabilities' => [], + ], + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ]); + + $response->assertStatus(200); + + return $response->headers->get('mcp-session-id'); + } + + public function testBasicProvider(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'get_book_info'); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content); + $this->assertStringContainsString('API Platform Guide', $content); + $this->assertStringContainsString('978-1234567890', $content); + } + + public function testBasicProcessor(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'update_book_status', [ + 'id' => null, + 'isbn' => '123', + 'title' => 'Test Book', + 'status' => 'pending', + ]); + + $result = $response->json(); + if (isset($result['error'])) { + $this->fail('MCP Error: '.json_encode($result['error'])); + } + $response->assertStatus(200); + $this->assertArrayHasKey('result', $result); + } + + public function testCustomResultWithoutMetadata(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'custom_result', [ + 'text' => 'Test content', + 'includeMetadata' => false, + 'name' => null, + 'email' => null, + 'age' => null, + ]); + + $result = $response->json(); + if (isset($result['error'])) { + $this->fail('MCP Error: '.json_encode($result['error'])); + } + $response->assertStatus(200); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertEquals('Custom result: Test content', $content); + $this->assertNull($result['result']['_meta'] ?? null); + } + + public function testCustomResultWithMetadata(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'custom_result', [ + 'text' => 'Test with metadata', + 'includeMetadata' => true, + 'name' => null, + 'email' => null, + 'age' => null, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertEquals('Custom result: Test with metadata', $content); + $hasMeta = isset($result['result']['_meta']) || isset($result['result']['meta']) || isset($result['result']['structuredContent']); + $this->assertTrue($hasMeta, 'No metadata found in: '.json_encode(array_keys($result['result']))); + } + + public function testValidationFailure(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'validate_input', [ + 'name' => 'ab', + 'email' => 'invalid-email', + 'age' => -5, + 'text' => null, + 'includeMetadata' => null, + ]); + + $result = $response->json(); + if (422 === $response->getStatusCode()) { + $this->assertArrayHasKey('error', $result); + } else { + $response->assertStatus(200); + } + } + + public function testValidationSuccess(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'validate_input', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'age' => 30, + 'text' => null, + 'includeMetadata' => null, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content); + $this->assertStringContainsString('Valid: John Doe', $content); + } + + public function testMarkdownWithoutCodeBlock(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'generate_markdown', [ + 'title' => 'API Platform Guide', + 'content' => 'This is a comprehensive guide to using API Platform.', + 'includeCodeBlock' => false, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content, 'No text content in result'); + $this->assertStringContainsString('# API Platform Guide', $content); + $this->assertStringContainsString('This is a comprehensive guide to using API Platform.', $content); + $this->assertStringNotContainsString('```', $content); + $this->assertNull($result['result']['_meta'] ?? null); + $this->assertArrayNotHasKey('structuredContent', $result['result']); + } + + public function testMarkdownWithCodeBlock(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'generate_markdown', [ + 'title' => 'Code Example', + 'content' => 'Here is how to use the feature:', + 'includeCodeBlock' => true, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content); + $this->assertStringContainsString('# Code Example', $content); + $this->assertStringContainsString('Here is how to use the feature:', $content); + $this->assertStringContainsString('```php', $content); + $this->assertStringContainsString("echo 'Hello, World!';", $content); + $this->assertStringContainsString('```', $content); + $this->assertNull($result['result']['_meta'] ?? null); + $this->assertArrayNotHasKey('structuredContent', $result['result']); + } + + public function testToolsList(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + + $data = $response->json(); + $this->assertArrayHasKey('result', $data); + $this->assertArrayHasKey('tools', $data['result']); + + $tools = $data['result']['tools']; + $toolNames = array_column($tools, 'name'); + + $this->assertContains('get_book_info', $toolNames); + $this->assertContains('update_book_status', $toolNames); + $this->assertContains('custom_result', $toolNames); + $this->assertContains('validate_input', $toolNames); + $this->assertContains('generate_markdown', $toolNames); + $this->assertContains('process_message', $toolNames); + + foreach ($tools as $tool) { + $this->assertArrayHasKey('name', $tool); + $this->assertArrayHasKey('inputSchema', $tool); + $this->assertEquals('object', $tool['inputSchema']['type']); + } + + $response->assertStatus(200); + } + + public function testMcpToolAttribute(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + + $data = $response->json(); + $tools = $data['result']['tools']; + $processMessageTool = null; + foreach ($tools as $tool) { + if ('process_message' === $tool['name']) { + $processMessageTool = $tool; + break; + } + } + + $this->assertNotNull($processMessageTool); + $this->assertEquals('process_message', $processMessageTool['name']); + $this->assertEquals('Process a message with priority', $processMessageTool['description'] ?? null); + $this->assertArrayHasKey('inputSchema', $processMessageTool); + $this->assertEquals('object', $processMessageTool['inputSchema']['type']); + + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'process_message', + 'arguments' => [ + 'message' => 'Hello World', + 'priority' => 5, + ], + ], + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + } +} diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index 5f68b516064..da1bc2901d0 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -28,16 +28,16 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.3", - "api-platform/hal": "^4.3", - "api-platform/hydra": "^4.3", - "api-platform/json-api": "^4.3", - "api-platform/json-schema": "^4.3", - "api-platform/jsonld": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/openapi": "^4.3", - "api-platform/serializer": "^4.3.12", - "api-platform/state": "^4.3", + "api-platform/documentation": "^5.0@alpha", + "api-platform/hal": "^5.0@alpha", + "api-platform/hydra": "^5.0@alpha", + "api-platform/json-api": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/jsonld": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/openapi": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "illuminate/config": "^11.0 || ^12.0 || ^13.0", "illuminate/container": "^11.0 || ^12.0 || ^13.0", "illuminate/contracts": "^11.0 || ^12.0 || ^13.0", @@ -49,13 +49,13 @@ "laravel/framework": "^11.0 || ^12.0 || ^13.0", "symfony/deprecation-contracts": "^3.6", "symfony/type-info": "^7.4 || ^8.0", - "symfony/web-link": "^6.4 || ^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, "require-dev": { - "api-platform/graphql": "^4.3", - "api-platform/http-cache": "^4.3", - "api-platform/mcp": "^4.3", + "api-platform/graphql": "^5.0@alpha", + "api-platform/http-cache": "^5.0@alpha", + "api-platform/mcp": "^5.0@alpha", "doctrine/dbal": "^4.0", "larastan/larastan": "^2.0 || ^3.0", "laravel/sanctum": "^4.0", @@ -98,13 +98,13 @@ ] }, "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.4 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 15f23e34cc3..c89c853be27 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -49,6 +49,10 @@ // on PATCH operations, allowing partial updates without requiring all fields. 'partial_patch_validation' => false, + // When true (default), HEAD requests skip response body construction so + // collections are not iterated. Set to false to process HEAD like GET. + 'enable_head_request_optimization' => true, + 'docs_formats' => [ 'jsonld' => ['application/ld+json'], // 'jsonapi' => ['application/vnd.api+json'], @@ -156,6 +160,8 @@ // 'bearerFormat' => 'JWT', // ], // ], + // + // 'with_credentials' => true, ], // 'openapi' => [ diff --git a/src/Laravel/phpstan.neon.dist b/src/Laravel/phpstan.neon.dist index 3842b533242..43bc599487b 100644 --- a/src/Laravel/phpstan.neon.dist +++ b/src/Laravel/phpstan.neon.dist @@ -19,3 +19,4 @@ parameters: - Tests ignoreErrors: - '#Cannot call method expectsQuestion#' + - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#" diff --git a/src/Laravel/public/init-swagger-ui.js b/src/Laravel/public/init-swagger-ui.js index 101d4fc83b2..794b86ae340 100644 --- a/src/Laravel/public/init-swagger-ui.js +++ b/src/Laravel/public/init-swagger-ui.js @@ -41,7 +41,8 @@ window.onload = function() { }).observe(document, {childList: true, subtree: true}); const data = JSON.parse(document.getElementById('swagger-data').innerText); - const ui = SwaggerUIBundle(Object.assign({ + + const config = { spec: data.spec, dom_id: '#swagger-ui', validatorUrl: null, @@ -55,7 +56,18 @@ window.onload = function() { SwaggerUIBundle.plugins.DownloadUrl, ], layout: 'StandaloneLayout', - }, data.extraConfiguration)); + }; + + if (data.withCredentials) { + // Cloudflare Access fix: ensure cookies are sent on token / CORS calls + config.requestInterceptor = (req) => { + req.credentials = 'include'; + return req; + }; + } + + const withExtraConfig = Object.assign(config, data.extraConfiguration); + const ui = SwaggerUIBundle(withExtraConfig); if (data.oauth.enabled) { ui.initOAuth({ diff --git a/src/Mcp/State/StructuredContentProcessor.php b/src/Mcp/State/StructuredContentProcessor.php index 1a92b43b51a..842304c9952 100644 --- a/src/Mcp/State/StructuredContentProcessor.php +++ b/src/Mcp/State/StructuredContentProcessor.php @@ -19,6 +19,7 @@ use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\ReadResourceResult; @@ -71,6 +72,15 @@ public function process(mixed $data, Operation $operation, array $uriVariables = } } + if ($operation instanceof McpResource) { + return new Response( + $context['mcp_request']->getId(), + new ReadResourceResult([ + new TextResourceContents($operation->getUri(), $operation->getMimeType() ?? 'application/json', $result), + ]), + ); + } + return new Response( $context['mcp_request']->getId(), new CallToolResult( diff --git a/src/Mcp/State/ToolProvider.php b/src/Mcp/State/ToolProvider.php index dff2e8874eb..8ed7f761d17 100644 --- a/src/Mcp/State/ToolProvider.php +++ b/src/Mcp/State/ToolProvider.php @@ -30,7 +30,7 @@ public function __construct(private readonly ObjectMapperInterface $objectMapper public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - if (!isset($context['mcp_request'])) { + if (!isset($context['mcp_request'], $context['mcp_data'])) { return null; } diff --git a/src/Mcp/Tests/State/StructuredContentProcessorTest.php b/src/Mcp/Tests/State/StructuredContentProcessorTest.php index 305a8f718f5..390bfc0b988 100644 --- a/src/Mcp/Tests/State/StructuredContentProcessorTest.php +++ b/src/Mcp/Tests/State/StructuredContentProcessorTest.php @@ -14,13 +14,16 @@ namespace ApiPlatform\Mcp\Tests\State; use ApiPlatform\Mcp\State\StructuredContentProcessor; +use ApiPlatform\Metadata\McpResource; use ApiPlatform\Metadata\McpTool; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\ReadResourceResult; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request as HttpRequest; use Symfony\Component\Serializer\Encoder\EncoderInterface; @@ -81,6 +84,45 @@ public function testTextContentIsPopulatedWhenStructuredContentIsDisabled(): voi $this->assertNotSame('{}', $textContent->text); $this->assertSame($expectedJson, $textContent->text); } + + public function testMcpResourceReturnsReadResourceResult(): void + { + $expectedJson = '{"name":"foo"}'; + $resourceUri = 'app://dummy'; + $resourceMimeType = 'application/json'; + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->method('process')->willReturn(new \stdClass()); + + $serializer = $this->createMock(SerializerEncoderNormalizer::class); + $serializer->method('normalize')->willReturn(['name' => 'foo']); + $serializer->method('encode')->willReturn($expectedJson); + + $contextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $contextBuilder->method('createFromRequest')->willReturn([]); + + $processor = new StructuredContentProcessor($serializer, $contextBuilder, $decorated); + + $operation = (new McpResource(uri: $resourceUri, mimeType: $resourceMimeType))->withClass(\stdClass::class); + + $mcpRequest = $this->createMock(Request::class); + $mcpRequest->method('getId')->willReturn('req-1'); + + /** @var Response $response */ + $response = $processor->process([], $operation, [], [ + 'mcp_request' => $mcpRequest, + 'request' => new HttpRequest(), + ]); + + $result = $response->result; + $this->assertInstanceOf(ReadResourceResult::class, $result); + + $resourceContents = $result->contents[0]; + $this->assertInstanceOf(TextResourceContents::class, $resourceContents); + $this->assertSame($resourceUri, $resourceContents->uri); + $this->assertSame($resourceMimeType, $resourceContents->mimeType); + $this->assertSame($expectedJson, $resourceContents->text); + } } /** diff --git a/src/Mcp/Tests/State/ToolProviderTest.php b/src/Mcp/Tests/State/ToolProviderTest.php new file mode 100644 index 00000000000..15308a6d1e9 --- /dev/null +++ b/src/Mcp/Tests/State/ToolProviderTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Mcp\Tests\State; + +use ApiPlatform\Mcp\State\ToolProvider; +use ApiPlatform\Metadata\McpResource; +use Mcp\Schema\Request\ReadResourceRequest; +use PHPUnit\Framework\TestCase; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +class ToolProviderTest extends TestCase +{ + /** + * The handler installs this provider on every MCP operation that declares none, + * MCP resources included, but it only fills `mcp_data` for a tool call: reading + * a resource must not be mapped from a payload that does not exist. + */ + public function testProvideReturnsNullWhenTheRequestCarriesNoToolPayload(): void + { + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->never())->method('map'); + + $provider = new ToolProvider($objectMapper); + + $operation = new McpResource(uri: 'dummy://docs', name: 'docs', class: \stdClass::class); + + $this->assertNull($provider->provide($operation, [], [ + 'mcp_request' => new ReadResourceRequest('dummy://docs'), + ])); + } +} diff --git a/src/Mcp/composer.json b/src/Mcp/composer.json index 7c2f893551c..24bab2d78cc 100644 --- a/src/Mcp/composer.json +++ b/src/Mcp/composer.json @@ -28,14 +28,19 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "api-platform/json-schema": "^4.3", + "api-platform/metadata": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "mcp/sdk": "^0.8", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/http-foundation": "^7.4 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", - "symfony/polyfill-php85": "^1.32" + "symfony/polyfill-php85": "^1.32", + "symfony/serializer": "^7.4 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^11.5 || ^12.2" + "phpunit/phpunit": "^11.5 || ^12.2", + "symfony/expression-language": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -50,16 +55,22 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev" + "dev-main": "5.0.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", "url": "https://github.com/api-platform/api-platform" } }, + "suggest": { + "symfony/expression-language": "To use the operation-level \"security\" expressions." + }, + "scripts": { + "test": "./vendor/bin/phpunit" + }, "minimum-stability": "beta", "prefer-stable": true } diff --git a/src/Metadata/ApiFilter.php b/src/Metadata/ApiFilter.php index 6a96f191b29..14be225afbb 100644 --- a/src/Metadata/ApiFilter.php +++ b/src/Metadata/ApiFilter.php @@ -19,6 +19,8 @@ * Filter attribute. * * @author Antoine Bluchet + * + * @deprecated since API Platform 4.4, use the {@see QueryParameter} attribute instead. Will be removed in 6.0. */ #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class ApiFilter diff --git a/src/Metadata/ApiProperty.php b/src/Metadata/ApiProperty.php index 28c6d09870d..a6dc14c8dda 100644 --- a/src/Metadata/ApiProperty.php +++ b/src/Metadata/ApiProperty.php @@ -13,8 +13,6 @@ namespace ApiPlatform\Metadata; -use ApiPlatform\Metadata\Util\PropertyInfoToTypeInfoHelper; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Ignore; @@ -50,7 +48,6 @@ final class ApiProperty * @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization * @param string[]|null $types the RDF types of this property * @param string[]|null $iris - * @param LegacyType[]|null $builtinTypes * @param string|null $uriTemplate whether to return the subRessource collection IRI instead of an iterable of IRI * @param string|null $property The property name * @param Context|Groups|Ignore|SerializedName|SerializedPath|MaxDepth|array $serialize Serializer attributes @@ -208,12 +205,6 @@ public function __construct( */ private string|\Stringable|null $securityPostDenormalize = null, array|string|null $types = null, - /* - * The related php types. - * - * deprecated since 4.2, use "nativeType" instead. - */ - private ?array $builtinTypes = null, private ?array $schema = null, private ?bool $initializable = null, private $iris = null, @@ -232,13 +223,6 @@ public function __construct( $this->types = \is_string($types) ? (array) $types : $types; $this->serialize = (null === $serialize || \is_array($serialize)) ? $serialize : [$serialize]; $this->nativeType = $nativeType; - - if ($this->builtinTypes) { - trigger_deprecation('api_platform/metadata', '4.2', \sprintf('The "builtinTypes" argument of "%s" is deprecated, use "nativeType" instead.', __CLASS__)); - $this->nativeType ??= PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($this->builtinTypes); - } elseif ($this->nativeType && class_exists(LegacyType::class)) { - $this->builtinTypes = PropertyInfoToTypeInfoHelper::convertTypeToLegacyTypes($this->nativeType) ?? []; - } } public function getProperty(): ?string @@ -525,38 +509,6 @@ public function withTypes(array|string $types = []): static return $self; } - /** - * deprecated since 4.2, use "getNativeType" instead. - * - * @return LegacyType[]|null - */ - public function getBuiltinTypes(): ?array - { - trigger_deprecation('api-platform/metadata', '4.2', 'The "%s()" method is deprecated, use "%s::getNativeType()" instead.', __METHOD__, self::class); - - if (null === $this->builtinTypes && null !== $this->nativeType) { - $this->builtinTypes = PropertyInfoToTypeInfoHelper::convertTypeToLegacyTypes($this->nativeType) ?? []; - } - - return $this->builtinTypes; - } - - /** - * deprecated since 4.2, use "withNativeType" instead. - * - * @param LegacyType[] $builtinTypes - */ - public function withBuiltinTypes(array $builtinTypes = []): static - { - trigger_deprecation('api-platform/metadata', '4.2', 'The "%s()" method is deprecated, use "%s::withNativeType()" instead.', __METHOD__, self::class); - - $self = clone $this; - $self->builtinTypes = $builtinTypes; - $self->nativeType = PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($builtinTypes); - - return $self; - } - public function getNativeType(): ?Type { return $this->nativeType; diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index eabfdda8fc0..5d136533d83 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -326,6 +326,14 @@ public function __construct( protected ?array $denormalizationContext = null, protected ?bool $collectDenormalizationErrors = null, protected ?array $hydraContext = null, + /** + * Extra entries to merge into the JSON-LD `@context` for this resource (e.g. namespace prefix declarations). + * + * Example: `jsonldContext: ['dct' => 'http://purl.org/dc/terms/']` + * + * @see https://api-platform.com/docs/core/extending-jsonld-context/ + */ + protected ?array $jsonldContext = null, protected bool|OpenApiOperation|null $openapi = null, /** * The `validationContext` option configures the context of validation for the current ApiResource. @@ -970,6 +978,7 @@ public function __construct( protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, protected ?bool $jsonStream = null, + protected ?bool $throwOnNotFound = null, protected array $extraProperties = [], ?bool $map = null, protected ?array $mcp = null, @@ -1018,6 +1027,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map, ); @@ -1373,6 +1383,19 @@ public function withHydraContext(array $hydraContext): static return $self; } + public function getJsonldContext(): ?array + { + return $this->jsonldContext; + } + + public function withJsonldContext(array $jsonldContext): static + { + $self = clone $this; + $self->jsonldContext = $jsonldContext; + + return $self; + } + public function getOpenapi(): bool|OpenApiOperation|null { return $this->openapi; diff --git a/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php b/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php index ace2d185f39..965fa9d3643 100644 --- a/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php +++ b/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php @@ -14,9 +14,13 @@ namespace ApiPlatform\Metadata; /** - * @author Vincent Amstoutz + * Lets a filter satisfy the legacy FilterInterface::getDescription() requirement without implementing it by hand. + * + * Use this trait in a filter that does not need to describe itself through the deprecated getDescription() mechanism: + * it returns an empty array, which is the expected value now that filters are described through QueryParameter metadata. + * The trait will be removed in 6.0 together with FilterInterface::getDescription(). * - * @internal + * @author Vincent Amstoutz */ trait BackwardCompatibleFilterDescriptionTrait { diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index b4e55ef6765..61744470fd6 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -101,6 +102,7 @@ public function __construct( ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, ) { @@ -129,6 +131,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, @@ -185,6 +188,7 @@ class: $class, parameters: $parameters, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + throwOnNotFound: $throwOnNotFound, stateOptions: $stateOptions, map: $map ); diff --git a/src/Metadata/Error.php b/src/Metadata/Error.php index dabe1b854d5..c7c34733459 100644 --- a/src/Metadata/Error.php +++ b/src/Metadata/Error.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -123,6 +124,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, diff --git a/src/Metadata/ErrorResource.php b/src/Metadata/ErrorResource.php index 8f1586ac038..c3700713617 100644 --- a/src/Metadata/ErrorResource.php +++ b/src/Metadata/ErrorResource.php @@ -49,6 +49,7 @@ public function __construct( ?array $denormalizationContext = null, ?bool $collectDenormalizationErrors = null, ?array $hydraContext = null, + ?array $jsonldContext = null, OpenApiOperation|bool|null $openapi = null, ?array $validationContext = null, ?array $filters = null, @@ -116,6 +117,7 @@ class: $class, denormalizationContext: $denormalizationContext, collectDenormalizationErrors: $collectDenormalizationErrors, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, validationContext: $validationContext, filters: $filters, diff --git a/src/Metadata/Exception/BadRequestException.php b/src/Metadata/Exception/BadRequestException.php new file mode 100644 index 00000000000..414a510b228 --- /dev/null +++ b/src/Metadata/Exception/BadRequestException.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Exception; + +/** + * Framework-agnostic 400 Bad Request, mapped by both the Symfony and Laravel error handlers. + */ +class BadRequestException extends \RuntimeException implements ExceptionInterface, HttpExceptionInterface +{ + public function getStatusCode(): int + { + return 400; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return []; + } +} diff --git a/src/Metadata/Extractor/XmlPropertyExtractor.php b/src/Metadata/Extractor/XmlPropertyExtractor.php index d1d9a13b755..98d02d22ebd 100644 --- a/src/Metadata/Extractor/XmlPropertyExtractor.php +++ b/src/Metadata/Extractor/XmlPropertyExtractor.php @@ -66,7 +66,6 @@ protected function extractPath(string $path): void 'security' => $this->phpize($property, 'security', 'string'), 'securityPostDenormalize' => $this->phpize($property, 'securityPostDenormalize', 'string'), 'types' => $this->buildArrayValue($property, 'type'), - 'builtinTypes' => $this->buildArrayValue($property, 'builtinType'), 'schema' => isset($property->schema->values) ? $this->buildValues($property->schema->values) : null, 'initializable' => $this->phpize($property, 'initializable', 'bool'), 'extraProperties' => $this->buildExtraProperties($property, 'extraProperties'), diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 6ebc1a66bf8..d439c1e9815 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -93,6 +93,7 @@ private function buildExtendedBase(\SimpleXMLElement $resource): array 'schemes' => $this->buildArrayValue($resource, 'scheme'), 'cacheHeaders' => $this->buildCacheHeaders($resource), 'hydraContext' => isset($resource->hydraContext->values) ? $this->buildValues($resource->hydraContext->values) : null, + 'jsonldContext' => isset($resource->jsonldContext->values) ? $this->buildValues($resource->jsonldContext->values) : null, 'openapi' => $this->buildOpenapi($resource), 'paginationViaCursor' => $this->buildPaginationViaCursor($resource), 'exceptionToStatus' => $this->buildExceptionToStatus($resource), @@ -148,6 +149,7 @@ private function buildBase(\SimpleXMLElement $resource): array 'write' => $this->phpize($resource, 'write', 'bool'), 'jsonStream' => $this->phpize($resource, 'jsonStream', 'bool'), 'map' => $this->phpize($resource, 'map', 'bool'), + 'throwOnNotFound' => $this->phpize($resource, 'throwOnNotFound', 'bool'), ]; } diff --git a/src/Metadata/Extractor/YamlPropertyExtractor.php b/src/Metadata/Extractor/YamlPropertyExtractor.php index c15c32ab277..1b95ed8a873 100644 --- a/src/Metadata/Extractor/YamlPropertyExtractor.php +++ b/src/Metadata/Extractor/YamlPropertyExtractor.php @@ -90,7 +90,6 @@ private function buildProperties(array $resourcesYaml): void 'extraProperties' => $this->buildAttribute($propertyValues, 'extraProperties'), 'default' => $propertyValues['default'] ?? null, 'example' => $propertyValues['example'] ?? null, - 'builtinTypes' => $this->buildAttribute($propertyValues, 'builtinTypes'), 'schema' => $this->buildAttribute($propertyValues, 'schema'), 'genId' => $this->phpize($propertyValues, 'genId', 'bool'), 'uriTemplate' => $this->phpize($propertyValues, 'uriTemplate', 'string'), diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index 10f6b700129..fe21862d065 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -114,6 +114,7 @@ private function buildExtendedBase(array $resource): array 'types' => $this->buildArrayValue($resource, 'types'), 'cacheHeaders' => $this->buildArrayValue($resource, 'cacheHeaders'), 'hydraContext' => $this->buildArrayValue($resource, 'hydraContext'), + 'jsonldContext' => $this->buildArrayValue($resource, 'jsonldContext'), 'openapi' => $this->buildOpenapi($resource), 'paginationViaCursor' => $this->buildArrayValue($resource, 'paginationViaCursor'), 'exceptionToStatus' => $this->buildArrayValue($resource, 'exceptionToStatus'), @@ -175,6 +176,7 @@ private function buildBase(array $resource): array 'write' => $this->phpize($resource, 'write', 'bool'), 'jsonStream' => $this->phpize($resource, 'jsonStream', 'bool'), 'map' => $this->phpize($resource, 'map', 'bool'), + 'throwOnNotFound' => $this->phpize($resource, 'throwOnNotFound', 'bool'), ]; } diff --git a/src/Metadata/Extractor/schema/properties.xsd b/src/Metadata/Extractor/schema/properties.xsd index 689852a92c3..bb953e00232 100644 --- a/src/Metadata/Extractor/schema/properties.xsd +++ b/src/Metadata/Extractor/schema/properties.xsd @@ -21,7 +21,6 @@ - @@ -74,18 +73,6 @@ - - - - - - - - - - - - diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index 02468b51bd7..6019722d6bb 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -479,6 +479,7 @@ + @@ -525,6 +526,7 @@ + diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 4babd54eb27..82a01f83dc8 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -101,6 +102,7 @@ public function __construct( ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, ) { @@ -128,6 +130,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, @@ -184,6 +187,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 27df4b9ad41..a94ae240999 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -102,6 +103,7 @@ public function __construct( protected ?bool $hideHydraOperation = null, ?bool $jsonStream = null, array $extraProperties = [], + ?bool $throwOnNotFound = null, private ?string $itemUriTemplate = null, ?bool $map = null, ) { @@ -129,6 +131,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, @@ -179,6 +182,7 @@ class: $class, processor: $processor, parameters: $parameters, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, rules: $rules, policy: $policy, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 58d4cf98c7f..a8f28f22d83 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -164,6 +164,7 @@ public function __construct( protected ?array $cacheHeaders = null, protected ?array $paginationViaCursor = null, protected ?array $hydraContext = null, + protected ?array $jsonldContext = null, protected bool|OpenApiOperation|Webhook|null $openapi = null, protected ?array $exceptionToStatus = null, protected ?array $links = null, @@ -221,6 +222,7 @@ public function __construct( array|string|null $middleware = null, ?bool $queryParameterValidationEnabled = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, ) { @@ -282,6 +284,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); @@ -629,6 +632,19 @@ public function withHydraContext(array $hydraContext): static return $self; } + public function getJsonldContext(): ?array + { + return $this->jsonldContext; + } + + public function withJsonldContext(array $jsonldContext): static + { + $self = clone $this; + $self->jsonldContext = $jsonldContext; + + return $self; + } + public function getOpenapi(): bool|OpenApiOperation|Webhook|null { return $this->openapi; diff --git a/src/Metadata/IdentifiersExtractor.php b/src/Metadata/IdentifiersExtractor.php index 7c0c8c5c98c..3b3015a6fc9 100644 --- a/src/Metadata/IdentifiersExtractor.php +++ b/src/Metadata/IdentifiersExtractor.php @@ -24,7 +24,6 @@ use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; /** * {@inheritdoc} @@ -131,30 +130,6 @@ private function getIdentifierValue(object $item, string $class, string $propert foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName); - // TODO: remove in 5.x - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes(); - if (null === ($type = $types[0] ?? null)) { - continue; - } - - try { - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - - if (null !== $collectionValueType && $collectionValueType->getClassName() === $class) { - return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, \sprintf('%s[0].%s', $propertyName, $property)), $parameterName); - } - } - - if ($type->getClassName() === $class) { - return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, "$propertyName.$property"), $parameterName); - } - } catch (NoSuchPropertyException $e) { - throw new RuntimeException('Not able to retrieve identifiers.', $e->getCode(), $e); - } - } - if (null === $type = $propertyMetadata->getNativeType()) { continue; } @@ -177,11 +152,6 @@ private function getIdentifierValue(object $item, string $class, string $propert throw new RuntimeException('Not able to retrieve identifiers.'); } - /** - * TODO: in 3.0 this method just uses $identifierValue instanceof \Stringable and we remove the weird behavior. - * - * @param mixed|\Stringable $identifierValue - */ private function resolveIdentifierValue(mixed $identifierValue, string $parameterName): float|bool|int|string { if (null === $identifierValue) { diff --git a/src/Metadata/McpResource.php b/src/Metadata/McpResource.php index c36342c1e6b..5be8ab91f35 100644 --- a/src/Metadata/McpResource.php +++ b/src/Metadata/McpResource.php @@ -126,6 +126,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?array $links = null, @@ -209,6 +210,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, links: $links, diff --git a/src/Metadata/McpTool.php b/src/Metadata/McpTool.php index 465da19d76f..f46f7a297d8 100644 --- a/src/Metadata/McpTool.php +++ b/src/Metadata/McpTool.php @@ -122,6 +122,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?array $links = null, @@ -205,6 +206,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, links: $links, diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 009612c9900..35e039c85a8 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -82,6 +82,7 @@ public function __construct( protected ?bool $hideHydraOperation = null, protected ?bool $jsonStream = null, protected ?bool $map = null, + protected ?bool $throwOnNotFound = null, protected array $extraProperties = [], ) { if (\is_array($parameters) && $parameters) { @@ -655,6 +656,19 @@ public function withMiddleware(string|array $middleware): static return $self; } + public function getThrowOnNotFound(): ?bool + { + return $this->throwOnNotFound; + } + + public function withThrowOnNotFound(bool $throwOnNotFound): static + { + $self = clone $this; + $self->throwOnNotFound = $throwOnNotFound; + + return $self; + } + public function getExtraProperties(): ?array { return $this->extraProperties; diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php index e106aa23b4e..c3422bac243 100644 --- a/src/Metadata/NotExposed.php +++ b/src/Metadata/NotExposed.php @@ -56,6 +56,7 @@ public function __construct( ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = false, ?array $exceptionToStatus = null, @@ -135,6 +136,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index cbd53751e59..343915a673c 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -814,6 +814,7 @@ public function __construct( protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, protected ?bool $jsonStream = null, + protected ?bool $throwOnNotFound = null, protected array $extraProperties = [], ?bool $map = null, ) { @@ -862,6 +863,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 13d7dc442a0..100ac370e7a 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -101,6 +102,7 @@ public function __construct( ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, ) { @@ -129,6 +131,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, @@ -185,6 +188,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 419512a851d..61a4a059c7c 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -99,6 +100,7 @@ public function __construct( ?string $policy = null, array|string|null $middleware = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], private ?string $itemUriTemplate = null, ?bool $strictQueryParameterValidation = null, @@ -130,6 +132,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, @@ -186,6 +189,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php b/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php index 1d5d659e7db..1ace680ce6e 100644 --- a/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php @@ -17,7 +17,6 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\PropertyNotFoundException; use ApiPlatform\Metadata\Util\Reflection; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -140,19 +139,6 @@ private function createMetadata(ApiProperty $attribute, ?ApiProperty $propertyMe foreach (get_class_methods(ApiProperty::class) as $method) { if (preg_match('/^(?:get|is)(.*)/', (string) $method, $matches)) { - // BC layer, to remove in 5.0 - if ('getBuiltinTypes' === $method) { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - continue; - } - - if ($builtinTypes = $attribute->getBuiltinTypes()) { - $propertyMetadata = $propertyMetadata->withBuiltinTypes($builtinTypes); - } - - continue; - } - if (null !== $val = $attribute->{$method}()) { $propertyMetadata = $propertyMetadata->{"with{$matches[1]}"}($val); } diff --git a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php index dad89450030..30dc24e2e70 100644 --- a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php @@ -19,8 +19,6 @@ use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Extractor\PropertyExtractorInterface; use PHPStan\PhpDocParser\Parser\PhpDocParser; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; @@ -61,16 +59,6 @@ public function create(string $resourceClass, string $property, array $options = $apiProperty = new ApiProperty(); foreach ($propertyMetadata as $key => $value) { - if ('builtinTypes' === $key && null !== $value) { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - continue; - } - - $apiProperty = $apiProperty->withBuiltinTypes(array_map(static fn (string $builtinType): LegacyType => new LegacyType($builtinType), $value)); - - continue; - } - if ('nativeType' === $key && null !== $value) { if (class_exists(PhpDocParser::class)) { $apiProperty = $apiProperty->withNativeType((new StringTypeResolver())->resolve($value)); diff --git a/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php b/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php index ba9b24b8adf..e04f172a83d 100644 --- a/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php @@ -15,10 +15,7 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\PropertyNotFoundException; -use Doctrine\Common\Collections\ArrayCollection; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; -use Symfony\Component\PropertyInfo\Type; /** * PropertyInfo metadata loader decorator. @@ -46,24 +43,8 @@ public function create(string $resourceClass, string $property, array $options = } } - // TODO: remove in 5.x - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - if (!$propertyMetadata->getBuiltinTypes()) { - $types = $this->propertyInfo->getTypes($resourceClass, $property, $options) ?? []; // @phpstan-ignore-line - - foreach ($types as $i => $type) { - // Temp fix for https://github.com/symfony/symfony/pull/52699 - if (ArrayCollection::class === $type->getClassName()) { - $types[$i] = new Type($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), true, $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); - } - } - - $propertyMetadata = $propertyMetadata->withBuiltinTypes($types); - } - } else { - if (!$propertyMetadata->getNativeType()) { - $propertyMetadata = $propertyMetadata->withNativeType($this->propertyInfo->getType($resourceClass, $property, $options)); - } + if (!$propertyMetadata->getNativeType()) { + $propertyMetadata = $propertyMetadata->withNativeType($this->propertyInfo->getType($resourceClass, $property, $options)); } if (null === $propertyMetadata->getDescription() && null !== $description = $this->propertyInfo->getShortDescription($resourceClass, $property, $options)) { diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index d80cc055923..05b3c605180 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -17,7 +17,6 @@ use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; use Symfony\Component\TypeInfo\Type; @@ -66,20 +65,6 @@ public function create(string $resourceClass, string $property, array $options = $propertyMetadata = $this->transformReadWrite($propertyMetadata, $resourceClass, $property, $normalizationGroups, $denormalizationGroups, $normalizationAttributes, $denormalizationAttributes, $ignoredAttributes); - // TODO: remove in 5.x - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - - if (!$this->isResourceClass($resourceClass) && $types) { - foreach ($types as $builtinType) { - if ($builtinType->isCollection()) { - return $propertyMetadata->withReadableLink(true)->withWritableLink(true); - } - } - } - - return $this->transformLinkStatusLegacy($propertyMetadata, $property, $normalizationGroups, $denormalizationGroups, $normalizationAttributes, $denormalizationAttributes, $types); - } $type = $propertyMetadata->getNativeType(); if (null !== $type && !$this->isResourceClass($resourceClass) && $type->isSatisfiedBy(static fn (Type $t): bool => $t instanceof CollectionType)) { return $propertyMetadata->withReadableLink(true)->withWritableLink(true); @@ -118,59 +103,6 @@ private function transformReadWrite(ApiProperty $propertyMetadata, string $resou return $propertyMetadata; } - /** - * Sets readableLink/writableLink based on matching normalization/denormalization groups/attributes. - * - * If normalization/denormalization groups/attributes are not specified, - * set link status to false since embedding of resource must be explicitly enabled - * - * @param string[]|null $normalizationGroups - * @param string[]|null $denormalizationGroups - */ - private function transformLinkStatusLegacy(ApiProperty $propertyMetadata, string $propertyName, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?array $normalizationAttributes = null, ?array $denormalizationAttributes = null, ?array $types = null): ApiProperty - { - // No need to check link status if property is not readable and not writable - if (false === $propertyMetadata->isReadable() && false === $propertyMetadata->isWritable()) { - return $propertyMetadata; - } - - foreach ($types as $type) { - if ( - $type->isCollection() - && $collectionValueType = $type->getCollectionValueTypes()[0] ?? null - ) { - $relatedClass = $collectionValueType->getClassName(); - } else { - $relatedClass = $type->getClassName(); - } - - // if property is not a resource relation, don't set link status (as it would have no meaning) - if (null === $relatedClass || !$this->isResourceClass($relatedClass)) { - continue; - } - - // find the resource class - // this prevents serializer groups on non-resource child class from incorrectly influencing the decision - if (null !== $this->resourceClassResolver) { - $relatedClass = $this->resourceClassResolver->getResourceClass(null, $relatedClass); - } - - $relatedGroups = $this->getClassSerializerGroups($relatedClass); - - if (null === $propertyMetadata->isReadableLink()) { - $propertyMetadata = $propertyMetadata->withReadableLink((null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))) || (null !== $normalizationAttributes && $this->isPropertyInAttributes($propertyName, $normalizationAttributes))); - } - - if (null === $propertyMetadata->isWritableLink()) { - $propertyMetadata = $propertyMetadata->withWritableLink((null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))) || (null !== $denormalizationAttributes && $this->isPropertyInAttributes($propertyName, $denormalizationAttributes))); - } - - return $propertyMetadata; - } - - return $propertyMetadata; - } - /** * Sets readableLink/writableLink based on matching normalization/denormalization groups/attributes. * diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 3ea21ffeadd..87529e95879 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -99,6 +100,7 @@ public function __construct( ?string $policy = null, array|string|null $middleware = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, @@ -130,6 +132,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, @@ -186,6 +189,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php index 00ebfc6bc45..e29a29b2e72 100644 --- a/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php @@ -58,7 +58,12 @@ public function create(string $resourceClass): ResourceMetadataCollection foreach ($resourceMetadataCollection as $i => $resource) { foreach ($operations = $resource->getOperations() ?? [] as $operationName => $operation) { - $operations->add($operationName, $operation->withFilters(array_unique(array_merge($resource->getFilters() ?? [], $operation->getFilters() ?? [], $filters)))); + $operationFilters = array_unique(array_merge($resource->getFilters() ?? [], $operation->getFilters() ?? [], $filters)); + if ($operationFilters) { + trigger_deprecation('api-platform/core', '4.4', \sprintf('Declaring filters on the "%s" operation through "Operation::$filters" is deprecated, use the "parameters" argument instead. It will be removed in 6.0.', $operation->getShortName())); + } + + $operations->add($operationName, $operation->withFilters($operationFilters)); } if ($operations) { diff --git a/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php b/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php index cd74e7776df..ae0fd13f366 100644 --- a/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php +++ b/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php @@ -239,7 +239,6 @@ private function hasSameOperation(ApiResource $resource, string $operationClass, */ private function deduplicateShortNames(array $resources): array { - $enabled = $this->defaults['extra_properties']['deduplicate_resource_short_names'] ?? false; $shortNameCounts = []; foreach ($resources as $index => $resource) { @@ -249,14 +248,6 @@ private function deduplicateShortNames(array $resources): array continue; } - if (!$enabled) { - if (1 === $shortNameCounts[$shortName]) { - trigger_deprecation('api-platform/core', '4.2', 'Having multiple "#[ApiResource]" attributes with the same "shortName" "%s" on class "%s" is deprecated and will result in automatic short name deduplication in API Platform 5.x. Set "defaults.extra_properties.deduplicate_resource_short_names" to "true" in the API Platform configuration to enable it now.', $shortName, $resource->getClass()); - } - ++$shortNameCounts[$shortName]; - continue; - } - $newShortName = $shortName.(++$shortNameCounts[$shortName]); $resource = $resource->withShortName($newShortName); diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 062554c122b..ca13f26904e 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata\Resource\Factory; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\RuntimeException; @@ -212,13 +213,7 @@ private function getProperties(string $resourceClass, ?Parameter $parameter = nu } if (($filter = $this->getFilterInstance($parameter->getFilter())) && $filter instanceof PropertyAwareFilterInterface) { - if (!method_exists($filter, 'getProperties')) { // todo 5.x remove this check - trigger_deprecation('api-platform/core', 'In API Platform 5.0 "%s" will implement a method named "getProperties"', PropertyAwareFilterInterface::class); - $refl = new \ReflectionClass($filter); - $filterProperties = $refl->hasProperty('properties') ? $refl->getProperty('properties')->getValue($filter) : []; - } else { - $filterProperties = array_keys($filter->getProperties() ?? []); - } + $filterProperties = array_keys($filter->getProperties() ?? []); foreach ($filterProperties as $prop) { if (!\in_array($prop, $propertyNames, true)) { @@ -423,7 +418,13 @@ private function setDefaults(string $key, Parameter $parameter, ?object $filter, try { return $this->getLegacyFilterMetadata($parameter, $operation, $filter); } catch (RuntimeException $exception) { - $this->logger?->alert($exception->getMessage(), ['exception' => $exception]); + // An inline filter instance never gets a ManagerRegistry, unlike one resolved as a service + // through the filter locator: failing to describe it is expected, not an alert-worthy event. + if ($filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { + $this->logger?->debug($exception->getMessage(), ['exception' => $exception]); + } else { + $this->logger?->alert($exception->getMessage(), ['exception' => $exception]); + } return $parameter; } diff --git a/src/Metadata/SortFilterInterface.php b/src/Metadata/SortFilterInterface.php new file mode 100644 index 00000000000..dd1fee51fab --- /dev/null +++ b/src/Metadata/SortFilterInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +/** + * Marks a filter that sorts a collection by one or more properties. + * + * Backend-agnostic so consumers can recognize a sort filter without depending + * on a persistence layer: GraphQL, for instance, exposes such a parameter as an + * ordered list of single-property inputs to preserve multi-key ordering, which + * an (unordered) input object cannot express. + */ +interface SortFilterInterface +{ +} diff --git a/src/Metadata/Tests/BackwardCompatibleFilterDescriptionTraitTest.php b/src/Metadata/Tests/BackwardCompatibleFilterDescriptionTraitTest.php new file mode 100644 index 00000000000..dc02b9072b4 --- /dev/null +++ b/src/Metadata/Tests/BackwardCompatibleFilterDescriptionTraitTest.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Tests; + +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use PHPUnit\Framework\TestCase; + +final class BackwardCompatibleFilterDescriptionTraitTest extends TestCase +{ + public function testGetDescriptionReturnsEmptyArray(): void + { + $filter = new class { + use BackwardCompatibleFilterDescriptionTrait; + }; + + $this->assertSame([], $filter->getDescription('Foo')); + } +} diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index b15d99a4508..4bebfa435e7 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -66,6 +66,7 @@ final class XmlResourceAdapter implements ResourceAdapterInterface 'stateOptions', 'collectDenormalizationErrors', 'jsonStream', + 'throwOnNotFound', 'links', 'parameters', ]; @@ -230,6 +231,11 @@ private function buildHydraContext(\SimpleXMLElement $resource, array $values): $this->buildValues($resource->addChild('hydraContext'), $values); } + private function buildJsonldContext(\SimpleXMLElement $resource, array $values): void + { + $this->buildValues($resource->addChild('jsonldContext'), $values); + } + private function buildOpenapi(\SimpleXMLElement $resource, array $values): void { $node = $resource->openapi ?? $resource->addChild('openapi'); diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.xml b/src/Metadata/Tests/Extractor/Adapter/resources.xml index b7e83452477..15883953196 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.xml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.xml @@ -1,3 +1,3 @@ -someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazbazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazbazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet +someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazhttp://purl.org/dc/terms/bazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazhttp://purl.org/dc/terms/bazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index 30c16895a07..fe1595bf154 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -66,6 +66,8 @@ resources: hydraContext: foo: bar: baz + jsonldContext: + dct: 'http://purl.org/dc/terms/' openapi: extensionProperties: bar: baz @@ -191,6 +193,8 @@ resources: hydraContext: foo: bar: baz + jsonldContext: + dct: 'http://purl.org/dc/terms/' openapi: extensionProperties: bar: baz @@ -339,6 +343,7 @@ resources: strictQueryParameterValidation: false hideHydraOperation: false jsonStream: true + throwOnNotFound: true extraProperties: custom_property: 'Lorem ipsum dolor sit amet' another_custom_property: diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php index 1e42121c528..acda234e4b1 100644 --- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php @@ -143,6 +143,9 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'hydraContext' => [ 'foo' => ['bar' => 'baz'], ], + 'jsonldContext' => [ + 'dct' => 'http://purl.org/dc/terms/', + ], 'openapi' => [ 'extensionProperties' => [ 'bar' => 'baz', @@ -166,6 +169,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase ], ], 'jsonStream' => true, + 'throwOnNotFound' => true, 'mercure' => true, 'stateOptions' => [ 'elasticsearchOptions' => [ @@ -357,6 +361,9 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'hydraContext' => [ 'foo' => ['bar' => 'baz'], ], + 'jsonldContext' => [ + 'dct' => 'http://purl.org/dc/terms/', + ], 'openapi' => [ 'extensionProperties' => [ 'bar' => 'baz', @@ -476,6 +483,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'order', 'extraProperties', 'jsonStream', + 'throwOnNotFound', ]; private const EXTENDED_BASE = [ 'uriTemplate', @@ -502,6 +510,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'schemes', 'cacheHeaders', 'hydraContext', + 'jsonldContext', 'openapi', 'paginationViaCursor', 'stateOptions', diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php index 98ca94248e6..7b3f496d04e 100644 --- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php @@ -108,6 +108,9 @@ public function testValidXML(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], [ 'uriTemplate' => '/users/{author}/comments{._format}', @@ -285,6 +288,9 @@ public function testValidXML(): void 'routeName' => 'custom_route_name', 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], [ 'name' => null, @@ -399,6 +405,9 @@ public function testValidXML(): void 'routeName' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], 'graphQlOperations' => null, @@ -412,6 +421,9 @@ public function testValidXML(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], ], $extractor->getResources()); diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php index 9e24f4ac5a9..3ff86c07f4b 100644 --- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php @@ -105,6 +105,9 @@ public function testValidYaml(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], Program::class => [ @@ -178,6 +181,9 @@ public function testValidYaml(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], [ 'uriTemplate' => '/users/{author}/programs{._format}', @@ -322,6 +328,9 @@ public function testValidYaml(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], [ 'name' => null, @@ -409,6 +418,9 @@ public function testValidYaml(): void 'parameters' => ['author' => new QueryParameter(schema: ['type' => 'string'], required: true, key: 'author', description: 'hello')], 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], 'graphQlOperations' => null, @@ -422,6 +434,9 @@ public function testValidYaml(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], SingleFileConfigDummy::class => [ @@ -495,6 +510,9 @@ public function testValidYaml(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], ], $extractor->getResources()); diff --git a/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php index 07af77d035c..a8d6de53511 100644 --- a/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php @@ -99,14 +99,14 @@ class: AttributeResource::class, graphQlOperations: $this->getDefaultGraphqlOperations('AttributeResource', AttributeResource::class, AttributeResourceProvider::class) ), new ApiResource( - shortName: 'AttributeResource', + shortName: 'AttributeResource2', class: AttributeResource::class, uriTemplate: '/dummy/{dummyId}/attribute_resources/{identifier}{._format}', operations: [ '_api_/dummy/{dummyId}/attribute_resources/{identifier}{._format}_get' => new Get( class: AttributeResource::class, uriTemplate: '/dummy/{dummyId}/attribute_resources/{identifier}{._format}', - shortName: 'AttributeResource', + shortName: 'AttributeResource2', inputFormats: ['json' => ['application/merge-patch+json']], priority: 4, status: 301, @@ -116,7 +116,7 @@ class: AttributeResource::class, '_api_/dummy/{dummyId}/attribute_resources/{identifier}{._format}_patch' => new Patch( class: AttributeResource::class, uriTemplate: '/dummy/{dummyId}/attribute_resources/{identifier}{._format}', - shortName: 'AttributeResource', + shortName: 'AttributeResource2', inputFormats: ['json' => ['application/merge-patch+json']], priority: 5, status: 301, @@ -272,11 +272,9 @@ public function testNameDeclarationShouldNotBeRemoved(): void $this->assertTrue($operations->has('password_reset')); } - public function testDeduplicateShortNamesWhenEnabled(): void + public function testDeduplicateShortNames(): void { - $factory = new AttributesResourceMetadataCollectionFactory(defaults: [ - 'extra_properties' => ['deduplicate_resource_short_names' => true], - ], graphQlEnabled: true); + $factory = new AttributesResourceMetadataCollectionFactory(graphQlEnabled: true); $collection = $factory->create(AttributeResource::class); @@ -292,20 +290,6 @@ public function testDeduplicateShortNamesWhenEnabled(): void } } - /** @group legacy */ - public function testDeduplicateShortNamesTriggersDeprecationWhenDisabled(): void - { - $factory = new AttributesResourceMetadataCollectionFactory(graphQlEnabled: true); - - $this->expectUserDeprecationMessage('Since api-platform/core 4.2: Having multiple "#[ApiResource]" attributes with the same "shortName" "AttributeResource" on class "ApiPlatform\Metadata\Tests\Fixtures\ApiResource\AttributeResource" is deprecated and will result in automatic short name deduplication in API Platform 5.x. Set "defaults.extra_properties.deduplicate_resource_short_names" to "true" in the API Platform configuration to enable it now.'); - - $collection = $factory->create(AttributeResource::class); - - // Without the flag, shortNames are NOT deduplicated - $this->assertSame('AttributeResource', $collection[0]->getShortName()); - $this->assertSame('AttributeResource', $collection[1]->getShortName()); - } - public function testWithParameters(): void { $attributeResourceMetadataCollectionFactory = new AttributesResourceMetadataCollectionFactory(); diff --git a/src/Metadata/Tests/Util/PropertyInfoToTypeInfoHelperTest.php b/src/Metadata/Tests/Util/PropertyInfoToTypeInfoHelperTest.php deleted file mode 100644 index 0725ed3ad9d..00000000000 --- a/src/Metadata/Tests/Util/PropertyInfoToTypeInfoHelperTest.php +++ /dev/null @@ -1,104 +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); - -/* - * This file is part of the Symfony package. - * - * (c) Fabien Potencier - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace ApiPlatform\Metadata\Tests\Util; - -use ApiPlatform\Metadata\Util\PropertyInfoToTypeInfoHelper; -use PHPUnit\Framework\TestCase; -use Symfony\Component\PropertyInfo\Type as LegacyType; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\TypeIdentifier; - -class PropertyInfoToTypeInfoHelperTest extends TestCase -{ - public function testConvertLegacyTypesToType(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - - $type = Type::collection(Type::builtin(TypeIdentifier::ARRAY), Type::int(), Type::string()); // @phpstan-ignore-line - - $tests = [ - [null, null], - [Type::null(), [new LegacyType('null')]], - // [Type::void(), [new LegacyType('void')]], - [Type::int(), [new LegacyType('int')]], - [Type::object(\stdClass::class), [new LegacyType('object', false, \stdClass::class)]], - [ - Type::generic(Type::object(\stdClass::class), Type::string(), Type::int()), - [new LegacyType('object', false, 'stdClass', false, [new LegacyType('string')], new LegacyType('int'))], - ], - [Type::nullable(Type::int()), [new LegacyType('int', true)]], - [Type::union(Type::int(), Type::string()), [new LegacyType('int'), new LegacyType('string')]], - [ - Type::union(Type::int(), Type::string(), Type::null()), - [new LegacyType('int', true), new LegacyType('string', true)], - ], - [$type, [new LegacyType('array', false, null, true, [new LegacyType('string')], new LegacyType('int'))]], - ]; - - foreach ($tests as [$expected, $legacyTypes]) { - $this->assertEquals($expected, PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($legacyTypes)); - } - } - - public function testConvertTypeToLegacyTypes(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - - $tests = [ - [null, null], - [null, Type::mixed()], - [null, Type::never()], - [[new LegacyType('null')], Type::null()], - [[new LegacyType('null')], Type::void()], - [[new LegacyType('int')], Type::int()], - [[new LegacyType('object', false, \stdClass::class)], Type::object(\stdClass::class)], - [ - [new LegacyType('object', false, \Traversable::class, true, null, new LegacyType('int'))], - Type::generic(Type::object(\Traversable::class), Type::int()), - ], - [ - [new LegacyType('array', false, null, true, new LegacyType('int'), new LegacyType('string'))], - Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::int(), Type::string()), // @phpstan-ignore-line - ], - [ - [new LegacyType('array', false, null, true, new LegacyType('int'), new LegacyType('string'))], - Type::collection(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::int()), // @phpstan-ignore-line - ], - [[new LegacyType('int', true)], Type::nullable(Type::int())], - [[new LegacyType('int'), new LegacyType('string')], Type::union(Type::int(), Type::string())], - [ - [new LegacyType('int', true), new LegacyType('string', true)], - Type::union(Type::int(), Type::string(), Type::null()), - ], - [[new LegacyType('object', false, \Stringable::class), new LegacyType('object', false, \Traversable::class)], Type::intersection(Type::object(\Traversable::class), Type::object(\Stringable::class))], - ]; - - foreach ($tests as [$expected, $type]) { - $this->assertEquals($expected, PropertyInfoToTypeInfoHelper::convertTypeToLegacyTypes($type)); - } - } -} diff --git a/src/Metadata/Util/PropertyInfoToTypeInfoHelper.php b/src/Metadata/Util/PropertyInfoToTypeInfoHelper.php deleted file mode 100644 index bda642848f8..00000000000 --- a/src/Metadata/Util/PropertyInfoToTypeInfoHelper.php +++ /dev/null @@ -1,307 +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 ApiPlatform\Metadata\Util; - -use Symfony\Component\PropertyInfo\Type as LegacyType; -use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\BuiltinType; -use Symfony\Component\TypeInfo\Type\CollectionType; -use Symfony\Component\TypeInfo\Type\GenericType; -use Symfony\Component\TypeInfo\Type\IntersectionType; -use Symfony\Component\TypeInfo\Type\NullableType; -use Symfony\Component\TypeInfo\Type\ObjectType; -use Symfony\Component\TypeInfo\Type\UnionType; -use Symfony\Component\TypeInfo\TypeIdentifier; - -/** - * A helper about PropertyInfo Type conversion. - * - * @see https://github.com/mtarld/symfony/commits/backup/chore/deprecate-property-info-type/ - * - * @author Mathias Arlaud - * - * @internal - */ -final class PropertyInfoToTypeInfoHelper -{ - /** - * Converts a {@see LegacyType} to what is should have been in the "symfony/type-info" component. - * - * @param list|null $legacyTypes - */ - public static function convertLegacyTypesToType(?array $legacyTypes): ?Type - { - if (!$legacyTypes) { - return null; - } - - $types = []; - $nullable = false; - - foreach (array_map(self::convertLegacyTypeToType(...), $legacyTypes) as $type) { - if ($type->isNullable()) { - $nullable = true; - - if ($type instanceof BuiltinType && TypeIdentifier::NULL === $type->getTypeIdentifier()) { - continue; - } - - $type = self::unwrapNullableType($type); - } - - if ($type instanceof UnionType) { - $types = [$types, ...$type->getTypes()]; - - continue; - } - - $types[] = $type; - } - - if ($nullable && [] === $types) { - return Type::null(); - } - - $type = \count($types) > 1 ? Type::union(...$types) : $types[0]; - if ($nullable) { - $type = Type::nullable($type); - } - - return $type; - } - - /** - * @param list $collectionKeyTypes - * @param list $collectionValueTypes - */ - public static function createTypeFromLegacyValues(string $builtinType, bool $nullable, ?string $class, bool $collection, array $collectionKeyTypes, array $collectionValueTypes): Type - { - $variableTypes = []; - - if ($collectionKeyTypes) { - $collectionKeyTypes = array_unique(array_map(self::convertLegacyTypeToType(...), $collectionKeyTypes)); - $variableTypes[] = \count($collectionKeyTypes) > 1 ? Type::union(...$collectionKeyTypes) : $collectionKeyTypes[0]; - } - - if ($collectionValueTypes) { - if (!$collectionKeyTypes) { - $variableTypes[] = \is_array($collectionKeyTypes) ? Type::mixed() : Type::union(Type::int(), Type::string()); // @phpstan-ignore-line - } - - $collectionValueTypes = array_unique(array_map(self::convertLegacyTypeToType(...), $collectionValueTypes)); - $variableTypes[] = \count($collectionValueTypes) > 1 ? Type::union(...$collectionValueTypes) : $collectionValueTypes[0]; - } - - if ($collectionKeyTypes && !$collectionValueTypes) { - $variableTypes[] = Type::mixed(); - } - - try { - $type = null !== $class ? Type::object($class) : Type::builtin(TypeIdentifier::from($builtinType)); - } catch (\ValueError) { - throw new InvalidArgumentException(\sprintf('"%s" is not a valid PHP type.', $builtinType)); - } - - if (\count($variableTypes)) { - // hack to have generic without classname - // this is required because some tests are using invalid data - if (null === $class && 'object' === $builtinType) { - $type = Type::object(\stdClass::class); - } - $type = Type::generic($type, ...$variableTypes); - } - - if ($collection) { - $type = Type::collection($type); - } - - if ($nullable && !$type->isNullable()) { - $type = Type::nullable($type); - } - - return $type; - } - - public static function unwrapNullableType(Type $type): Type - { - // BC layer for "symfony/type-info" < 7.2 - if (method_exists($type, 'asNonNullable')) { - return (!$type instanceof UnionType) ? $type : $type->asNonNullable(); - } - - if (!$type instanceof NullableType) { - return $type; - } - - return $type->getWrappedType(); - } - - /** - * Recursive method that converts {@see LegacyType} to its related {@see Type}. - */ - private static function convertLegacyTypeToType(LegacyType $legacyType): Type - { - return self::createTypeFromLegacyValues( - $legacyType->getBuiltinType(), - $legacyType->isNullable(), - $legacyType->getClassName(), - $legacyType->isCollection(), - $legacyType->getCollectionKeyTypes(), - $legacyType->getCollectionValueTypes(), - ); - } - - /** - * Converts a {@see Type} to what is should have been in the "symfony/property-info" component. - * - * @return list|null - */ - public static function convertTypeToLegacyTypes(?Type $type): ?array - { - if (null === $type) { - return null; - } - - if (\in_array((string) $type, ['mixed', 'never'], true)) { - return null; - } - - if (\in_array((string) $type, ['null', 'void'], true)) { - return [new LegacyType('null')]; - } - - $legacyType = self::convertTypeToLegacy($type); - - if (!\is_array($legacyType)) { - $legacyType = [$legacyType]; - } - - return $legacyType; - } - - /** - * Recursive method that converts {@see Type} to its related {@see LegacyType} (or list of {@see @LegacyType}). - * - * @return LegacyType|list - */ - private static function convertTypeToLegacy(Type $type): LegacyType|array - { - $nullable = false; - - if ($type instanceof NullableType) { - $nullable = true; - $type = $type->getWrappedType(); - } - - if ($type instanceof UnionType) { - $unionTypes = []; - foreach ($type->getTypes() as $t) { - if ($t instanceof IntersectionType) { - throw new \LogicException(\sprintf('DNF types are not supported by "%s".', LegacyType::class)); - } - - if ($nullable) { - $t = Type::nullable($t); - } - - $unionTypes[] = $t; - } - - /** @var list $legacyTypes */ - $legacyTypes = array_map(self::convertTypeToLegacy(...), $unionTypes); - - if (1 === \count($legacyTypes)) { - return $legacyTypes[0]; - } - - return $legacyTypes; - } - - if ($type instanceof IntersectionType) { - /** @var list $legacyTypes */ - $legacyTypes = array_map(self::convertTypeToLegacy(...), $type->getTypes()); - - if (1 === \count($legacyTypes)) { - return $legacyTypes[0]; - } - - return $legacyTypes; - } - - if ($type instanceof CollectionType) { - $type = $type->getWrappedType(); - if ($nullable) { - $type = Type::nullable($type); - } - - return self::convertTypeToLegacy($type); - } - - $typeIdentifier = TypeIdentifier::MIXED; - $className = null; - $collectionKeyType = $collectionValueType = null; - - if ($type instanceof GenericType) { - $wrappedType = $type->getWrappedType(); - - if ($wrappedType instanceof BuiltinType) { - $typeIdentifier = $wrappedType->getTypeIdentifier(); - } elseif ($wrappedType instanceof ObjectType) { - $typeIdentifier = TypeIdentifier::OBJECT; - $className = $wrappedType->getClassName(); - } - - $variableTypes = $type->getVariableTypes(); - - if (2 === \count($variableTypes)) { - if ('int|string' !== (string) $variableTypes[0]) { - $collectionKeyType = self::convertTypeToLegacy($variableTypes[0]); - } - $collectionValueType = self::convertTypeToLegacy($variableTypes[1]); - } elseif (1 === \count($variableTypes)) { - $collectionValueType = self::convertTypeToLegacy($variableTypes[0]); - } - } elseif ($type instanceof ObjectType) { - $typeIdentifier = TypeIdentifier::OBJECT; - $className = $type->getClassName(); - } elseif ($type instanceof BuiltinType) { - $typeIdentifier = $type->getTypeIdentifier(); - } - - if (TypeIdentifier::MIXED === $typeIdentifier) { - return [ - new LegacyType(LegacyType::BUILTIN_TYPE_INT, true), - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT, true), - new LegacyType(LegacyType::BUILTIN_TYPE_STRING, true), - new LegacyType(LegacyType::BUILTIN_TYPE_BOOL, true), - new LegacyType(LegacyType::BUILTIN_TYPE_RESOURCE, true), - new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, true), - new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, true), - new LegacyType(LegacyType::BUILTIN_TYPE_NULL, true), - new LegacyType(LegacyType::BUILTIN_TYPE_CALLABLE, true), - new LegacyType(LegacyType::BUILTIN_TYPE_ITERABLE, true), - ]; - } - - return new LegacyType( - builtinType: $typeIdentifier->value, - nullable: $nullable, - class: $className, - collection: $type instanceof GenericType, - collectionKeyType: $collectionKeyType, - collectionValueType: $collectionValueType, - ); - } -} diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index 0433c6e8e72..947d91bbf84 100644 --- a/src/Metadata/composer.json +++ b/src/Metadata/composer.json @@ -31,22 +31,22 @@ "doctrine/inflector": "^2.0", "psr/cache": "^1.0 || ^2.0 || ^3.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/string": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/property-info": "^7.4 || ^8.0", + "symfony/string": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { - "api-platform/json-schema": "^4.3", - "api-platform/openapi": "^4.3", - "api-platform/state": "^4.3", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/openapi": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "phpspec/prophecy-phpunit": "^2.2", "phpstan/phpdoc-parser": "^1.29 || ^2.0", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/config": "^6.4 || ^7.0 || ^8.0", - "symfony/routing": "^6.4 || ^7.0 || ^8.0", - "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + "symfony/config": "^7.4 || ^8.0", + "symfony/routing": "^7.4 || ^8.0", + "symfony/var-dumper": "^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0" }, "suggest": { "phpstan/phpdoc-parser": "For PHP documentation support.", @@ -73,13 +73,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 6273ea8ba51..1612d6e22e9 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -58,8 +58,6 @@ use ApiPlatform\State\Util\StateOptionsTrait; use ApiPlatform\Validator\Exception\ValidationException; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\TypeInfo\Type; @@ -728,28 +726,21 @@ private function getFilterParameter(string $name, array $description, string $sh if (!isset($description['openapi']) || $description['openapi'] instanceof Parameter) { $schema = $description['schema'] ?? []; - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true) && !isset($schema['type'])) { - $type = Type::builtin($description['type']); - if ($description['is_collection'] ?? false) { - $type = Type::array($type, Type::int()); - } - - $schema += $this->getType($type); - } - // TODO: remove in 5.x - } else { - if (isset($description['type']) && \in_array($description['type'], LegacyType::$builtinTypes, true) && !isset($schema['type'])) { - $schema += $this->getType(new LegacyType($description['type'], false, null, $description['is_collection'] ?? false)); + if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true) && !isset($schema['type'])) { + $type = Type::builtin($description['type']); + if ($description['is_collection'] ?? false) { + $type = Type::array($type, Type::int()); } + + $schema += $this->getType($type); } if (!isset($schema['type'])) { $schema['type'] = 'string'; } - $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; - $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; + $arrayValueType = TypeIdentifier::ARRAY->value; + $objectValueType = TypeIdentifier::OBJECT->value; $isArraySchema = 'array' === ($schema['type'] ?? null); $style = $isArraySchema && \in_array( @@ -776,26 +767,19 @@ private function getFilterParameter(string $name, array $description, string $sh $schema = $description['schema'] ?? null; if (!$schema) { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true)) { - $type = Type::builtin($description['type']); - if ($description['is_collection'] ?? false) { - $type = Type::array($type, key: Type::int()); - } - $schema = $this->getType($type); - } else { - $schema = ['type' => 'string']; + if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true)) { + $type = Type::builtin($description['type']); + if ($description['is_collection'] ?? false) { + $type = Type::array($type, key: Type::int()); } - // TODO: remove in 5.x + $schema = $this->getType($type); } else { - $schema = isset($description['type']) && \in_array($description['type'], LegacyType::$builtinTypes, true) - ? $this->getType(new LegacyType($description['type'], false, null, $description['is_collection'] ?? false)) - : ['type' => 'string']; + $schema = ['type' => 'string']; } } - $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; - $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; + $arrayValueType = TypeIdentifier::ARRAY->value; + $objectValueType = TypeIdentifier::OBJECT->value; $isArraySchema = 'array' === $schema['type']; diff --git a/src/OpenApi/Factory/TypeFactoryTrait.php b/src/OpenApi/Factory/TypeFactoryTrait.php index f711e92a2db..d5386240e18 100644 --- a/src/OpenApi/Factory/TypeFactoryTrait.php +++ b/src/OpenApi/Factory/TypeFactoryTrait.php @@ -14,7 +14,6 @@ namespace ApiPlatform\OpenApi\Factory; use Ramsey\Uuid\UuidInterface; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type as NativeType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -30,30 +29,9 @@ trait TypeFactoryTrait /** * @return array */ - private function getType(LegacyType|NativeType $type): array + private function getType(NativeType $type): array { - if ($type instanceof NativeType) { - return $this->getNativeType($type); - } - - if ($type->isCollection()) { - $keyType = $type->getCollectionKeyTypes()[0] ?? null; - $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new LegacyType($type->getBuiltinType(), false, $type->getClassName(), false); - - if (null !== $keyType && LegacyType::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { - return $this->addNullabilityToTypeDefinition([ - 'type' => 'object', - 'additionalProperties' => $this->getType($subType), - ], $type); - } - - return $this->addNullabilityToTypeDefinition([ - 'type' => 'array', - 'items' => $this->getType($subType), - ], $type); - } - - return $this->addNullabilityToTypeDefinition($this->makeLegacyBasicType($type), $type); + return $this->getNativeType($type); } /** @@ -81,20 +59,6 @@ private function getNativeType(NativeType $type): array return $this->addNullabilityToTypeDefinition($this->makeBasicType($type), $type); } - /** - * @return array - */ - private function makeLegacyBasicType(LegacyType $type): array - { - return match ($type->getBuiltinType()) { - LegacyType::BUILTIN_TYPE_INT => ['type' => 'integer'], - LegacyType::BUILTIN_TYPE_FLOAT => ['type' => 'number'], - LegacyType::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], - LegacyType::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable()), - default => ['type' => 'string'], - }; - } - /** * @return array */ @@ -182,7 +146,7 @@ private function getClassType(?string $className, bool $nullable): array * * @return array */ - private function addNullabilityToTypeDefinition(array $jsonSchema, LegacyType|NativeType $type): array + private function addNullabilityToTypeDefinition(array $jsonSchema, NativeType $type): array { if (!$type->isNullable()) { return $jsonSchema; diff --git a/src/OpenApi/Model/Components.php b/src/OpenApi/Model/Components.php index 04c6ef14023..2ecec747d6e 100644 --- a/src/OpenApi/Model/Components.php +++ b/src/OpenApi/Model/Components.php @@ -30,8 +30,9 @@ final class Components * @param \ArrayObject|\ArrayObject $links * @param \ArrayObject>|\ArrayObject> $callbacks * @param \ArrayObject|\ArrayObject $pathItems + * @param \ArrayObject|\ArrayObject $mediaTypes */ - public function __construct(?\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null) + public function __construct(?\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null, private ?\ArrayObject $mediaTypes = null) { $schemas?->ksort(); @@ -88,6 +89,11 @@ public function getPathItems(): ?\ArrayObject return $this->pathItems; } + public function getMediaTypes(): ?\ArrayObject + { + return $this->mediaTypes; + } + public function withSchemas(\ArrayObject $schemas): self { $clone = clone $this; @@ -167,4 +173,12 @@ public function withPathItems(\ArrayObject $pathItems): self return $clone; } + + public function withMediaTypes(\ArrayObject $mediaTypes): self + { + $clone = clone $this; + $clone->mediaTypes = $mediaTypes; + + return $clone; + } } diff --git a/src/OpenApi/Model/Encoding.php b/src/OpenApi/Model/Encoding.php index d56ee0e436f..4ec8f7d7e7c 100644 --- a/src/OpenApi/Model/Encoding.php +++ b/src/OpenApi/Model/Encoding.php @@ -17,7 +17,10 @@ final class Encoding { use ExtensionTrait; - public function __construct(private string $contentType = '', private ?\ArrayObject $headers = null, private string $style = '', private bool $explode = false, private bool $allowReserved = false) + /** + * @param array|null $prefixEncoding + */ + public function __construct(private string $contentType = '', private ?\ArrayObject $headers = null, private string $style = '', private bool $explode = false, private bool $allowReserved = false, private ?\ArrayObject $encoding = null, private ?array $prefixEncoding = null, private ?self $itemEncoding = null) { } @@ -56,6 +59,24 @@ public function getAllowReserved(): bool return $this->allowReserved; } + public function getEncoding(): ?\ArrayObject + { + return $this->encoding; + } + + /** + * @return array|null + */ + public function getPrefixEncoding(): ?array + { + return $this->prefixEncoding; + } + + public function getItemEncoding(): ?self + { + return $this->itemEncoding; + } + public function withContentType(string $contentType): self { $clone = clone $this; @@ -95,4 +116,31 @@ public function withAllowReserved(bool $allowReserved): self return $clone; } + + public function withEncoding(?\ArrayObject $encoding): self + { + $clone = clone $this; + $clone->encoding = $encoding; + + return $clone; + } + + /** + * @param array|null $prefixEncoding + */ + public function withPrefixEncoding(?array $prefixEncoding): self + { + $clone = clone $this; + $clone->prefixEncoding = $prefixEncoding; + + return $clone; + } + + public function withItemEncoding(self $itemEncoding): self + { + $clone = clone $this; + $clone->itemEncoding = $itemEncoding; + + return $clone; + } } diff --git a/src/OpenApi/Model/Example.php b/src/OpenApi/Model/Example.php index 4b2f1903c78..583820d407b 100644 --- a/src/OpenApi/Model/Example.php +++ b/src/OpenApi/Model/Example.php @@ -17,7 +17,7 @@ final class Example { use ExtensionTrait; - public function __construct(private ?string $summary = null, private ?string $description = null, private mixed $value = null, private ?string $externalValue = null) + public function __construct(private ?string $summary = null, private ?string $description = null, private mixed $value = null, private ?string $externalValue = null, private mixed $dataValue = null, private ?string $serializedValue = null) { } @@ -72,4 +72,30 @@ public function withExternalValue(string $externalValue): self return $clone; } + + public function getDataValue(): mixed + { + return $this->dataValue; + } + + public function withDataValue(mixed $dataValue): self + { + $clone = clone $this; + $clone->dataValue = $dataValue; + + return $clone; + } + + public function getSerializedValue(): ?string + { + return $this->serializedValue; + } + + public function withSerializedValue(string $serializedValue): self + { + $clone = clone $this; + $clone->serializedValue = $serializedValue; + + return $clone; + } } diff --git a/src/OpenApi/Model/MediaType.php b/src/OpenApi/Model/MediaType.php index ea50465398f..10d9c1d7d4c 100644 --- a/src/OpenApi/Model/MediaType.php +++ b/src/OpenApi/Model/MediaType.php @@ -17,7 +17,10 @@ final class MediaType { use ExtensionTrait; - public function __construct(private ?\ArrayObject $schema = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null) + /** + * @param array|null $prefixEncoding + */ + public function __construct(private ?\ArrayObject $schema = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null, private ?\ArrayObject $itemSchema = null, private ?array $prefixEncoding = null, private ?Encoding $itemEncoding = null) { } @@ -41,6 +44,24 @@ public function getEncoding(): ?Encoding return $this->encoding; } + public function getItemSchema(): ?\ArrayObject + { + return $this->itemSchema; + } + + /** + * @return array|null + */ + public function getPrefixEncoding(): ?array + { + return $this->prefixEncoding; + } + + public function getItemEncoding(): ?Encoding + { + return $this->itemEncoding; + } + public function withSchema(\ArrayObject $schema): self { $clone = clone $this; @@ -72,4 +93,31 @@ public function withEncoding(Encoding $encoding): self return $clone; } + + public function withItemSchema(\ArrayObject $itemSchema): self + { + $clone = clone $this; + $clone->itemSchema = $itemSchema; + + return $clone; + } + + /** + * @param array|null $prefixEncoding + */ + public function withPrefixEncoding(?array $prefixEncoding): self + { + $clone = clone $this; + $clone->prefixEncoding = $prefixEncoding; + + return $clone; + } + + public function withItemEncoding(Encoding $itemEncoding): self + { + $clone = clone $this; + $clone->itemEncoding = $itemEncoding; + + return $clone; + } } diff --git a/src/OpenApi/Model/OAuthFlow.php b/src/OpenApi/Model/OAuthFlow.php index 2c2e356fbe7..479d1853da8 100644 --- a/src/OpenApi/Model/OAuthFlow.php +++ b/src/OpenApi/Model/OAuthFlow.php @@ -17,7 +17,7 @@ final class OAuthFlow { use ExtensionTrait; - public function __construct(private ?string $authorizationUrl = null, private ?string $tokenUrl = null, private ?string $refreshUrl = null, private ?\ArrayObject $scopes = null) + public function __construct(private ?string $authorizationUrl = null, private ?string $tokenUrl = null, private ?string $refreshUrl = null, private ?\ArrayObject $scopes = null, private ?string $deviceAuthorizationUrl = null) { } @@ -41,6 +41,11 @@ public function getScopes(): \ArrayObject return $this->scopes; } + public function getDeviceAuthorizationUrl(): ?string + { + return $this->deviceAuthorizationUrl; + } + public function withAuthorizationUrl(string $authorizationUrl): self { $clone = clone $this; @@ -72,4 +77,12 @@ public function withScopes(\ArrayObject $scopes): self return $clone; } + + public function withDeviceAuthorizationUrl(string $deviceAuthorizationUrl): self + { + $clone = clone $this; + $clone->deviceAuthorizationUrl = $deviceAuthorizationUrl; + + return $clone; + } } diff --git a/src/OpenApi/Model/OAuthFlows.php b/src/OpenApi/Model/OAuthFlows.php index ad0f9fb7049..d677d105cad 100644 --- a/src/OpenApi/Model/OAuthFlows.php +++ b/src/OpenApi/Model/OAuthFlows.php @@ -17,7 +17,7 @@ final class OAuthFlows { use ExtensionTrait; - public function __construct(private ?OAuthFlow $implicit = null, private ?OAuthFlow $password = null, private ?OAuthFlow $clientCredentials = null, private ?OAuthFlow $authorizationCode = null) + public function __construct(private ?OAuthFlow $implicit = null, private ?OAuthFlow $password = null, private ?OAuthFlow $clientCredentials = null, private ?OAuthFlow $authorizationCode = null, private ?OAuthFlow $deviceAuthorization = null) { } @@ -41,6 +41,11 @@ public function getAuthorizationCode(): ?OAuthFlow return $this->authorizationCode; } + public function getDeviceAuthorization(): ?OAuthFlow + { + return $this->deviceAuthorization; + } + public function withImplicit(OAuthFlow $implicit): self { $clone = clone $this; @@ -72,4 +77,12 @@ public function withAuthorizationCode(OAuthFlow $authorizationCode): self return $clone; } + + public function withDeviceAuthorization(OAuthFlow $deviceAuthorization): self + { + $clone = clone $this; + $clone->deviceAuthorization = $deviceAuthorization; + + return $clone; + } } diff --git a/src/OpenApi/Model/PathItem.php b/src/OpenApi/Model/PathItem.php index e481e7536b8..8ff59cb3ffe 100644 --- a/src/OpenApi/Model/PathItem.php +++ b/src/OpenApi/Model/PathItem.php @@ -19,7 +19,7 @@ final class PathItem public static array $methods = ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE']; - public function __construct(private ?string $ref = null, private ?string $summary = null, private ?string $description = null, private ?Operation $get = null, private ?Operation $put = null, private ?Operation $post = null, private ?Operation $delete = null, private ?Operation $options = null, private ?Operation $head = null, private ?Operation $patch = null, private ?Operation $trace = null, private ?array $servers = null, private ?array $parameters = null) + public function __construct(private ?string $ref = null, private ?string $summary = null, private ?string $description = null, private ?Operation $get = null, private ?Operation $put = null, private ?Operation $post = null, private ?Operation $delete = null, private ?Operation $options = null, private ?Operation $head = null, private ?Operation $patch = null, private ?Operation $trace = null, private ?array $servers = null, private ?array $parameters = null, private ?Operation $query = null, private ?array $additionalOperations = null) { } @@ -88,6 +88,19 @@ public function getParameters(): ?array return $this->parameters; } + public function getQuery(): ?Operation + { + return $this->query; + } + + /** + * @return array|null + */ + public function getAdditionalOperations(): ?array + { + return $this->additionalOperations; + } + public function withRef(string $ref): self { $clone = clone $this; @@ -191,4 +204,23 @@ public function withParameters(?array $parameters = null): self return $clone; } + + public function withQuery(?Operation $query): self + { + $clone = clone $this; + $clone->query = $query; + + return $clone; + } + + /** + * @param array|null $additionalOperations + */ + public function withAdditionalOperations(?array $additionalOperations = null): self + { + $clone = clone $this; + $clone->additionalOperations = $additionalOperations; + + return $clone; + } } diff --git a/src/OpenApi/Model/Response.php b/src/OpenApi/Model/Response.php index 187e8be10ec..b417b9d4a91 100644 --- a/src/OpenApi/Model/Response.php +++ b/src/OpenApi/Model/Response.php @@ -17,7 +17,7 @@ final class Response { use ExtensionTrait; - public function __construct(private ?string $description = null, private ?\ArrayObject $content = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $links = null) + public function __construct(private ?string $description = null, private ?\ArrayObject $content = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $links = null, private ?string $summary = null) { } @@ -41,6 +41,11 @@ public function getLinks(): ?\ArrayObject return $this->links; } + public function getSummary(): ?string + { + return $this->summary; + } + public function withDescription(string $description): self { $clone = clone $this; @@ -72,4 +77,12 @@ public function withLinks(\ArrayObject $links): self return $clone; } + + public function withSummary(string $summary): self + { + $clone = clone $this; + $clone->summary = $summary; + + return $clone; + } } diff --git a/src/OpenApi/Model/SecurityScheme.php b/src/OpenApi/Model/SecurityScheme.php index 52ed63fc6fc..b2ab6edcf96 100644 --- a/src/OpenApi/Model/SecurityScheme.php +++ b/src/OpenApi/Model/SecurityScheme.php @@ -17,7 +17,7 @@ final class SecurityScheme { use ExtensionTrait; - public function __construct(private ?string $type = null, private string $description = '', private ?string $name = null, private ?string $in = null, private ?string $scheme = null, private ?string $bearerFormat = null, private ?OAuthFlows $flows = null, private ?string $openIdConnectUrl = null) + public function __construct(private ?string $type = null, private string $description = '', private ?string $name = null, private ?string $in = null, private ?string $scheme = null, private ?string $bearerFormat = null, private ?OAuthFlows $flows = null, private ?string $openIdConnectUrl = null, private ?string $oauth2MetadataUrl = null, private ?bool $deprecated = null) { } @@ -61,6 +61,16 @@ public function getOpenIdConnectUrl(): ?string return $this->openIdConnectUrl; } + public function getOauth2MetadataUrl(): ?string + { + return $this->oauth2MetadataUrl; + } + + public function getDeprecated(): ?bool + { + return $this->deprecated; + } + public function withType(string $type): self { $clone = clone $this; @@ -124,4 +134,20 @@ public function withOpenIdConnectUrl(string $openIdConnectUrl): self return $clone; } + + public function withOauth2MetadataUrl(string $oauth2MetadataUrl): self + { + $clone = clone $this; + $clone->oauth2MetadataUrl = $oauth2MetadataUrl; + + return $clone; + } + + public function withDeprecated(bool $deprecated): self + { + $clone = clone $this; + $clone->deprecated = $deprecated; + + return $clone; + } } diff --git a/src/OpenApi/Model/Server.php b/src/OpenApi/Model/Server.php index e5a50a7e6b5..8b5d9ed78ff 100644 --- a/src/OpenApi/Model/Server.php +++ b/src/OpenApi/Model/Server.php @@ -17,7 +17,7 @@ final class Server { use ExtensionTrait; - public function __construct(private string $url, private string $description = '', private ?\ArrayObject $variables = null) + public function __construct(private string $url, private string $description = '', private ?\ArrayObject $variables = null, private ?string $name = null) { } @@ -36,6 +36,11 @@ public function getVariables(): ?\ArrayObject return $this->variables; } + public function getName(): ?string + { + return $this->name; + } + public function withUrl(string $url): self { $clone = clone $this; @@ -59,4 +64,12 @@ public function withVariables(\ArrayObject $variables): self return $clone; } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } } diff --git a/src/OpenApi/Model/Tag.php b/src/OpenApi/Model/Tag.php index c0793522a15..82e8e700bdf 100644 --- a/src/OpenApi/Model/Tag.php +++ b/src/OpenApi/Model/Tag.php @@ -17,7 +17,7 @@ final class Tag { use ExtensionTrait; - public function __construct(private string $name, private ?string $description = null, private ?string $externalDocs = null) + public function __construct(private string $name, private ?string $description = null, private ?string $externalDocs = null, private ?string $summary = null, private ?string $parent = null, private ?string $kind = null) { } @@ -59,4 +59,43 @@ public function withExternalDocs(string $externalDocs): self return $clone; } + + public function getSummary(): ?string + { + return $this->summary; + } + + public function withSummary(string $summary): self + { + $clone = clone $this; + $clone->summary = $summary; + + return $clone; + } + + public function getParent(): ?string + { + return $this->parent; + } + + public function withParent(string $parent): self + { + $clone = clone $this; + $clone->parent = $parent; + + return $clone; + } + + public function getKind(): ?string + { + return $this->kind; + } + + public function withKind(string $kind): self + { + $clone = clone $this; + $clone->kind = $kind; + + return $clone; + } } diff --git a/src/OpenApi/OpenApi.php b/src/OpenApi/OpenApi.php index 61a43d7d490..ac17632ff9d 100644 --- a/src/OpenApi/OpenApi.php +++ b/src/OpenApi/OpenApi.php @@ -17,12 +17,13 @@ use ApiPlatform\OpenApi\Model\ExtensionTrait; use ApiPlatform\OpenApi\Model\Info; use ApiPlatform\OpenApi\Model\Paths; +use Symfony\Component\Serializer\Attribute\SerializedName; final class OpenApi { use ExtensionTrait; - public const VERSION = '3.1.0'; + public const VERSION = '3.2.0'; private string $openapi = self::VERSION; private Components $components; @@ -30,7 +31,7 @@ final class OpenApi /** * @param array|null $externalDocs */ - public function __construct(private Info $info, private array $servers, private Paths $paths, ?Components $components = null, private array $security = [], private array $tags = [], private $externalDocs = null, private ?string $jsonSchemaDialect = null, private readonly ?\ArrayObject $webhooks = null) + public function __construct(private Info $info, private array $servers, private Paths $paths, ?Components $components = null, private array $security = [], private array $tags = [], private $externalDocs = null, private ?string $jsonSchemaDialect = null, private readonly ?\ArrayObject $webhooks = null, private ?string $self = null) { $this->components = $components ?? new Components(); } @@ -85,6 +86,12 @@ public function getWebhooks(): ?\ArrayObject return $this->webhooks; } + #[SerializedName('$self')] + public function getSelf(): ?string + { + return $this->self; + } + public function withOpenapi(string $openapi): self { $clone = clone $this; @@ -156,4 +163,12 @@ public function withJsonSchemaDialect(?string $jsonSchemaDialect): self return $clone; } + + public function withSelf(?string $self): self + { + $clone = clone $this; + $clone->self = $self; + + return $clone; + } } diff --git a/src/OpenApi/Options.php b/src/OpenApi/Options.php index e91976aa929..a22904bd15c 100644 --- a/src/OpenApi/Options.php +++ b/src/OpenApi/Options.php @@ -47,6 +47,7 @@ public function __construct( private ?string $errorResourceClass = null, private ?string $validationErrorResourceClass = null, private ?string $licenseIdentifier = null, + private bool $withCredentials = false, ) { } @@ -178,4 +179,9 @@ public function getLicenseIdentifier(): ?string { return $this->licenseIdentifier; } + + public function getWithCredentials(): bool + { + return $this->withCredentials; + } } diff --git a/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php b/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php index 747b2d23752..e9787431bd4 100644 --- a/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php +++ b/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php @@ -24,7 +24,7 @@ final class LegacyOpenApiNormalizer implements NormalizerInterface private const SCHEMA_NESTED_KEYS = ['items', 'additionalProperties', 'not', 'contains', 'propertyNames', 'if', 'then', 'else']; private array $defaultContext = [ - self::SPEC_VERSION => '3.1.0', + self::SPEC_VERSION => '3.2.0', ]; public function __construct(private readonly NormalizerInterface $decorated, array $defaultContext = []) diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index 89463d4fc63..c556998dbb1 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -531,7 +531,7 @@ public function testInvoke(): void $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $definitionNameFactory = new DefinitionNameFactory([]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceCollectionMetadataFactory, @@ -1397,7 +1397,7 @@ public function testGetExtensionPropertiesWithFalseValue(): void $resourceCollectionMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory([]); + $definitionNameFactory = new DefinitionNameFactory(); $resourceCollectionMetadata = new ResourceMetadataCollection(Dummy::class, [(new ApiResource(operations: [ (new Get())->withOpenapi(true)->withShortName('Dummy')->withName('api_dummies_get_collection')->withRouteName('api_dummies_get_collection'), @@ -1447,7 +1447,7 @@ public function testMetadataParameterInOpenApiOperationParametersThrows(): void $resourceCollectionMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory([]); + $definitionNameFactory = new DefinitionNameFactory(); $resourceCollectionMetadata = new ResourceMetadataCollection(Dummy::class, [(new ApiResource(operations: [ (new GetCollection()) diff --git a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php index efe1f25df25..4e5000e9c97 100644 --- a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php +++ b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php @@ -239,7 +239,7 @@ public function testNormalize(): void $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactory, diff --git a/src/OpenApi/composer.json b/src/OpenApi/composer.json index 11ea83cbc44..330698ab94d 100644 --- a/src/OpenApi/composer.json +++ b/src/OpenApi/composer.json @@ -28,23 +28,23 @@ ], "require": { "php": ">=8.2", - "api-platform/json-schema": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/state": "^4.3", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "api-platform/json-schema": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", + "symfony/console": "^7.4 || ^8.0", + "symfony/filesystem": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "api-platform/doctrine-common": "^4.3", - "api-platform/doctrine-orm": "^4.3", - "api-platform/doctrine-odm": "^4.3", - "api-platform/serializer": "^4.3.12", - "symfony/type-info": "^7.3 || ^8.0" + "api-platform/doctrine-common": "^5.0@alpha", + "api-platform/doctrine-orm": "^5.0@alpha", + "api-platform/doctrine-odm": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -66,13 +66,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/RamseyUuid/composer.json b/src/RamseyUuid/composer.json index ed025c5c9c4..2c6c0663157 100644 --- a/src/RamseyUuid/composer.json +++ b/src/RamseyUuid/composer.json @@ -23,15 +23,15 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0" + "api-platform/metadata": "^5.0@alpha", + "symfony/serializer": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -50,13 +50,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 21aa1de0fe0..1c124aff438 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -33,8 +33,6 @@ use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Encoder\CsvEncoder; use Symfony\Component\Serializer\Encoder\XmlEncoder; use Symfony\Component\Serializer\Exception\LogicException; @@ -610,29 +608,6 @@ protected function setAttributeValue(object $object, string $attribute, mixed $v } } - /** - * @deprecated since 4.1, use "validateAttributeType" instead - * - * Validates the type of the value. Allows using integers as floats for JSON formats. - * - * @throws NotNormalizableValueException - */ - protected function validateType(string $attribute, LegacyType $type, mixed $value, ?string $format = null, array $context = []): void - { - trigger_deprecation('api-platform/serializer', '4.1', 'The "%s()" method is deprecated, use "%s::validateAttributeType()" instead.', __METHOD__, self::class); - - $builtinType = $type->getBuiltinType(); - if (LegacyType::BUILTIN_TYPE_FLOAT === $builtinType && null !== $format && str_contains($format, 'json')) { - $isValid = \is_float($value) || \is_int($value); - } else { - $isValid = \call_user_func('is_'.$builtinType, $value); - } - - if (!$isValid) { - throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $builtinType, \gettype($value)), $value, [$builtinType], $context['deserialization_path'] ?? null); - } - } - /** * Validates the type of the value. Allows using integers as floats for JSON formats. * @@ -651,52 +626,6 @@ protected function validateAttributeType(string $attribute, Type $type, mixed $v } } - /** - * @deprecated since 4.1, use "denormalizeObjectCollection" instead. - * - * Denormalizes a collection of objects. - * - * @throws NotNormalizableValueException - */ - protected function denormalizeCollection(string $attribute, ApiProperty $propertyMetadata, LegacyType $type, string $className, mixed $value, ?string $format, array $context): array - { - trigger_deprecation('api-platform/serializer', '4.1', 'The "%s()" method is deprecated, use "%s::denormalizeObjectCollection()" instead.', __METHOD__, self::class); - - if (!\is_array($value)) { - throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute must be "array", "%s" given.', $attribute, \gettype($value)), $value, ['array'], $context['deserialization_path'] ?? null); - } - - $values = []; - $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format); - $collectionKeyTypes = $type->getCollectionKeyTypes(); - foreach ($value as $index => $obj) { - $currentChildContext = $childContext; - if (isset($childContext['deserialization_path'])) { - $currentChildContext['deserialization_path'] = "{$childContext['deserialization_path']}[{$index}]"; - } - - // no typehint provided on collection key - if (!$collectionKeyTypes) { - $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext); - continue; - } - - // validate collection key typehint - foreach ($collectionKeyTypes as $collectionKeyType) { - $collectionKeyBuiltinType = $collectionKeyType->getBuiltinType(); - if (!\call_user_func('is_'.$collectionKeyBuiltinType, $index)) { - continue; - } - - $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext); - continue 2; - } - throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the key "%s" must be "%s", "%s" given.', $index, $collectionKeyTypes[0]->getBuiltinType(), \gettype($index)), $index, [$collectionKeyTypes[0]->getBuiltinType()], ($context['deserialization_path'] ?? false) ? \sprintf('key(%s)', $context['deserialization_path']) : null, true); - } - - return $values; - } - /** * Denormalizes a collection of objects. * @@ -872,134 +801,6 @@ protected function getAttributeValue(object $object, string $attribute, ?string return $this->propertyAccessor->getValue($object, $attribute); } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - - foreach ($types as $type) { - if ( - $type->isCollection() - && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null) - && ($className = $collectionValueType->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - $childContext = $this->createChildContext($this->createOperationContext($context, $className, $propertyMetadata), $attribute, $format); - - // @see ApiPlatform\Hal\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content - // @see ApiPlatform\JsonApi\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content - if ('jsonld' === $format && $itemUriTemplate = $propertyMetadata->getUriTemplate()) { - $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation( - operationName: $itemUriTemplate, - forceCollection: true, - httpOperation: true - ); - - return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); - } - - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - - if (null === $attributeValue && $type->isNullable()) { - return null; - } - - if (!is_iterable($attributeValue)) { - throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.'); - } - - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - - $data = $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); - $context['data'] = $data; - $context['type'] = $type; - - if ($this->tagCollector) { - $this->tagCollector->collect($context); - } - - return $data; - } - - if ( - ($className = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - $childContext = $this->createChildContext($this->createOperationContext($context, $className, $propertyMetadata), $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables'], $childContext['item_uri_template']); - if ('jsonld' === $format && $uriTemplate = $propertyMetadata->getUriTemplate()) { - $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation( - operationName: $uriTemplate, - httpOperation: true - ); - - return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); - } - - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - - if (!\is_object($attributeValue) && null !== $attributeValue) { - throw new UnexpectedValueException('Unexpected non-object value for to-one relation.'); - } - - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - - $data = $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); - $context['data'] = $data; - $context['type'] = $type; - - if ($this->tagCollector) { - $this->tagCollector->collect($context); - } - - return $data; - } - - if (!$this->serializer instanceof NormalizerInterface) { - throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); - } - - unset( - $context['resource_class'], - $context['force_resource_class'], - $context['uri_variables'], - ); - - // Anonymous resources - if ($className) { - $childContext = $this->createChildContext($this->createOperationContext($context, $className, $propertyMetadata), $attribute, $format); - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - - return $this->serializer->normalize($attributeValue, $format, $childContext); - } - - if ('array' === $type->getBuiltinType()) { - if ($className = ($type->getCollectionValueTypes()[0] ?? null)?->getClassName()) { - $context = $this->createOperationContext($context, $className, $propertyMetadata); - } - - $childContext = $this->createChildContext($context, $attribute, $format); - $childContext['output']['gen_id'] ??= $propertyMetadata->getGenId() ?? true; - - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - - return $this->serializer->normalize($attributeValue, $format, $childContext); - } - } - - if (!$this->serializer instanceof NormalizerInterface) { - throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); - } - - unset( - $context['resource_class'], - $context['force_resource_class'], - $context['uri_variables'] - ); - - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - - return $this->serializer->normalize($attributeValue, $format, $context); - } - $type = $propertyMetadata->getNativeType(); $nullable = false; @@ -1214,13 +1015,8 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value { $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); - $type = null; - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - } else { - $type = $propertyMetadata->getNativeType(); - $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]); - } + $type = $propertyMetadata->getNativeType(); + $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]); $className = null; $typeIsResourceClass = function (Type $type) use (&$className): bool { @@ -1231,11 +1027,7 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value $denormalizationException = null; foreach ($types as $t) { - if ($type instanceof Type) { - $isNullable = $type->isNullable(); - } else { - $isNullable = $t->isNullable(); - } + $isNullable = $type->isNullable(); if (null === $value && ($isNullable || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false))) { return $value; @@ -1245,37 +1037,29 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value if ($t instanceof CollectionType) { $collectionValueType = $t->getCollectionValueType(); - } elseif ($t instanceof LegacyType) { - $collectionValueType = $t->getCollectionValueTypes()[0] ?? null; } /* From @see AbstractObjectNormalizer::validateAndDenormalize() */ // Fix a collection that contains the only one element // This is special to xml format only if ('xml' === $format && null !== $collectionValueType && (!\is_array($value) || !\is_int(key($value)))) { - $isMixedType = $collectionValueType instanceof Type && $collectionValueType->isIdentifiedBy(TypeIdentifier::MIXED); + $isMixedType = $collectionValueType->isIdentifiedBy(TypeIdentifier::MIXED); if (!$isMixedType) { $value = [$value]; } } - if (($collectionValueType instanceof Type && $collectionValueType->isSatisfiedBy($typeIsResourceClass)) - || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) - ) { + if ($collectionValueType instanceof Type && $collectionValueType->isSatisfiedBy($typeIsResourceClass)) { $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); $context['resource_class'] = $resourceClass; unset($context['uri_variables']); // Validate the IRI target against the declared collection value type so a union // (array) accepts an IRI pointing to any of its members, not just the first. - if ($collectionValueType instanceof Type) { - $context['relation_native_type'] = $collectionValueType; - } + $context['relation_native_type'] = $collectionValueType; try { - return $t instanceof Type - ? $this->denormalizeObjectCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context) - : $this->denormalizeCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context); + return $this->denormalizeObjectCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context); } catch (NotNormalizableValueException $e) { // union/intersect types: try the next type, if not valid, an exception will be thrown at the end if ($isMultipleTypes) { @@ -1288,15 +1072,10 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value } } - if ( - ($t instanceof Type && $t->isSatisfiedBy($typeIsResourceClass)) - || ($t instanceof LegacyType && null !== ($className = $t->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) - ) { + if ($t instanceof Type && $t->isSatisfiedBy($typeIsResourceClass)) { $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass, $propertyMetadata), $attribute, $format); - if ($t instanceof Type) { - $childContext['relation_native_type'] = $t; - } + $childContext['relation_native_type'] = $t; try { return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext); @@ -1320,11 +1099,8 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value $unwrappedCollectionValueType = $unwrappedCollectionValueType->getWrappedType(); } - if ( - ($t instanceof CollectionType && $unwrappedCollectionValueType instanceof ObjectType) - || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== $collectionValueType->getClassName()) - ) { - $className = $unwrappedCollectionValueType instanceof ObjectType ? $unwrappedCollectionValueType->getClassName() : $collectionValueType->getClassName(); + if ($t instanceof CollectionType && $unwrappedCollectionValueType instanceof ObjectType) { + $className = $unwrappedCollectionValueType->getClassName(); if (!$this->serializer instanceof DenormalizerInterface) { throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); } @@ -1349,10 +1125,7 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value $t = $t->getWrappedType(); } - if ( - $t instanceof ObjectType - || ($t instanceof LegacyType && null !== $t->getClassName()) - ) { + if ($t instanceof ObjectType) { if (!$this->serializer instanceof DenormalizerInterface) { throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); } @@ -1378,14 +1151,11 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value // if a value is meant to be a string, float, int or a boolean value from the serialized representation. // That's why we have to transform the values, if one of these non-string basic datatypes is expected. if (\is_string($value) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) { - if ('' === $value && $isNullable && ( - ($t instanceof Type && $t->isIdentifiedBy(TypeIdentifier::BOOL, TypeIdentifier::INT, TypeIdentifier::FLOAT)) - || ($t instanceof LegacyType && \in_array($t->getBuiltinType(), [LegacyType::BUILTIN_TYPE_BOOL, LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT], true)) - )) { + if ('' === $value && $isNullable && $t instanceof Type && $t->isIdentifiedBy(TypeIdentifier::BOOL, TypeIdentifier::INT, TypeIdentifier::FLOAT)) { return null; } - $typeIdentifier = $t instanceof BuiltinType ? $t->getTypeIdentifier() : TypeIdentifier::tryFrom($t->getBuiltinType()); + $typeIdentifier = $t instanceof BuiltinType ? $t->getTypeIdentifier() : null; switch ($typeIdentifier) { case TypeIdentifier::BOOL: @@ -1440,9 +1210,7 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value } try { - $t instanceof Type - ? $this->validateAttributeType($attribute, $t, $value, $format, $context) - : $this->validateType($attribute, $t, $value, $format, $context); + $this->validateAttributeType($attribute, $t, $value, $format, $context); $denormalizationException = null; break; diff --git a/src/Serializer/Filter/PropertyFilter.php b/src/Serializer/Filter/PropertyFilter.php index 01f36dfc0b1..2cf6c2594c9 100644 --- a/src/Serializer/Filter/PropertyFilter.php +++ b/src/Serializer/Filter/PropertyFilter.php @@ -280,7 +280,7 @@ public function getSchema(MetadataParameter $parameter): array public function getOpenApiParameters(MetadataParameter $parameter): Parameter { $example = \sprintf( - '%1$s[]={propertyName}&%1$s[]={anotherPropertyName}', + '%1$s[]={propertyName}&%1$s[]={anotherPropertyName}&%1$s[{nestedPropertyParent}][]={nestedProperty}', $parameter->getKey() ); diff --git a/src/Serializer/ItemDenormalizer.php b/src/Serializer/ItemDenormalizer.php new file mode 100644 index 00000000000..288bf3e20ec --- /dev/null +++ b/src/Serializer/ItemDenormalizer.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer; + +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Generic item denormalizer. + * + * @author Kévin Dunglas + */ +class ItemDenormalizer extends AbstractItemNormalizer +{ + use ItemNormalizerTrait; + + private readonly LoggerInterface $logger; + + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, ?LoggerInterface $logger = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = [], protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) + { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); + + $this->logger = $logger ?: new NullLogger(); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return false; + } +} diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index 051171bbe5d..0d683eca5da 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -13,20 +13,15 @@ namespace ApiPlatform\Serializer; -use ApiPlatform\Metadata\Exception\InvalidArgumentException; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; -use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\Metadata\UrlGeneratorInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -39,6 +34,10 @@ */ class ItemNormalizer extends AbstractItemNormalizer { + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } + private readonly LoggerInterface $logger; public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, ?LoggerInterface $logger = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = [], protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) @@ -48,74 +47,10 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->logger = $logger ?: new NullLogger(); } - /** - * {@inheritdoc} - * - * @throws NotNormalizableValueException - */ public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { - // Avoid issues with proxies if we populated the object - if (isset($data['id']) && !isset($context[self::OBJECT_TO_POPULATE])) { - if (isset($context['api_allow_update']) && true !== $context['api_allow_update']) { - throw new NotNormalizableValueException('Update is not allowed for this operation.'); - } - - if (isset($context['resource_class'])) { - if ($this->updateObjectToPopulate($data, $context)) { - unset($data['id']); - } - } else { - // See https://github.com/api-platform/core/pull/2326 to understand this message. - $this->logger->warning('The "resource_class" key is missing from the context.', [ - 'context' => $context, - ]); - } - } - - return parent::denormalize($data, $type, $format, $context); - } - - private function updateObjectToPopulate(array $data, array &$context): bool - { - try { - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri((string) $data['id'], $context + ['fetch_data' => true]); - - return true; - } catch (InvalidArgumentException) { - $operation = $this->resourceMetadataCollectionFactory?->create($context['resource_class'])->getOperation(); - if ( - !$operation || ( - null !== ($context['uri_variables'] ?? null) - && $operation instanceof HttpOperation - && \count($operation->getUriVariables() ?? []) > 1 - ) - ) { - throw new InvalidArgumentException('Cannot find object to populate, use JSON-LD or specify an IRI at path "id".'); - } - $uriVariables = $this->getContextUriVariables($data, $operation, $context); - $iri = $this->iriConverter->getIriFromResource($context['resource_class'], UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => $uriVariables]); - - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context + ['fetch_data' => true]); - } - - return false; - } - - private function getContextUriVariables(array $data, Operation $operation, array $context): array - { - $uriVariables = $context['uri_variables'] ?? []; - - if ($operation instanceof HttpOperation) { - $operationUriVariables = $operation->getUriVariables(); - if ((null !== $uriVariable = array_shift($operationUriVariables)) && \count($uriVariable->getIdentifiers())) { - $identifier = $uriVariable->getIdentifiers()[0]; - if (isset($data[$identifier])) { - $uriVariables[$uriVariable->getParameterName()] = $data[$identifier]; - } - } - } + trigger_deprecation('api-platform/core', '4.4', 'Calling "denormalize()" on "%s" is deprecated, use "%s" instead.', self::class, ItemDenormalizer::class); - return $uriVariables; + return $this->doDenormalize($data, $type, $format, $context); } } diff --git a/src/Serializer/ItemNormalizerTrait.php b/src/Serializer/ItemNormalizerTrait.php new file mode 100644 index 00000000000..1334f598148 --- /dev/null +++ b/src/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; + +/** + * Shared denormalization logic for the generic item (de)normalizer. + * + * @author Kévin Dunglas + * + * @internal + */ +trait ItemNormalizerTrait +{ + /** + * @throws NotNormalizableValueException + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + // Avoid issues with proxies if we populated the object + if (isset($data['id']) && !isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) { + if (isset($context['api_allow_update']) && true !== $context['api_allow_update']) { + throw new NotNormalizableValueException('Update is not allowed for this operation.'); + } + + if (isset($context['resource_class'])) { + if ($this->updateObjectToPopulate($data, $context)) { + unset($data['id']); + } + } else { + // See https://github.com/api-platform/core/pull/2326 to understand this message. + $this->logger->warning('The "resource_class" key is missing from the context.', [ + 'context' => $context, + ]); + } + } + + return parent::denormalize($data, $type, $format, $context); + } + + private function updateObjectToPopulate(array $data, array &$context): bool + { + try { + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri((string) $data['id'], $context + ['fetch_data' => true]); + + return true; + } catch (InvalidArgumentException) { + $operation = $this->resourceMetadataCollectionFactory?->create($context['resource_class'])->getOperation(); + if ( + !$operation || ( + null !== ($context['uri_variables'] ?? null) + && $operation instanceof HttpOperation + && \count($operation->getUriVariables() ?? []) > 1 + ) + ) { + throw new InvalidArgumentException('Cannot find object to populate, use JSON-LD or specify an IRI at path "id".'); + } + $uriVariables = $this->getContextUriVariables($data, $operation, $context); + $iri = $this->iriConverter->getIriFromResource($context['resource_class'], UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => $uriVariables]); + + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context + ['fetch_data' => true]); + } + + return false; + } + + private function getContextUriVariables(array $data, Operation $operation, array $context): array + { + $uriVariables = $context['uri_variables'] ?? []; + + if ($operation instanceof HttpOperation) { + $operationUriVariables = $operation->getUriVariables(); + if ((null !== $uriVariable = array_shift($operationUriVariables)) && \count($uriVariable->getIdentifiers())) { + $identifier = $uriVariable->getIdentifiers()[0]; + if (isset($data[$identifier])) { + $uriVariables[$uriVariable->getParameterName()] = $data[$identifier]; + } + } + } + + return $uriVariables; + } +} diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 63ee797f426..b259e6015ea 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -76,15 +76,6 @@ public function createFromRequest(Request $request, bool $normalization, ?array $context['types'] = $types; } - // TODO: remove this as uri variables are available in the SerializerProcessor but correctly parsed - if ($operation->getUriVariables()) { - $context['uri_variables'] = []; - - foreach (array_keys($operation->getUriVariables()) as $parameterName) { - $context['uri_variables'][$parameterName] = $request->attributes->get($parameterName); - } - } - if (null === $context['output'] && $this->getStateOptionsClass($operation)) { $context['force_resource_class'] = $operation->getClass(); } diff --git a/src/Serializer/State/JsonStreamerProcessor.php b/src/Serializer/State/JsonStreamerProcessor.php index c91b43bcba2..be1a3d3d201 100644 --- a/src/Serializer/State/JsonStreamerProcessor.php +++ b/src/Serializer/State/JsonStreamerProcessor.php @@ -48,6 +48,7 @@ public function __construct( ?ResourceClassResolverInterface $resourceClassResolver = null, ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + private readonly bool $enableHeadRequestOptimization = true, ) { $this->resourceClassResolver = $resourceClassResolver; $this->iriConverter = $iriConverter; @@ -68,6 +69,16 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return $this->processor?->process($data, $operation, $uriVariables, $context); } + if ($this->enableHeadRequestOptimization && $request->isMethod('HEAD')) { + $response = new Response( + null, + $this->getStatus($request, $operation, $context), + $this->getHeaders($request, $operation, $context) + ); + + return $this->processor ? $this->processor->process($response, $operation, $uriVariables, $context) : $response; + } + if ($operation instanceof CollectionOperationInterface) { $data = $this->jsonStreamer->write( $data, diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index bc17aae5005..ff54a251511 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -52,8 +52,6 @@ use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -111,26 +109,14 @@ public function testNormalize(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'alias', 'relatedDummy', 'relatedDummies'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(false)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); - $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); - } + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); @@ -177,21 +163,11 @@ public function testNormalizeNullableToManyRelationReturnsNull(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'relatedDummies'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, true, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); + $relatedDummiesType = Type::nullable(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int())); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(false)); - } else { - $relatedDummiesType = Type::nullable(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int())); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); - } + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); @@ -235,14 +211,8 @@ public function testNormalizeWithSecuredProperty(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/secured_dummies/1'); @@ -402,34 +372,15 @@ public function testNormalizePropertyAsIriWithUriTemplate(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'propertyCollectionIriOnlyRelation', Argument::type('array'))->willReturn( - (new ApiProperty())->withReadable(true)->withUriTemplate('/property-collection-relations')->withBuiltinTypes([ - new LegacyType('iterable', false, null, true, new LegacyType('int', false, null, false), new LegacyType('object', false, PropertyCollectionIriOnlyRelation::class, false)), - ]) - ); - $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'iterableIri', Argument::type('array'))->willReturn( - (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations')->withBuiltinTypes([ - new LegacyType('iterable', false, null, true, new LegacyType('int', false, null, false), new LegacyType('object', false, PropertyCollectionIriOnlyRelation::class, false)), - ]) - ); - $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'toOneRelation', Argument::type('array'))->willReturn( - (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations/{id}')->withBuiltinTypes([ - new LegacyType('object', false, PropertyCollectionIriOnlyRelation::class, false), - ]) - ); - } else { - $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'propertyCollectionIriOnlyRelation', Argument::type('array'))->willReturn( - (new ApiProperty())->withReadable(true)->withUriTemplate('/property-collection-relations')->withNativeType(Type::list(Type::object(PropertyCollectionIriOnlyRelation::class))) - ); - $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'iterableIri', Argument::type('array'))->willReturn( - (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations')->withNativeType(Type::iterable(Type::object(PropertyCollectionIriOnlyRelation::class))) - ); - $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'toOneRelation', Argument::type('array'))->willReturn( - (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations/{id}')->withNativeType(Type::object(PropertyCollectionIriOnlyRelation::class)) - ); - } + $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'propertyCollectionIriOnlyRelation', Argument::type('array'))->willReturn( + (new ApiProperty())->withReadable(true)->withUriTemplate('/property-collection-relations')->withNativeType(Type::list(Type::object(PropertyCollectionIriOnlyRelation::class))) + ); + $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'iterableIri', Argument::type('array'))->willReturn( + (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations')->withNativeType(Type::iterable(Type::object(PropertyCollectionIriOnlyRelation::class))) + ); + $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'toOneRelation', Argument::type('array'))->willReturn( + (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations/{id}')->withNativeType(Type::object(PropertyCollectionIriOnlyRelation::class)) + ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($propertyCollectionIriOnly, UrlGeneratorInterface::ABS_URL, null, Argument::any())->willReturn('/property-collection-relations', '/parent/42/another-collection-operations'); @@ -484,13 +435,8 @@ public function testDenormalizeWithSecuredPropertyAndThrowOnAccessDeniedExtraPro $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')->withExtraProperties(['throw_on_access_denied' => true])); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')->withExtraProperties(['throw_on_access_denied' => true])); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')->withExtraProperties(['throw_on_access_denied' => true])); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -537,13 +483,8 @@ public function testDenormalizeWithSecuredPropertyAndThrowOnAccessDeniedExtraPro $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -594,13 +535,8 @@ public function testDenormalizeWithSecuredPropertyAndThrowOnAccessDeniedExtraPro $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -652,14 +588,8 @@ public function testDenormalizeWithSecuredProperty(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -702,14 +632,8 @@ public function testDenormalizeCreateWithDeniedPostDenormalizeSecuredProperty(): $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withWritable(true)->withSecurityPostDenormalize('false')->withDefault('')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurityPostDenormalize('false')->withDefault('')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurityPostDenormalize('false')->withDefault('')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -755,14 +679,8 @@ public function testDenormalizeUpdateWithSecuredProperty(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withWritable(true)->withSecurity('true')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurity('true')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurity('true')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -815,14 +733,8 @@ public function testDenormalizeUpdateWithDeniedSecuredProperty(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withWritable(true)->withSecurity('false')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurity('false')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurity('false')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -875,14 +787,8 @@ public function testDenormalizeUpdateWithDeniedPostDenormalizeSecuredProperty(): $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withWritable(true)->withSecurityPostDenormalize('false')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurityPostDenormalize('false')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurityPostDenormalize('false')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -930,22 +836,12 @@ public function testNormalizeReadableLinks(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['relatedDummy', 'relatedDummies'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withReadable(true)->withWritable(false)->withReadableLink(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(true)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); - $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(true)->withWritable(false)->withReadableLink(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(true)); - } + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(true)->withWritable(false)->withReadableLink(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); @@ -998,20 +894,11 @@ public function testNormalizePolymorphicRelations(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(DummyTableInheritanceRelated::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['children'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $abstractDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, DummyTableInheritance::class); - $abstractDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $abstractDummyType); + $abstractDummyType = Type::object(DummyTableInheritance::class); + $abstractDummiesType = Type::collection(Type::object(ArrayCollection::class), $abstractDummyType, Type::int()); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(DummyTableInheritanceRelated::class, 'children', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$abstractDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(true)); - } else { - $abstractDummyType = Type::object(DummyTableInheritance::class); - $abstractDummiesType = Type::collection(Type::object(ArrayCollection::class), $abstractDummyType, Type::int()); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(DummyTableInheritanceRelated::class, 'children', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($abstractDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(true)); - } + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(DummyTableInheritanceRelated::class, 'children', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($abstractDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); @@ -1061,26 +948,14 @@ public function testDenormalize(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'relatedDummy', 'relatedDummies'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); - $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getResourceFromIri('/dummies/1', Argument::type('array'))->willReturn($relatedDummy1); @@ -1183,30 +1058,16 @@ public function testDenormalizeWritableLinks(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'relatedDummy', 'relatedDummies', 'relatedDummiesWithUnionTypes'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - $relatedDummiesWithUnionTypesIntType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - $relatedDummiesWithUnionTypesFloatType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), $relatedDummyType); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummiesWithUnionTypes', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesWithUnionTypesIntType, $relatedDummiesWithUnionTypesFloatType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); - $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - $relatedDummiesWithUnionTypesIntType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - $relatedDummiesWithUnionTypesFloatType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::float()); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummiesWithUnionTypes', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::union($relatedDummiesWithUnionTypesIntType, $relatedDummiesWithUnionTypesFloatType))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - } + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); + $relatedDummiesWithUnionTypesIntType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); + $relatedDummiesWithUnionTypesFloatType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::float()); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummiesWithUnionTypes', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::union($relatedDummiesWithUnionTypesIntType, $relatedDummiesWithUnionTypesFloatType))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1288,12 +1149,6 @@ public function testUnionTypeDenormalizationFallsThroughAfterTypeConfusionGuardM public function testUnionTypeCollectionDenormalizationAcceptsAnyMember(): void { - // The union-collection IRI guard relies on the native type; the legacy - // property-info path (< 7.1) only keeps the first collection value type. - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); - } - $data = ['attachments' => ['/related_dummies/1']]; $relatedDummy = new RelatedDummy(); @@ -1338,12 +1193,6 @@ public function testUnionTypeCollectionDenormalizationAcceptsAnyMember(): void public function testDenormalizeNullableCollectionOfBackedEnums(): void { - // Nullable collection value types (NullableType wrapping ObjectType/BackedEnumType) only exist - // in the native TypeInfo system. - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); - } - $data = ['notificationType' => ['email']]; $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); @@ -1389,11 +1238,6 @@ public function testDenormalizeNullableCollectionOfBackedEnums(): void public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreservesNormalizerException(): void { - // Nullable object types (NullableType wrapping ObjectType) only exist in the native TypeInfo system. - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); - } - // What Symfony's DateTimeNormalizer throws for a value it cannot parse. $normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true); @@ -1412,10 +1256,6 @@ public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreserves public function testDenormalizeWrongTypedValueForNonNullableObjectPropertyPreservesNormalizerException(): void { - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); - } - $normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true); $normalizer = $this->createNormalizerForObjectProperty('dummyDate', Type::object(\DateTimeImmutable::class), \DateTimeImmutable::class, $normalizerException); @@ -1544,18 +1384,10 @@ public function testDenormalizeRelationNotFoundReturnsNull(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['relatedDummy'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummyType = Type::object(RelatedDummy::class); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getResourceFromIri('/dummies/not_found', Argument::type('array'))->willThrow(new ItemNotFoundException()); @@ -1597,16 +1429,9 @@ public function testBadRelationType(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) - ); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) - ); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1638,16 +1463,9 @@ public function testBadRelationTypeWithExceptionToValidationErrors(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) - ); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) - ); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1681,16 +1499,9 @@ public function testDeserializationPathForNotDenormalizableRelations(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn( - (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class))])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true) - ); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn( - (new ApiProperty())->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class)))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true) - ); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class)))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true) + ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getResourceFromIri(Argument::cetera())->willThrow(new InvalidArgumentException('Invalid IRI')); @@ -1787,16 +1598,9 @@ public function testInnerDocumentNotAllowed(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) - ); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) - ); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1831,12 +1635,7 @@ public function testBadType(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1866,12 +1665,7 @@ public function testTypeChecksCanBeDisabled(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1905,12 +1699,7 @@ public function testJsonAllowIntAsFloat(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1960,27 +1749,11 @@ public function testDenormalizeBadKeyType(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)])->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - - $type = new LegacyType( - LegacyType::BUILTIN_TYPE_OBJECT, - false, - ArrayCollection::class, - true, - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class) - ); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$type])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - - $type = Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int()); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($type)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); + + $type = Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int()); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($type)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -2012,12 +1785,7 @@ public function testNullable(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING, true)])->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::string()))->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::string()))->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -2060,34 +1828,18 @@ public function testDenormalizeBasicTypePropertiesFromXml(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolTrue1', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_BOOL)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolFalse1', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_BOOL)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolTrue2', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_BOOL)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolFalse2', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_BOOL)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'int1', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'int2', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float1', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float2', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float3', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatNaN', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatInf', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatNegInf', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)); - } else { - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolTrue1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolFalse1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolTrue2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolFalse2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'int1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'int2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float3', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatNaN', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatInf', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatNegInf', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); - } + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolTrue1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolFalse1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolTrue2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolFalse2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'int1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'int2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float3', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatNaN', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatInf', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatNegInf', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -2151,20 +1903,11 @@ public function testDenormalizeCollectionDecodedFromXmlWithOneChild(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['relatedDummies'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); - $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - } + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -2201,12 +1944,7 @@ public function testDenormalizePopulatingNonCloneableObject(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(NonCloneableDummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - } else { - $propertyMetadataFactoryProphecy->create(NonCloneableDummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - } + $propertyMetadataFactoryProphecy->create(NonCloneableDummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); @@ -2241,12 +1979,7 @@ public function testDenormalizeObjectWithNullDisabledTypeEnforcement(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(DtoWithNullValue::class, 'dummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, nullable: true)])->withDescription('')->withReadable(true)->withWritable(true)); - } else { - $propertyMetadataFactoryProphecy->create(DtoWithNullValue::class, 'dummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::object()))->withDescription('')->withReadable(true)->withWritable(true)); - } + $propertyMetadataFactoryProphecy->create(DtoWithNullValue::class, 'dummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::object()))->withDescription('')->withReadable(true)->withWritable(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); @@ -2282,26 +2015,14 @@ public function testCacheKey(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'alias', 'relatedDummy', 'relatedDummies'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(false)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); - $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); - } + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); @@ -2399,15 +2120,9 @@ public function testDenormalizeReportsAllMissingConstructorArguments(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withReadable(true)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'rating', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)])->withReadable(true)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'comment', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withReadable(true)->withWritable(true)); - } else { - $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'rating', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'comment', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); - } + $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'rating', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'comment', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); @@ -2439,13 +2154,8 @@ public function testDenormalizeNullableConstructorArgWithoutDefault(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(DummyWithNullableConstructorArg::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withReadable(true)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(DummyWithNullableConstructorArg::class, 'description', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING, true)])->withReadable(true)->withWritable(true)); - } else { - $propertyMetadataFactoryProphecy->create(DummyWithNullableConstructorArg::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(DummyWithNullableConstructorArg::class, 'description', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::string()))->withReadable(true)->withWritable(true)); - } + $propertyMetadataFactoryProphecy->create(DummyWithNullableConstructorArg::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(DummyWithNullableConstructorArg::class, 'description', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::string()))->withReadable(true)->withWritable(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); diff --git a/src/Serializer/Tests/ItemDenormalizerTest.php b/src/Serializer/Tests/ItemDenormalizerTest.php new file mode 100644 index 00000000000..9fd567ca530 --- /dev/null +++ b/src/Serializer/Tests/ItemDenormalizerTest.php @@ -0,0 +1,271 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Serializer\ItemDenormalizer; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +class ItemDenormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportsDenormalization(): void + { + $dummy = new Dummy(); + $std = new \stdClass(); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(\stdClass::class)->willReturn(false); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($denormalizer->supportsNormalization($dummy)); + $this->assertTrue($denormalizer->supportsDenormalization($dummy, Dummy::class)); + $this->assertFalse($denormalizer->supportsDenormalization($std, \stdClass::class)); + } + + public function testDenormalize(): void + { + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['name' => 'hello'], Dummy::class, null, $context)); + } + + public function testDenormalizeWithIri(): void + { + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + + $propertyNameCollection = new PropertyNameCollection(['id', 'name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri('/dummies/12', ['resource_class' => Dummy::class, 'api_allow_update' => true, 'fetch_data' => true])->shouldBeCalled(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['id' => '/dummies/12', 'name' => 'hello'], Dummy::class, null, $context)); + } + + public function testDenormalizeWithIdAndUpdateNotAllowed(): void + { + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('Update is not allowed for this operation.'); + + $context = ['resource_class' => Dummy::class, 'api_allow_update' => false]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + $denormalizer->denormalize(['id' => '12', 'name' => 'hello'], Dummy::class, null, $context); + } + + public function testDenormalizeWithIdAndNoResourceClass(): void + { + $context = []; + + $propertyNameCollection = new PropertyNameCollection(['id', 'name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $object = $denormalizer->denormalize(['id' => '42', 'name' => 'hello'], Dummy::class, null, $context); + $this->assertInstanceOf(Dummy::class, $object); + $this->assertSame('42', $object->getId()); + $this->assertSame('hello', $object->getName()); + } + + public function testDenormalizeWithWrongIdAndNoResourceMetadataFactory(): void + { + $this->expectException(InvalidArgumentException::class); + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException()); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['name' => 'hello', 'id' => 'fail'], Dummy::class, null, $context)); + } + + public function testDenormalizeWithWrongId(): void + { + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + $operation = new Get(uriVariables: ['id' => new Link(identifiers: ['id'], parameterName: 'id')]); + $obj = new Dummy(); + + $propertyNameCollection = new PropertyNameCollection(['id', 'name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withIdentifier(true))->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException()); + $iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => ['id' => 'fail']])->willReturn('/dummies/fail'); + $iriConverterProphecy->getResourceFromIri('/dummies/fail', $context + ['fetch_data' => true])->willReturn($obj); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($obj, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + new ApiResource(operations: [$operation]), + ])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + $resourceMetadataCollectionFactory->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['name' => 'hello', 'id' => 'fail'], Dummy::class, null, $context)); + } +} diff --git a/src/Serializer/Tests/ItemNormalizerTest.php b/src/Serializer/Tests/ItemNormalizerTest.php index 3c2f06346a8..8ed4b0364b4 100644 --- a/src/Serializer/Tests/ItemNormalizerTest.php +++ b/src/Serializer/Tests/ItemNormalizerTest.php @@ -14,25 +14,19 @@ namespace ApiPlatform\Serializer\Tests; use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\Exception\InvalidArgumentException; -use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\IriConverterInterface; -use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; -use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; -use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -113,100 +107,7 @@ public function testNormalize(): void $this->assertEquals(['name' => 'hello'], $normalizer->normalize($dummy, null, ['resources' => []])); } - public function testDenormalize(): void - { - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; - - $propertyNameCollection = new PropertyNameCollection(['name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['name' => 'hello'], Dummy::class, null, $context)); - } - - public function testDenormalizeWithIri(): void - { - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; - - $propertyNameCollection = new PropertyNameCollection(['id', 'name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getResourceFromIri('/dummies/12', ['resource_class' => Dummy::class, 'api_allow_update' => true, 'fetch_data' => true])->shouldBeCalled(); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['id' => '/dummies/12', 'name' => 'hello'], Dummy::class, null, $context)); - } - - public function testDenormalizeWithIdAndUpdateNotAllowed(): void - { - $this->expectException(NotNormalizableValueException::class); - $this->expectExceptionMessage('Update is not allowed for this operation.'); - - $context = ['resource_class' => Dummy::class, 'api_allow_update' => false]; - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - $normalizer->denormalize(['id' => '12', 'name' => 'hello'], Dummy::class, null, $context); - } - - public function testDenormalizeWithDefinedIri(): void + public function testNormalizeWithDefinedIri(): void { $dummy = new Dummy(); $dummy->setName('hello'); @@ -245,116 +146,30 @@ public function testDenormalizeWithDefinedIri(): void $this->assertEquals(['name' => 'hello'], $normalizer->normalize($dummy, null, ['resources' => [], 'iri' => '/custom'])); } - public function testDenormalizeWithIdAndNoResourceClass(): void - { - $context = []; - - $propertyNameCollection = new PropertyNameCollection(['id', 'name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $object = $normalizer->denormalize(['id' => '42', 'name' => 'hello'], Dummy::class, null, $context); - $this->assertInstanceOf(Dummy::class, $object); - $this->assertSame('42', $object->getId()); - $this->assertSame('hello', $object->getName()); - } - - public function testDenormalizeWithWrongIdAndNoResourceMetadataFactory(): void + #[Group('legacy')] + #[IgnoreDeprecations] + public function testDenormalizeIsDeprecated(): void { - $this->expectException(InvalidArgumentException::class); - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + $this->expectUserDeprecationMessage('Since api-platform/core 4.4: Calling "denormalize()" on "ApiPlatform\Serializer\ItemNormalizer" is deprecated, use "ApiPlatform\Serializer\ItemDenormalizer" instead.'); + $this->expectException(NotNormalizableValueException::class); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException()); - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal() ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['name' => 'hello', 'id' => 'fail'], Dummy::class, null, $context)); - } - - public function testDenormalizeWithWrongId(): void - { - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; - $operation = new Get(uriVariables: ['id' => new Link(identifiers: ['id'], parameterName: 'id')]); - $obj = new Dummy(); - - $propertyNameCollection = new PropertyNameCollection(['id', 'name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withIdentifier(true))->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException()); - $iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => ['id' => 'fail']])->willReturn('/dummies/fail'); - $iriConverterProphecy->getResourceFromIri('/dummies/fail', $context + ['fetch_data' => true])->willReturn($obj); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->getResourceClass($obj, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ - new ApiResource(operations: [$operation]), - ])); - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), - null, + $normalizer->denormalize( + ['id' => '12', 'name' => 'hello'], + Dummy::class, null, - null, - null, - $resourceMetadataCollectionFactory->reveal() + ['resource_class' => Dummy::class, 'api_allow_update' => false] ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['name' => 'hello', 'id' => 'fail'], Dummy::class, null, $context)); } } diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json index b49b2c41c0f..c036295bf7f 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -23,27 +23,27 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "api-platform/state": "^4.3", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", - "symfony/validator": "^6.4.11 || ^7.0 || ^8.0" + "api-platform/metadata": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4.9 || ^8.0.9", + "symfony/validator": "^7.4 || ^8.0" }, "require-dev": { - "api-platform/doctrine-common": "^4.3", - "api-platform/doctrine-odm": "^4.3", - "api-platform/doctrine-orm": "^4.3", - "api-platform/json-schema": "^4.3", - "api-platform/openapi": "^4.3", + "api-platform/doctrine-common": "^5.0@alpha", + "api-platform/doctrine-odm": "^5.0@alpha", + "api-platform/doctrine-orm": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/openapi": "^5.0@alpha", "doctrine/collections": "^2.1", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", "sebastian/exporter": "^6.3.2 || ^7.0.2", "symfony/mercure-bundle": "^0.4.3|^0.5", - "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/var-dumper": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "suggest": { "api-platform/doctrine-orm": "To support Doctrine ORM state options.", @@ -69,13 +69,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/State/DenormalizationViolationFactoryInterface.php b/src/State/DenormalizationViolationFactoryInterface.php new file mode 100644 index 00000000000..bc414423fab --- /dev/null +++ b/src/State/DenormalizationViolationFactoryInterface.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State; + +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; + +/** + * Promotes Symfony serializer denormalization errors (raw type mismatches that would + * otherwise produce a 400) into HTTP-level validation violations (422) when the target + * {@see Operation} declares a matching validation contract. + * + * Each framework integration provides its own implementation: the Symfony bundle reads + * Symfony Validator metadata and throws {@see \ApiPlatform\Validator\Exception\ValidationException}; + * the Laravel package reads Illuminate validation rules and throws Laravel's native + * {@see \ApiPlatform\Laravel\ApiResource\ValidationError}. Implementations must NOT + * depend on a sibling framework's validation stack. + * + * Contract: throw an HTTP exception (typically 422) when at least one error has a + * matching validation contract; return void when nothing matches so the caller can + * rethrow the original denormalization exception for an honest 400. + * + * @author Antoine Bluchet + * + * @see https://github.com/api-platform/core/issues/7981 + */ +interface DenormalizationViolationFactoryInterface +{ + /** + * Builds and throws a validation violation from a denormalization error. + * + * Accepts either a single {@see NotNormalizableValueException} (raised when the + * serializer fails on the first type mismatch) or a {@see PartialDenormalizationException} + * (raised when `collect_denormalization_errors=true` collects every type mismatch in + * a batch). Implementations dispatch on the concrete type. + * + * @throws \Throwable when at least one error has a matching validation contract + */ + public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void; +} diff --git a/src/State/Parameter/ValueCaster.php b/src/State/Parameter/ValueCaster.php index 7b9366e8d07..8ba295eebf2 100644 --- a/src/State/Parameter/ValueCaster.php +++ b/src/State/Parameter/ValueCaster.php @@ -13,10 +13,12 @@ namespace ApiPlatform\State\Parameter; +use ApiPlatform\Metadata\Exception\BadRequestException; + /** - * Caster returns the default value when a value can not be casted - * This is used by parameters before they get validated by constraints - * Therefore we do not need to throw exceptions, validation will just fail. + * Caster returns the value unchanged when it can not be casted, so constraint validation can + * reject it. An empty string is the exception: it can not represent a scalar native type, so we + * throw a Bad Request (400) rather than letting it reach the filter as a raw, untyped value. * * @internal */ @@ -31,6 +33,7 @@ public static function toBool(mixed $v): mixed return match (strtolower($v)) { '1', 'true' => true, '0', 'false' => false, + '' => throw new BadRequestException('An empty value cannot be cast to a boolean.'), default => $v, }; } @@ -41,6 +44,10 @@ public static function toInt(mixed $v): mixed return $v; } + if ('' === $v) { + throw new BadRequestException('An empty value cannot be cast to an integer.'); + } + $value = filter_var($v, \FILTER_VALIDATE_INT); return false === $value ? $v : $value; @@ -52,6 +59,10 @@ public static function toFloat(mixed $v): mixed return $v; } + if ('' === $v) { + throw new BadRequestException('An empty value cannot be cast to a float.'); + } + $value = filter_var($v, \FILTER_VALIDATE_FLOAT); return false === $value ? $v : $value; diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index 3d28f5be729..e8147041d0a 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -23,8 +23,6 @@ use Psr\Log\LoggerInterface; /** - * @experimental - * * @author Vincent Amstoutz */ final readonly class IriConverterParameterProvider implements ParameterProviderInterface diff --git a/src/State/ParameterProvider/ReadLinkParameterProvider.php b/src/State/ParameterProvider/ReadLinkParameterProvider.php index 9b64676d5c0..4a43f6c3c20 100644 --- a/src/State/ParameterProvider/ReadLinkParameterProvider.php +++ b/src/State/ParameterProvider/ReadLinkParameterProvider.php @@ -26,8 +26,6 @@ /** * Checks if the linked resources have security attributes and prepares them for access checking. - * - * @experimental */ final class ReadLinkParameterProvider implements ParameterProviderInterface { @@ -105,11 +103,13 @@ public function provide(Parameter $parameter, array $parameters = [], array $con } /** - * @return array + * @return array */ private function getUriVariables(mixed $value, Parameter $parameter, Operation $operation): array { - $extraProperties = $parameter->getExtraProperties(); + if (\is_array($value)) { + return $value; + } if ($operation instanceof HttpOperation) { $links = $operation->getUriVariables(); @@ -119,24 +119,30 @@ private function getUriVariables(mixed $value, Parameter $parameter, Operation $ $links = []; } - if (!\is_array($value)) { - $uriVariables = []; + $extraProperties = $parameter->getExtraProperties(); + $linkClass = $parameter instanceof Link + ? ($parameter->getFromClass() ?? $parameter->getToClass()) + : null; + + $fallbackKey = null; + foreach ($links as $key => $link) { + if (!\is_string($key)) { + $key = $link->getParameterName() ?? $extraProperties['uri_variable'] ?? $link->getFromProperty(); + } - foreach ($links as $key => $link) { - if (!\is_string($key)) { - $key = $link->getParameterName() ?? $extraProperties['uri_variable'] ?? $link->getFromProperty(); - } + if (!$key || !\is_string($key)) { + continue; + } - if (!$key || !\is_string($key)) { - continue; - } + $linkFromClass = $link instanceof Link ? ($link->getFromClass() ?? $link->getToClass()) : null; - $uriVariables[$key] = $value; + if (null !== $linkClass && $linkFromClass === $linkClass) { + return [$key => $value]; } - return $uriVariables; + $fallbackKey ??= $key; } - return $value; + return null === $fallbackKey ? [] : [$fallbackKey => $value]; } } diff --git a/src/State/Processor/ObjectMapperProcessor.php b/src/State/Processor/ObjectMapperProcessor.php deleted file mode 100644 index f7bb34a367e..00000000000 --- a/src/State/Processor/ObjectMapperProcessor.php +++ /dev/null @@ -1,87 +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 ApiPlatform\State\Processor; - -use ApiPlatform\Metadata\Operation; -use ApiPlatform\State\ProcessorInterface; -use ApiPlatform\State\Util\StateOptionsTrait; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\ObjectMapper\ObjectMapperInterface; - -/** - * @deprecated since API Platform 4.3, use {@see ObjectMapperInputProcessor} and {@see ObjectMapperOutputProcessor} instead - * - * @implements ProcessorInterface - */ -final class ObjectMapperProcessor implements ProcessorInterface -{ - use StateOptionsTrait; - - /** - * @param ProcessorInterface $decorated - */ - public function __construct( - private readonly ?ObjectMapperInterface $objectMapper, - private readonly ProcessorInterface $decorated, - ) { - trigger_deprecation('api-platform/core', '4.3', 'The "%s" class is deprecated, use "%s" and "%s" instead.', self::class, ObjectMapperInputProcessor::class, ObjectMapperOutputProcessor::class); - } - - public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null - { - $class = $operation->getInput()['class'] ?? $operation->getClass(); - - if ( - $data instanceof Response - || !$this->objectMapper - || !$operation->canWrite() - || null === $data - || !is_a($data, $class, true) - || !$operation->canMap() - ) { - return $this->decorated->process($data, $operation, $uriVariables, $context); - } - - $request = $context['request'] ?? null; - - // maps the Resource to an Entity - if ($request?->attributes->get('mapped_data')) { - $mappedData = $this->objectMapper->map($data, $request->attributes->get('mapped_data')); - } else { - $mappedData = $this->objectMapper->map($data, $this->getStateOptionsClass($operation, $operation->getClass())); - } - $request?->attributes->set('mapped_data', $mappedData); - - $persisted = $this->decorated->process( - $mappedData, - $operation, - $uriVariables, - $context, - ); - - // in some cases (delete operation), the decoration may return a null object - if (null === $persisted) { - return $persisted; - } - - $request?->attributes->set('persisted_data', $persisted); - - // return the Resource representation of the persisted entity - return $this->objectMapper->map( - // persist the entity - $persisted, - $operation->getClass() - ); - } -} diff --git a/src/State/Processor/SerializeProcessor.php b/src/State/Processor/SerializeProcessor.php index 8047a384899..4e206fb3cc1 100644 --- a/src/State/Processor/SerializeProcessor.php +++ b/src/State/Processor/SerializeProcessor.php @@ -46,6 +46,7 @@ public function __construct( private readonly ?ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, + private readonly bool $enableHeadRequestOptimization = true, ) { } @@ -60,6 +61,12 @@ public function process(mixed $data, Operation $operation, array $uriVariables = // @see ApiPlatform\State\Processor\RespondProcessor $context['original_data'] = $data; + if ($this->enableHeadRequestOptimization && $request->isMethod('HEAD')) { + $this->stopwatch?->stop('api_platform.processor.serialize'); + + return $this->processor?->process(null, $operation, $uriVariables, $context); + } + $class = $operation->getClass(); $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ 'resource_class' => $class, diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php index e264cc19e0c..9ae282691ff 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -15,22 +15,17 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use ApiPlatform\State\StopwatchAwareInterface; use ApiPlatform\State\StopwatchAwareTrait; -use ApiPlatform\Validator\Exception\ValidationException; use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Validator\Constraints\Type; -use Symfony\Component\Validator\ConstraintViolation; -use Symfony\Component\Validator\ConstraintViolationList; -use Symfony\Contracts\Translation\LocaleAwareInterface; use Symfony\Contracts\Translation\TranslatorInterface; -use Symfony\Contracts\Translation\TranslatorTrait; final class DeserializeProvider implements ProviderInterface, StopwatchAwareInterface { @@ -40,13 +35,11 @@ public function __construct( private readonly ?ProviderInterface $decorated, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, - private ?TranslatorInterface $translator = null, + ?TranslatorInterface $translator = null, + private readonly ?DenormalizationViolationFactoryInterface $violationFactory = null, ) { - if (null === $this->translator) { - $this->translator = new class implements TranslatorInterface, LocaleAwareInterface { - use TranslatorTrait; - }; - $this->translator->setLocale('en'); + if (null !== $translator) { + trigger_deprecation('api-platform/core', '4.4', 'Passing a "%s" to "%s" is deprecated and will be removed in 5.0. Translation is now handled by "%s".', TranslatorInterface::class, self::class, DenormalizationViolationFactoryInterface::class); } } @@ -81,18 +74,6 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new UnsupportedMediaTypeHttpException('Format not supported.'); } - if (null === ($serializerContext[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE] ?? null)) { - $method = $operation->getMethod(); - $assignObjectToPopulate = 'POST' === $method - || 'PATCH' === $method - || ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true)); - - if ($assignObjectToPopulate) { - $serializerContext[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE] = true; - trigger_deprecation('api-platform/core', '5.0', 'To assign an object to populate you should set "%s" in your denormalizationContext, not defining it is deprecated.', SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE); - } - } - if (null !== $data && ($serializerContext[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE] ?? false)) { $serializerContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; } @@ -101,31 +82,10 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { $data = $this->serializer->deserialize((string) $request->getContent(), $serializerContext['deserializer_type'] ?? $operation->getClass(), $format, $serializerContext); - } catch (PartialDenormalizationException $e) { - if (!class_exists(ConstraintViolationList::class)) { - throw $e; - } - - $violations = new ConstraintViolationList(); - foreach ($e->getErrors() as $exception) { - if (!$exception instanceof NotNormalizableValueException) { - continue; - } - $violations->add($this->createViolationFromException($exception)); - } - if (0 !== \count($violations)) { - throw new ValidationException($violations); - } - } catch (NotNormalizableValueException $e) { - // BackedEnum denormalization errors should surface as validation violations (422) - // rather than denormalization errors (400). See https://github.com/api-platform/core/issues/8183. - if (!class_exists(ConstraintViolationList::class) || !$this->isBackedEnumException($e)) { - throw $e; - } - - $violations = new ConstraintViolationList(); - $violations->add($this->createViolationFromException($e)); - throw new ValidationException($violations); + } catch (PartialDenormalizationException|NotNormalizableValueException $e) { + $this->violationFactory?->handle($e, $operation); + + throw $e; } $this->stopwatch?->stop('api_platform.provider.deserialize'); @@ -134,68 +94,4 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $data; } - - private function normalizeExpectedTypes(?array $expectedTypes = null): array - { - $normalizedTypes = []; - - foreach ($expectedTypes ?? [] as $expectedType) { - $normalizedType = $expectedType; - - if (class_exists($expectedType) || interface_exists($expectedType)) { - // A backed enum is sent over the wire as its backing scalar (e.g. "string"), not as the - // PHP enum class, so report the JSON-visible type rather than the internal FQCN (#8388). - if (is_subclass_of($expectedType, \BackedEnum::class) && ($backingType = (new \ReflectionEnum($expectedType))->getBackingType())) { - $normalizedType = (string) $backingType; - } else { - $normalizedType = (new \ReflectionClass($expectedType))->getShortName(); - } - } - - $normalizedTypes[] = $normalizedType; - } - - return array_values(array_unique($normalizedTypes)); - } - - private function createViolationFromException(NotNormalizableValueException $exception): ConstraintViolation - { - $expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes()); - $parameters = []; - if ($exception->canUseMessageForUser()) { - $parameters['hint'] = $exception->getMessage(); - } - - if (!$expectedTypes && $exception->canUseMessageForUser()) { - $violationMessage = $exception->getMessage(); - - return new ConstraintViolation($violationMessage, $violationMessage, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR); - } - - $message = (new Type($expectedTypes))->message; - - return new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR); - } - - private function isBackedEnumException(NotNormalizableValueException $exception): bool - { - foreach ($exception->getExpectedTypes() ?? [] as $expectedType) { - if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType)) && is_subclass_of($expectedType, \BackedEnum::class)) { - return true; - } - } - - for ($previous = $exception->getPrevious(); $previous instanceof \Throwable; $previous = $previous->getPrevious()) { - if (!$previous instanceof NotNormalizableValueException) { - continue; - } - foreach ($previous->getExpectedTypes() ?? [] as $expectedType) { - if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType)) && is_subclass_of($expectedType, \BackedEnum::class)) { - return true; - } - } - } - - return false; - } } diff --git a/src/State/Provider/ReadProvider.php b/src/State/Provider/ReadProvider.php index c6c65a3888b..734229d4aea 100644 --- a/src/State/Provider/ReadProvider.php +++ b/src/State/Provider/ReadProvider.php @@ -88,14 +88,18 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $data = null; } - if ( - null === $data - && 'POST' !== $operation->getMethod() - && ('PUT' !== $operation->getMethod() - || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) - ) - ) { - throw new NotFoundHttpException('Not Found', $e ?? null); + if (null === $data) { + $throwOnNotFound = $operation->getThrowOnNotFound(); + if (null === $throwOnNotFound) { + $throwOnNotFound = 'POST' !== $operation->getMethod() + && ('PUT' !== $operation->getMethod() + || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) + ); + } + + if ($throwOnNotFound) { + throw new NotFoundHttpException('Not Found', $e ?? null); + } } $request?->attributes->set('data', $data); diff --git a/src/State/Provider/SecurityParameterProvider.php b/src/State/Provider/SecurityParameterProvider.php index 301c2d5cb36..9b84c76d423 100644 --- a/src/State/Provider/SecurityParameterProvider.php +++ b/src/State/Provider/SecurityParameterProvider.php @@ -30,8 +30,6 @@ * Loops over parameters to check parameter security. * Throws an exception if security is not granted. * - * @experimental - * * @implements ProviderInterface */ final class SecurityParameterProvider implements ProviderInterface diff --git a/src/State/SerializerAwareProviderInterface.php b/src/State/SerializerAwareProviderInterface.php deleted file mode 100644 index 6aada8eba41..00000000000 --- a/src/State/SerializerAwareProviderInterface.php +++ /dev/null @@ -1,28 +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 ApiPlatform\State; - -use Psr\Container\ContainerInterface; - -/** - * Injects serializer in providers. - * - * @author Vincent Chalamon - * - * @deprecated in 4.2, to be removed in 5.0 because it violates the dependency injection principle. - */ -interface SerializerAwareProviderInterface -{ - public function setSerializerLocator(ContainerInterface $serializerLocator): void; -} diff --git a/src/State/SerializerAwareProviderTrait.php b/src/State/SerializerAwareProviderTrait.php deleted file mode 100644 index bba3665f467..00000000000 --- a/src/State/SerializerAwareProviderTrait.php +++ /dev/null @@ -1,47 +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 ApiPlatform\State; - -use Psr\Container\ContainerInterface; -use Symfony\Component\Serializer\SerializerInterface; - -/** - * Injects serializer in providers. - * - * @author Vincent Chalamon - */ -trait SerializerAwareProviderTrait -{ - /** - * @internal - */ - private ContainerInterface $serializerLocator; - - public function setSerializerLocator(ContainerInterface $serializerLocator): void - { - trigger_deprecation( - 'api-platform/core', - '4.2', - 'The "%s" interface is deprecated and will be removed in 5.0. It violates the dependency injection principle.', - SerializerAwareProviderInterface::class - ); - - $this->serializerLocator = $serializerLocator; - } - - private function getSerializer(): SerializerInterface - { - return $this->serializerLocator->get('serializer'); - } -} diff --git a/src/State/Tests/Parameter/ValueCasterTest.php b/src/State/Tests/Parameter/ValueCasterTest.php new file mode 100644 index 00000000000..481cd30e7e5 --- /dev/null +++ b/src/State/Tests/Parameter/ValueCasterTest.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Tests\Parameter; + +use ApiPlatform\Metadata\Exception\BadRequestException; +use ApiPlatform\State\Parameter\ValueCaster; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +class ValueCasterTest extends TestCase +{ + #[DataProvider('boolProvider')] + public function testToBool(mixed $value, mixed $expected): void + { + $this->assertSame($expected, ValueCaster::toBool($value)); + } + + public static function boolProvider(): \Generator + { + yield 'true string' => ['true', true]; + yield 'numeric 1' => ['1', true]; + yield 'false string' => ['false', false]; + yield 'numeric 0' => ['0', false]; + // Unrecognized values (including "null") are returned untouched so constraint validation + // rejects them. + yield 'invalid string' => ['string', 'string']; + yield 'null string is not cast' => ['null', 'null']; + yield 'non-string passthrough' => [true, true]; + } + + #[DataProvider('intProvider')] + public function testToInt(mixed $value, mixed $expected): void + { + $this->assertSame($expected, ValueCaster::toInt($value)); + } + + public static function intProvider(): \Generator + { + yield 'integer string' => ['10', 10]; + yield 'invalid string' => ['string', 'string']; + yield 'null string is not cast' => ['null', 'null']; + yield 'int passthrough' => [10, 10]; + } + + #[DataProvider('floatProvider')] + public function testToFloat(mixed $value, mixed $expected): void + { + $this->assertSame($expected, ValueCaster::toFloat($value)); + } + + public static function floatProvider(): \Generator + { + yield 'float string' => ['1.5', 1.5]; + yield 'invalid string' => ['string', 'string']; + yield 'null string is not cast' => ['null', 'null']; + yield 'float passthrough' => [1.5, 1.5]; + } + + /** + * An empty string cannot represent a scalar native type, so the caster rejects it with a + * Bad Request rather than leaving a raw value for the filter. + */ + #[DataProvider('emptyCasterProvider')] + public function testEmptyValueThrowsBadRequest(callable $caster): void + { + $this->expectException(BadRequestException::class); + $caster(''); + } + + public static function emptyCasterProvider(): \Generator + { + yield 'toBool' => [ValueCaster::toBool(...)]; + yield 'toInt' => [ValueCaster::toInt(...)]; + yield 'toFloat' => [ValueCaster::toFloat(...)]; + } +} diff --git a/src/State/Tests/Processor/SerializeProcessorTest.php b/src/State/Tests/Processor/SerializeProcessorTest.php new file mode 100644 index 00000000000..7fbf6111507 --- /dev/null +++ b/src/State/Tests/Processor/SerializeProcessorTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Tests\Processor; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\Processor\SerializeProcessor; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\SerializerContextBuilderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Serializer\SerializerInterface; + +class SerializeProcessorTest extends TestCase +{ + public function testHeadRequestSkipsSerializationAndForwardsNull(): void + { + $request = Request::create('/foos', 'HEAD'); + + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->never())->method('serialize'); + + $inner = $this->createMock(ProcessorInterface::class); + $inner->expects($this->once()) + ->method('process') + ->with($this->isNull()) + ->willReturn(null); + + $processor = new SerializeProcessor($inner, $serializer, $this->createStub(SerializerContextBuilderInterface::class)); + $operation = (new Get())->withSerialize(true); + + $this->assertNull($processor->process(new \stdClass(), $operation, [], ['request' => $request])); + } + + public function testHeadRequestSerializesWhenOptimizationDisabled(): void + { + $request = Request::create('/foos', 'HEAD'); + + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->once())->method('serialize')->willReturn(''); + + $inner = $this->createMock(ProcessorInterface::class); + $inner->method('process')->willReturn('forwarded'); + + $contextBuilder = $this->createStub(SerializerContextBuilderInterface::class); + $contextBuilder->method('createFromRequest')->willReturn([]); + + $processor = new SerializeProcessor($inner, $serializer, $contextBuilder, false); + $operation = (new Get())->withSerialize(true); + + $this->assertSame('forwarded', $processor->process(new \stdClass(), $operation, [], ['request' => $request])); + } +} diff --git a/src/State/Tests/Provider/DeserializeProviderTest.php b/src/State/Tests/Provider/DeserializeProviderTest.php index 1fced0c05eb..6332e30f9ea 100644 --- a/src/State/Tests/Provider/DeserializeProviderTest.php +++ b/src/State/Tests/Provider/DeserializeProviderTest.php @@ -14,15 +14,11 @@ namespace ApiPlatform\State\Tests\Provider; use ApiPlatform\Metadata\Get; -use ApiPlatform\Metadata\HttpOperation; -use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; -use ApiPlatform\Metadata\Put; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; -use ApiPlatform\Validator\Exception\ValidationException; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; @@ -31,14 +27,11 @@ use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Validator\Constraints\Type; class DeserializeProviderTest extends TestCase { - #[IgnoreDeprecations] public function testDeserialize(): void { - $this->expectUserDeprecationMessage('Since api-platform/core 5.0: To assign an object to populate you should set "api_assign_object_to_populate" in your denormalizationContext, not defining it is deprecated.'); $objectToPopulate = new \stdClass(); $serializerContext = []; $operation = new Post(deserialize: true, class: \stdClass::class); @@ -48,7 +41,7 @@ public function testDeserialize(): void $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); $serializerContextBuilder->expects($this->once())->method('createFromRequest')->willReturn($serializerContext); $serializer = $this->createMock(SerializerInterface::class); - $serializer->expects($this->once())->method('deserialize')->with('test', \stdClass::class, 'format', ['uri_variables' => ['id' => 1], AbstractNormalizer::OBJECT_TO_POPULATE => $objectToPopulate] + $serializerContext)->willReturn(new \stdClass()); + $serializer->expects($this->once())->method('deserialize')->with('test', \stdClass::class, 'format', ['uri_variables' => ['id' => 1]] + $serializerContext)->willReturn(new \stdClass()); $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); $request = new Request(content: 'test'); @@ -140,14 +133,11 @@ public function testRequestWithEmptyContentType(): void $provider->provide($operation, [], $context); } - #[DataProvider('provideMethodsTriggeringDeprecation')] - #[IgnoreDeprecations] - public function testDeserializeTriggersDeprecationWhenContextNotSet(HttpOperation $operation): void + public function testDeserializeSetsObjectToPopulateWhenContextIsTrue(): void { - $this->expectUserDeprecationMessage('Since api-platform/core 5.0: To assign an object to populate you should set "api_assign_object_to_populate" in your denormalizationContext, not defining it is deprecated.'); - $objectToPopulate = new \stdClass(); - $serializerContext = []; + $serializerContext = [SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE => true]; + $operation = new Post(deserialize: true, class: \stdClass::class); $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn($objectToPopulate); @@ -159,7 +149,12 @@ public function testDeserializeTriggersDeprecationWhenContextNotSet(HttpOperatio 'test', \stdClass::class, 'format', - ['uri_variables' => ['id' => 1], 'object_to_populate' => $objectToPopulate] + $serializerContext + $this->callback(function (array $context) use ($objectToPopulate) { + $this->assertArrayHasKey(AbstractNormalizer::OBJECT_TO_POPULATE, $context); + $this->assertSame($objectToPopulate, $context[AbstractNormalizer::OBJECT_TO_POPULATE]); + + return true; + }) )->willReturn(new \stdClass()); $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); @@ -169,58 +164,42 @@ public function testDeserializeTriggersDeprecationWhenContextNotSet(HttpOperatio $provider->provide($operation, ['id' => 1], ['request' => $request]); } - public static function provideMethodsTriggeringDeprecation(): iterable - { - yield 'POST method' => [new Post(deserialize: true, class: \stdClass::class)]; - yield 'PATCH method' => [new Patch(deserialize: true, class: \stdClass::class)]; - yield 'PUT method (non-standard)' => [new Put(deserialize: true, class: \stdClass::class, extraProperties: ['standard_put' => false])]; - } - - public function testDeserializeSetsObjectToPopulateWhenContextIsTrue(): void + #[IgnoreDeprecations] + public function testDeserializeDelegatesSingleErrorToHandler(): void { - $objectToPopulate = new \stdClass(); - $serializerContext = [SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE => true]; $operation = new Post(deserialize: true, class: \stdClass::class); $decorated = $this->createStub(ProviderInterface::class); - $decorated->method('provide')->willReturn($objectToPopulate); + $decorated->method('provide')->willReturn(null); - $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); - $serializerContextBuilder->method('createFromRequest')->willReturn($serializerContext); + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'invalid', ['string'], 'status', true); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->method('createFromRequest')->willReturn([]); $serializer = $this->createMock(SerializerInterface::class); - $serializer->expects($this->once())->method('deserialize')->with( - 'test', - \stdClass::class, - 'format', - $this->callback(function (array $context) use ($objectToPopulate) { - $this->assertArrayHasKey(AbstractNormalizer::OBJECT_TO_POPULATE, $context); - $this->assertSame($objectToPopulate, $context[AbstractNormalizer::OBJECT_TO_POPULATE]); + $serializer->method('deserialize')->willThrowException($exception); - return true; - }) - )->willReturn(new \stdClass()); + $handler = $this->createMock(DenormalizationViolationFactoryInterface::class); + $handler->expects($this->once())->method('handle')->with($exception, $operation) + ->willThrowException(new \LogicException('handler-threw')); - $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); - $request = new Request(content: 'test'); - $request->headers->set('CONTENT_TYPE', 'ok'); - $request->attributes->set('input_format', 'format'); - $provider->provide($operation, ['id' => 1], ['request' => $request]); + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder, null, $handler); + $request = new Request(content: '{"status":"invalid"}'); + $request->headers->set('CONTENT_TYPE', 'application/json'); + $request->attributes->set('input_format', 'json'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('handler-threw'); + $provider->provide($operation, [], ['request' => $request]); } #[IgnoreDeprecations] - public function testDeserializeKeepsTypeMessageWhenExpectedTypesAreSet(): void + public function testDeserializeDelegatesPartialErrorToHandler(): void { $operation = new Post(deserialize: true, class: \stdClass::class); $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn(null); - $exception = NotNormalizableValueException::createForUnexpectedDataType( - 'The data must belong to a backed enumeration of type Suit.', - 'invalid', - ['string'], - 'status', - true, - ); + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'invalid', ['string'], 'status', true); $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); @@ -228,89 +207,51 @@ public function testDeserializeKeepsTypeMessageWhenExpectedTypesAreSet(): void $serializer = $this->createMock(SerializerInterface::class); $serializer->method('deserialize')->willThrowException($partialException); - $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); + $handler = $this->createMock(DenormalizationViolationFactoryInterface::class); + $handler->expects($this->once())->method('handle')->with($partialException, $operation) + ->willThrowException(new \LogicException('handler-threw-partial')); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder, null, $handler); $request = new Request(content: '{"status":"invalid"}'); $request->headers->set('CONTENT_TYPE', 'application/json'); $request->attributes->set('input_format', 'json'); - try { - $provider->provide($operation, [], ['request' => $request]); - $this->fail('Expected ValidationException'); - } catch (ValidationException $e) { - $violations = $e->getConstraintViolationList(); - $this->assertCount(1, $violations); - $this->assertSame('This value should be of type string.', $violations[0]->getMessage()); - $this->assertSame('status', $violations[0]->getPropertyPath()); - $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode()); - $this->assertSame('The data must belong to a backed enumeration of type Suit.', $violations[0]->getParameters()['hint'] ?? null); - } + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('handler-threw-partial'); + $provider->provide($operation, [], ['request' => $request]); } - /** - * Simulates Symfony 8.1 BackedEnumNormalizer behavior (symfony/serializer PR #62574): - * when a value has the right type but is not a valid enum case, the exception - * is created with expectedTypes=null and a user-friendly message listing valid values. - */ #[IgnoreDeprecations] - public function testDeserializeUsesExceptionMessageWhenExpectedTypesIsNull(): void + public function testDeserializeRethrowsSingleErrorWhenNoHandler(): void { $operation = new Post(deserialize: true, class: \stdClass::class); $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn(null); - $ctor = new \ReflectionMethod(NotNormalizableValueException::class, '__construct'); - if ($ctor->getNumberOfParameters() <= 3) { - $this->markTestSkipped('NotNormalizableValueException does not support extended constructor parameters.'); - } - - $exception = new NotNormalizableValueException( - "The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", - 0, - null, - null, - null, - 'suit', - true, - ); - $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'invalid', ['string'], 'status', true); $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); $serializerContextBuilder->method('createFromRequest')->willReturn([]); $serializer = $this->createMock(SerializerInterface::class); - $serializer->method('deserialize')->willThrowException($partialException); + $serializer->method('deserialize')->willThrowException($exception); $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); - $request = new Request(content: '{"suit":"invalid"}'); + $request = new Request(content: '{"status":"invalid"}'); $request->headers->set('CONTENT_TYPE', 'application/json'); $request->attributes->set('input_format', 'json'); - try { - $provider->provide($operation, [], ['request' => $request]); - $this->fail('Expected ValidationException'); - } catch (ValidationException $e) { - $violations = $e->getConstraintViolationList(); - $this->assertCount(1, $violations); - $this->assertSame("The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", $violations[0]->getMessage()); - $this->assertSame("The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", $violations[0]->getMessageTemplate()); - $this->assertSame('suit', $violations[0]->getPropertyPath()); - $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode()); - } + $this->expectException(NotNormalizableValueException::class); + $provider->provide($operation, [], ['request' => $request]); } #[IgnoreDeprecations] - public function testDeserializeUsesTypeMessageWhenCannotUseMessageForUser(): void + public function testDeserializeRethrowsPartialErrorWhenHandlerReturnsVoid(): void { $operation = new Post(deserialize: true, class: \stdClass::class); $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn(null); - $exception = NotNormalizableValueException::createForUnexpectedDataType( - 'Internal error detail', - 42, - ['string'], - 'name', - false, - ); + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'invalid', ['string'], 'status', true); $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); @@ -318,22 +259,16 @@ public function testDeserializeUsesTypeMessageWhenCannotUseMessageForUser(): voi $serializer = $this->createMock(SerializerInterface::class); $serializer->method('deserialize')->willThrowException($partialException); - $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); - $request = new Request(content: '{"name":42}'); + $handler = $this->createMock(DenormalizationViolationFactoryInterface::class); + $handler->expects($this->once())->method('handle'); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder, null, $handler); + $request = new Request(content: '{"status":"invalid"}'); $request->headers->set('CONTENT_TYPE', 'application/json'); $request->attributes->set('input_format', 'json'); - try { - $provider->provide($operation, [], ['request' => $request]); - $this->fail('Expected ValidationException'); - } catch (ValidationException $e) { - $violations = $e->getConstraintViolationList(); - $this->assertCount(1, $violations); - $this->assertStringContainsString('string', $violations[0]->getMessage()); - $this->assertSame('name', $violations[0]->getPropertyPath()); - $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode()); - $this->assertArrayNotHasKey('hint', $violations[0]->getParameters()); - } + $this->expectException(PartialDenormalizationException::class); + $provider->provide($operation, [], ['request' => $request]); } public function testDeserializeDoesNotSetObjectToPopulateWhenContextIsFalse(): void diff --git a/src/State/Tests/Provider/ReadProviderTest.php b/src/State/Tests/Provider/ReadProviderTest.php index 92cfce527da..3b5f6ee2092 100644 --- a/src/State/Tests/Provider/ReadProviderTest.php +++ b/src/State/Tests/Provider/ReadProviderTest.php @@ -15,11 +15,14 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ReadProviderTest extends TestCase { @@ -61,4 +64,71 @@ public function testWithoutRequest(): void $readProvider = new ReadProvider($provider, $serializerContextBuilder); $this->assertEquals($readProvider->provide($operation), ['ok']); } + + public function testThrowOnNotFoundExplicitTrueThrowsForPost(): void + { + $operation = new Post(read: true, throwOnNotFound: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $this->expectException(NotFoundHttpException::class); + $provider->provide($operation, ['id' => 1], ['request' => new Request()]); + } + + public function testThrowOnNotFoundExplicitFalseSkipsThrowForGet(): void + { + $operation = new Get(read: true, throwOnNotFound: false); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $request = new Request(); + $this->assertNull($provider->provide($operation, ['id' => 1], ['request' => $request])); + $this->assertNull($request->attributes->get('data')); + } + + public function testThrowOnNotFoundDefaultThrowsForGet(): void + { + $operation = new Get(read: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $this->expectException(NotFoundHttpException::class); + $provider->provide($operation, ['id' => 1], ['request' => new Request()]); + } + + public function testThrowOnNotFoundDefaultSkipsThrowForPost(): void + { + $operation = new Post(read: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $request = new Request(); + $this->assertNull($provider->provide($operation, [], ['request' => $request])); + } + + public function testThrowOnNotFoundDefaultThrowsForPutWithoutAllowCreate(): void + { + $operation = new Put(read: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $this->expectException(NotFoundHttpException::class); + $provider->provide($operation, ['id' => 1], ['request' => new Request()]); + } + + public function testThrowOnNotFoundDefaultSkipsThrowForPutWithAllowCreate(): void + { + $operation = new Put(read: true, allowCreate: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $request = new Request(); + $this->assertNull($provider->provide($operation, ['id' => 1], ['request' => $request])); + } } diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index 6b0190c50b6..a608706fa0d 100644 --- a/src/State/Util/HttpResponseHeadersTrait.php +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -155,7 +155,7 @@ private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $op } $acceptPost = null; - $allowedMethods = ['OPTIONS', 'HEAD']; + $allowedMethods = []; $resourceCollection = $this->resourceMetadataCollectionFactory->create($operation->getClass()); foreach ($resourceCollection as $resource) { foreach ($resource->getOperations() as $op) { @@ -172,6 +172,7 @@ private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $op $headers['Accept-Post'] = $acceptPost; } - $headers['Allow'] = implode(', ', $allowedMethods); + $head = \in_array('GET', $allowedMethods, true) ? ['HEAD'] : []; + $headers['Allow'] = implode(', ', array_merge(['OPTIONS'], $head, $allowedMethods)); } } diff --git a/src/State/Util/HttpResponseStatusTrait.php b/src/State/Util/HttpResponseStatusTrait.php index 89b9156c3ea..86745540386 100644 --- a/src/State/Util/HttpResponseStatusTrait.php +++ b/src/State/Util/HttpResponseStatusTrait.php @@ -37,6 +37,10 @@ trait HttpResponseStatusTrait */ private function getStatus(Request $request, HttpOperation $operation, array $context): int { + if ($request->attributes->has('_api_response_status')) { + return $request->attributes->getInt('_api_response_status'); + } + $status = $operation->getStatus(); $method = $request->getMethod(); diff --git a/src/State/Util/OperationRequestInitiatorTrait.php b/src/State/Util/OperationRequestInitiatorTrait.php index 4261ece85d3..11a8bd479fd 100644 --- a/src/State/Util/OperationRequestInitiatorTrait.php +++ b/src/State/Util/OperationRequestInitiatorTrait.php @@ -24,9 +24,6 @@ trait OperationRequestInitiatorTrait { private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null; - /** - * TODO: Kernel terminate remove the _api_operation attribute? - */ private function initializeOperation(Request $request): ?HttpOperation { if ($request->attributes->get('_api_operation')) { diff --git a/src/State/Util/StateOptionsTrait.php b/src/State/Util/StateOptionsTrait.php index 1b27c5534f7..5017cb8ace5 100644 --- a/src/State/Util/StateOptionsTrait.php +++ b/src/State/Util/StateOptionsTrait.php @@ -55,4 +55,20 @@ public function getStateOptionsClass(Operation $operation, ?string $defaultClass return $defaultClass; } + + public function getStateOptionsRepositoryMethod(Operation $operation): ?string + { + if (!$options = $operation->getStateOptions()) { + return null; + } + + if ( + (class_exists(Options::class) && $options instanceof Options) + || (class_exists(ODMOptions::class) && $options instanceof ODMOptions) + ) { + return $options->getRepositoryMethod(); + } + + return null; + } } diff --git a/src/State/composer.json b/src/State/composer.json index 10700330cad..39eb0b40cfb 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -28,24 +28,24 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", + "api-platform/metadata": "^5.0@alpha", "psr/container": "^1.0 || ^2.0", - "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", "symfony/translation-contracts": "^3.0", "symfony/deprecation-contracts": "^3.1" }, "require-dev": { - "api-platform/serializer": "^4.3.12", - "api-platform/validator": "^4.3.1", + "api-platform/serializer": "^5.0@alpha", + "api-platform/validator": "^5.0@alpha", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", + "symfony/http-foundation": "^7.4 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/type-info": "^7.4 || ^8.0", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, - "conflicts": { + "conflict": { "symfony/object-mapper": "<7.3.4" }, "autoload": { @@ -68,13 +68,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Symfony/Bundle/ApiPlatformBundle.php b/src/Symfony/Bundle/ApiPlatformBundle.php index f887630f0d5..a5353ec9e30 100644 --- a/src/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Symfony/Bundle/ApiPlatformBundle.php @@ -16,7 +16,6 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AttributeFilterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AttributeResourcePass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AuthenticatorManagerPass; -use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\DataProviderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ErrorResourceAttributeLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; @@ -48,8 +47,6 @@ public function build(ContainerBuilder $container): void { parent::build($container); - // TODO: remove in 5.x - $container->addCompilerPass(new DataProviderPass()); // Run the compiler pass before the {@see ResolveInstanceofConditionalsPass} to allow autoconfiguration of generated filter definitions. $container->addCompilerPass(new AttributeFilterPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 101); $container->addCompilerPass(new AttributeResourcePass()); diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterCollisionException.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterCollisionException.php new file mode 100644 index 00000000000..48749e3dbed --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterCollisionException.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Bundle\Command\Upgrade; + +/** + * Thrown when two legacy filters on a resource resolve to the same QueryParameter key + * (e.g. an exact and a range filter on one property), which cannot be expressed as two + * QueryParameters. Such resources are skipped by the command and handled separately. + * + * @internal + */ +final class UpgradeApiFilterCollisionException extends UpgradeApiFilterSkipException +{ + public function __construct(public readonly string $parameterKey) + { + parent::__construct(\sprintf('Cannot auto-migrate: two filters resolve to the same QueryParameter key "%s".', $parameterKey)); + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapper.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapper.php new file mode 100644 index 00000000000..87546f52514 --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapper.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Bundle\Command\Upgrade; + +/** + * Maps a legacy Doctrine filter to its canonical QueryParameter replacement. + * + * Filters that survive (Date/Range/Exists) and custom/third-party filters are returned + * unchanged — the codemod still wraps them in a `QueryParameter`, it just keeps the class. + * + * @internal + */ +final class UpgradeApiFilterMapper +{ + private const ORM_NAMESPACE = 'ApiPlatform\Doctrine\Orm\Filter'; + private const ODM_NAMESPACE = 'ApiPlatform\Doctrine\Odm\Filter'; + + public function map(string $filterClass, ?string $strategy = null, ?string $propertyNativeType = null, bool $isRelation = false): UpgradeApiFilterMapping + { + $namespace = $this->driverNamespace($filterClass); + + // Custom / third-party filter: keep as-is, just wrap it in a QueryParameter. + if (null === $namespace) { + return new UpgradeApiFilterMapping($filterClass); + } + + $shortName = substr($filterClass, \strlen($namespace) + 1); + $canonical = static fn (string $name): string => $namespace.'\\'.$name; + + return match ($shortName) { + 'BooleanFilter' => new UpgradeApiFilterMapping($canonical('ExactFilter'), castToNativeType: true, nativeType: 'bool'), + 'NumericFilter' => new UpgradeApiFilterMapping($canonical('ExactFilter'), castToNativeType: true, nativeType: $propertyNativeType ?? 'int'), + 'BackedEnumFilter' => new UpgradeApiFilterMapping($canonical('ExactFilter'), castToNativeType: true, nativeType: $propertyNativeType), + 'OrderFilter' => new UpgradeApiFilterMapping($canonical('SortFilter')), + 'SearchFilter' => $this->searchReplacement($canonical, $strategy, $isRelation), + default => new UpgradeApiFilterMapping($filterClass), + }; + } + + /** + * @param callable(string): string $canonical + */ + private function searchReplacement(callable $canonical, ?string $strategy, bool $isRelation): UpgradeApiFilterMapping + { + if ($isRelation) { + return new UpgradeApiFilterMapping($canonical('IriFilter')); + } + + // A leading "i" makes the legacy strategy case-insensitive; the new search filters are + // case-insensitive by default, so a case-sensitive (non-"i") strategy opts back in. + $caseInsensitive = null !== $strategy && str_starts_with($strategy, 'i'); + $base = $caseInsensitive ? substr($strategy, 1) : $strategy; + + $shortName = match ($base) { + 'exact' => 'ExactFilter', + 'start' => 'StartSearchFilter', + 'end' => 'EndSearchFilter', + 'word_start' => 'WordStartSearchFilter', + default => 'PartialSearchFilter', + }; + + // ExactFilter has no case-sensitivity option. + $caseSensitive = 'ExactFilter' !== $shortName && !$caseInsensitive; + + return new UpgradeApiFilterMapping($canonical($shortName), caseSensitive: $caseSensitive); + } + + private function driverNamespace(string $filterClass): ?string + { + return match (true) { + str_starts_with($filterClass, self::ORM_NAMESPACE.'\\') => self::ORM_NAMESPACE, + str_starts_with($filterClass, self::ODM_NAMESPACE.'\\') => self::ODM_NAMESPACE, + default => null, + }; + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapping.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapping.php new file mode 100644 index 00000000000..0a2c118902b --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapping.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Bundle\Command\Upgrade; + +/** + * Canonical replacement for a single legacy filter, resolved by {@see UpgradeApiFilterMapper}. + * + * @internal + */ +final readonly class UpgradeApiFilterMapping +{ + public function __construct( + public string $filterClass, + public bool $castToNativeType = false, + public ?string $nativeType = null, + public bool $caseSensitive = false, + ) { + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterNameConversionException.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterNameConversionException.php new file mode 100644 index 00000000000..a6bbc93dc03 --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterNameConversionException.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Bundle\Command\Upgrade; + +/** + * Thrown when a filtered property is renamed by a configured name converter. The new overlay filters + * do not denormalize the property (the parameter factory normalizes it), so such a resource cannot be + * auto-migrated faithfully and is reported and skipped. + * + * @internal + */ +final class UpgradeApiFilterNameConversionException extends UpgradeApiFilterSkipException +{ + public function __construct(public readonly string $property) + { + parent::__construct(\sprintf('Cannot auto-migrate: property "%s" is renamed by a name converter, which the target filters do not support. Migrate this resource manually.', $property)); + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterParameter.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterParameter.php new file mode 100644 index 00000000000..4128408b013 --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterParameter.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Bundle\Command\Upgrade; + +/** + * Resolved target of a single `#[ApiFilter]` declaration: the QueryParameter to emit. + * + * @internal + */ +final readonly class UpgradeApiFilterParameter +{ + /** + * @param string $key the parameter key (query string name) + * @param string $filterClass canonical replacement filter FQCN to instantiate + * @param string|null $property explicit property when it differs from $key + * @param string|null $nativeType scalar native type hint (bool|int|float|string), null to omit + * @param bool $castToNativeType whether the QueryParameter should coerce the raw value + * @param string|null $filterContext filter-specific config carried by the QueryParameter (e.g. the + * DateFilter null-management mode), null to omit + * @param bool $caseSensitive emit `caseSensitive: true` on the search filter (case-sensitive + * strategy); the new search filters are case-insensitive by default + * @param array $arguments constructor arguments to pass to the (kept) filter, named + */ + public function __construct( + public string $key, + public string $filterClass, + public ?string $property = null, + public ?string $nativeType = null, + public bool $castToNativeType = false, + public ?string $filterContext = null, + public bool $caseSensitive = false, + public array $arguments = [], + ) { + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterResolver.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterResolver.php new file mode 100644 index 00000000000..507d74a3815 --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterResolver.php @@ -0,0 +1,218 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Bundle\Command\Upgrade; + +use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; +use ApiPlatform\Metadata\Exception\PropertyNotFoundException; +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\TypeHelper; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Turns the legacy filters declared on a resource into the canonical {@see UpgradeApiFilterParameter} + * list that the visitor injects as QueryParameters. + * + * Properties and strategies are read from each filter's runtime `getDescription()` (the only place + * that knows what a class-level auto-detecting filter actually targets), then mapped to the canonical + * filter by {@see UpgradeApiFilterMapper}. A SearchFilter targeting an association cannot be told apart + * from one targeting a scalar field through the description alone, so the property's native type is + * resolved to decide whether it maps to an IriFilter. + * + * @internal + */ +final class UpgradeApiFilterResolver +{ + /** DateFilter null-management modes carried verbatim into the QueryParameter `filterContext`. */ + private const DATE_NULL_MANAGEMENT = [ + DateFilterInterface::EXCLUDE_NULL, + DateFilterInterface::INCLUDE_NULL_BEFORE, + DateFilterInterface::INCLUDE_NULL_AFTER, + DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER, + ]; + + public function __construct( + private readonly UpgradeApiFilterMapper $mapper, + private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, + private readonly ResourceClassResolverInterface $resourceClassResolver, + ) { + } + + /** + * @param list}> $filters + * one entry per `#[ApiFilter]` declaration (keyed by service id upstream so that two + * instances of the same filter class are kept distinct) + * @param list $reservedFilters in-place service filters (the resource `filters:` array) whose query keys must + * not be re-migrated: an #[ApiFilter] mapping onto one of these keys would shadow it + * + * @throws UpgradeApiFilterCollisionException when two filters map to the same parameter key, or an + * #[ApiFilter] key collides with an in-place service filter + * + * @return list + */ + public function resolve(string $resourceClass, array $filters, array $reservedFilters = []): array + { + $params = []; + $seenKeys = []; + + foreach ($reservedFilters as $reservedFilter) { + foreach (array_keys($this->group($reservedFilter->getDescription($resourceClass))) as $reservedKey) { + $seenKeys[$reservedKey] = true; + } + } + + foreach ($filters as ['filter' => $filter, 'filterClass' => $filterClass, 'arguments' => $arguments]) { + $description = $filter->getDescription($resourceClass); + // The new overlay filters do not denormalize property names, so a resource whose filtered + // properties are renamed by a name converter cannot be migrated faithfully — skip it. + $this->assertNoNameConversion($filter, $description); + + // The mode of a DateFilter (include/exclude null) lives in the constructor `properties` map + // as the value, never in getDescription(); read it straight from the filter instance. + $rawProperties = \is_callable([$filter, 'getProperties']) ? ($filter->getProperties() ?? []) : []; + + foreach ($this->group($description) as $key => $info) { + if (isset($seenKeys[$key])) { + throw new UpgradeApiFilterCollisionException($key); + } + $seenKeys[$key] = true; + + $isRelation = null !== $info['property'] && $this->isRelation($resourceClass, $info['property']); + $mapping = $this->mapper->map($filterClass, $info['strategy'], $info['type'], $isRelation); + // The new filter system infers the property from a plain key, but cannot for a nested + // (dotted) key, so it must be stated explicitly even when it equals the key. + $property = $this->explicitProperty($info['property'], $key); + + $mode = null !== $info['property'] ? ($rawProperties[$info['property']] ?? null) : null; + $filterContext = \is_string($mode) && \in_array($mode, self::DATE_NULL_MANAGEMENT, true) ? $mode : null; + + // Constructor arguments only carry over when the filter is kept as-is (custom or a + // surviving filter); a remapped filter has a different constructor. + $filterArguments = $mapping->filterClass === $filterClass ? $arguments : []; + + $params[] = new UpgradeApiFilterParameter( + key: $key, + filterClass: $mapping->filterClass, + property: $property, + nativeType: $mapping->nativeType, + castToNativeType: $mapping->castToNativeType, + filterContext: $filterContext, + caseSensitive: $mapping->caseSensitive, + arguments: $filterArguments, + ); + } + } + + return $params; + } + + /** + * Collapses a filter description into logical parameters: operator/array bracket variants + * (`quantity[gt]`, `quantity[]`) fold into their base property, and the `order[...]` family + * folds into a single `order[:property]` template. + * + * @param array> $description + * + * @return array + */ + private function group(array $description): array + { + $grouped = []; + + foreach ($description as $descKey => $meta) { + if (str_starts_with($descKey, 'order[')) { + $grouped['order[:property]'] = ['property' => null, 'strategy' => null, 'type' => null]; + continue; + } + + // ExistsFilter uses the `exists[property]` query syntax; collapse it to the catch-all template + // (the bracketed property, name-converted or not, is resolved by the filter at query time). + if (str_starts_with($descKey, 'exists[')) { + $grouped['exists[:property]'] = ['property' => null, 'strategy' => null, 'type' => null]; + continue; + } + + $key = false === ($pos = strpos($descKey, '[')) ? $descKey : substr($descKey, 0, $pos); + + $grouped[$key] ??= [ + 'property' => $meta['property'] ?? $key, + 'strategy' => $meta['strategy'] ?? null, + 'type' => $meta['type'] ?? null, + ]; + } + + return $grouped; + } + + /** + * The new overlay filters read the property as-is (no name-converter denormalization the legacy + * filters did), while the parameter factory normalizes it — so a filtered property renamed by a + * configured name converter would target the wrong field. Detect it and skip the resource. + * + * @param array> $description + * + * @throws UpgradeApiFilterNameConversionException + */ + private function assertNoNameConversion(FilterInterface $filter, array $description): void + { + $nameConverter = \is_callable([$filter, 'getNameConverter']) ? $filter->getNameConverter() : null; + if (!$nameConverter instanceof NameConverterInterface) { + return; + } + + foreach ($description as $meta) { + $property = $meta['property'] ?? null; + if (null === $property) { + continue; + } + + $real = implode('.', array_map($nameConverter->denormalize(...), explode('.', (string) $property))); + if ($real !== $property) { + throw new UpgradeApiFilterNameConversionException($property); + } + } + } + + private function explicitProperty(?string $property, string $key): ?string + { + if (null === $property) { + return null; + } + + return ($property !== $key || str_contains($property, '.')) ? $property : null; + } + + /** + * A SearchFilter property is a relation when its native type resolves to an API resource class (an + * object, or a collection of objects). Gating on the resource resolver keeps value objects such as + * \DateTime — which also resolve to a class — out of the IriFilter mapping. + */ + private function isRelation(string $resourceClass, string $property): bool + { + try { + $type = $this->propertyMetadataFactory->create($resourceClass, $property)->getNativeType(); + } catch (PropertyNotFoundException) { + return false; + } + + if (null === $type) { + return false; + } + + $className = TypeHelper::getClassName(TypeHelper::getCollectionValueType($type) ?? $type); + + return null !== $className && $this->resourceClassResolver->isResourceClass($className); + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterSkipException.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterSkipException.php new file mode 100644 index 00000000000..a4ea6cff64b --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterSkipException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Bundle\Command\Upgrade; + +/** + * Base class for the reasons a resource cannot be auto-migrated and is reported and skipped by the command. + * + * @internal + */ +abstract class UpgradeApiFilterSkipException extends \RuntimeException +{ +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterVisitor.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterVisitor.php new file mode 100644 index 00000000000..8f3e723cce5 --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterVisitor.php @@ -0,0 +1,268 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Bundle\Command\Upgrade; + +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\QueryParameter; +use PhpParser\BuilderHelpers; +use PhpParser\Node; +use PhpParser\NodeVisitorAbstract; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Rewrites legacy `#[ApiFilter]` declarations on a resource class to `QueryParameter` + * entries on the `#[ApiResource]` attribute, dropping the now-unused imports. + * + * @internal + */ +final class UpgradeApiFilterVisitor extends NodeVisitorAbstract +{ + /** DateFilter null-management mode value => the constant name to reference on the filter class. */ + private const FILTER_CONTEXT_CONSTANTS = [ + 'exclude_null' => 'EXCLUDE_NULL', + 'include_null_before' => 'INCLUDE_NULL_BEFORE', + 'include_null_after' => 'INCLUDE_NULL_AFTER', + 'include_null_before_and_after' => 'INCLUDE_NULL_BEFORE_AND_AFTER', + ]; + + /** @var list short names of filter classes referenced by removed `#[ApiFilter]` attributes */ + private array $removedFilterShortNames = []; + + /** + * @param string $className FQCN of the resource class to transform + * @param list $parameters resolved QueryParameter targets to inject + */ + public function __construct( + private readonly string $className, + private readonly array $parameters, + ) { + } + + public function enterNode(Node $node): ?Node + { + if ($node instanceof Node\Stmt\Class_ && $this->isTargetClass($node)) { + $this->removeApiFilterAttributes($node); + $this->injectParameters($node); + } + + return null; + } + + public function leaveNode(Node $node): ?Node + { + if ($node instanceof Node\Stmt\Namespace_) { + $this->rewriteUses($node); + } + + return null; + } + + private function isTargetClass(Node\Stmt\Class_ $node): bool + { + return null !== $node->name && $node->name->toString() === $this->shortName($this->className); + } + + private function removeApiFilterAttributes(Node\Stmt\Class_ $node): void + { + $this->stripApiFilter($node); + + foreach ($node->getProperties() as $property) { + $this->stripApiFilter($property); + } + + $constructor = $node->getMethod('__construct'); + if (null !== $constructor) { + foreach ($constructor->params as $param) { + $this->stripApiFilter($param); + } + } + } + + private function stripApiFilter(Node\Stmt\Class_|Node\Stmt\Property|Node\Param $node): void + { + foreach ($node->attrGroups as $gi => $group) { + foreach ($group->attrs as $ai => $attr) { + if ('ApiFilter' !== $attr->name->getLast()) { + continue; + } + + $firstArg = $attr->args[0] ?? null; + if ($firstArg?->value instanceof Node\Expr\ClassConstFetch && $firstArg->value->class instanceof Node\Name) { + $this->removedFilterShortNames[] = $firstArg->value->class->getLast(); + } + + unset($group->attrs[$ai]); + } + + $group->attrs = array_values($group->attrs); + if (!$group->attrs) { + unset($node->attrGroups[$gi]); + } + } + + $node->attrGroups = array_values($node->attrGroups); + } + + private function injectParameters(Node\Stmt\Class_ $node): void + { + if (!$this->parameters) { + return; + } + + $items = []; + foreach ($this->parameters as $parameter) { + $items[] = new Node\ArrayItem($this->buildQueryParameter($parameter), new Node\Scalar\String_($parameter->key)); + } + + $parametersArg = new Node\Arg( + new Node\Expr\Array_($items, ['kind' => Node\Expr\Array_::KIND_SHORT]), + name: new Node\Identifier('parameters'), + ); + + foreach ($node->attrGroups as $group) { + foreach ($group->attrs as $attr) { + if ('ApiResource' === $attr->name->getLast()) { + $attr->args[] = $parametersArg; + + return; + } + } + } + } + + private function buildQueryParameter(UpgradeApiFilterParameter $parameter): Node\Expr\New_ + { + $filterArgs = []; + if ($parameter->caseSensitive) { + $filterArgs[] = new Node\Arg(new Node\Expr\ConstFetch(new Node\Name('true')), name: new Node\Identifier('caseSensitive')); + } + foreach ($parameter->arguments as $name => $value) { + $filterArgs[] = new Node\Arg($this->buildValue($value), name: new Node\Identifier($name)); + } + + $args = [ + new Node\Arg( + new Node\Expr\New_(new Node\Name($this->shortName($parameter->filterClass)), $filterArgs), + name: new Node\Identifier('filter'), + ), + ]; + + if (null !== $parameter->property) { + $args[] = new Node\Arg(new Node\Scalar\String_($parameter->property), name: new Node\Identifier('property')); + } + + if (null !== $parameter->nativeType) { + $args[] = new Node\Arg($this->buildNativeType($parameter->nativeType), name: new Node\Identifier('nativeType')); + } + + if ($parameter->castToNativeType) { + $args[] = new Node\Arg(new Node\Expr\ConstFetch(new Node\Name('true')), name: new Node\Identifier('castToNativeType')); + } + + if (null !== $parameter->filterContext) { + $args[] = new Node\Arg($this->buildFilterContext($parameter), name: new Node\Identifier('filterContext')); + } + + return new Node\Expr\New_(new Node\Name('QueryParameter'), $args); + } + + /** + * Re-expresses a DateFilter null-management mode as the `DateFilter::INCLUDE_NULL_*` constant it + * came from (the filter class is already imported), falling back to a string literal otherwise. + */ + private function buildFilterContext(UpgradeApiFilterParameter $parameter): Node\Expr + { + $constant = self::FILTER_CONTEXT_CONSTANTS[$parameter->filterContext] ?? null; + if (null === $constant) { + return new Node\Scalar\String_($parameter->filterContext); + } + + return new Node\Expr\ClassConstFetch(new Node\Name($this->shortName($parameter->filterClass)), new Node\Identifier($constant)); + } + + private function buildValue(mixed $value): Node\Expr + { + return BuilderHelpers::normalizeValue($value); + } + + private function buildNativeType(string $nativeType): Node\Expr\New_ + { + $case = match ($nativeType) { + 'bool' => 'BOOL', + 'int' => 'INT', + 'float' => 'FLOAT', + default => 'STRING', + }; + + return new Node\Expr\New_(new Node\Name('BuiltinType'), [ + new Node\Arg(new Node\Expr\ClassConstFetch(new Node\Name('TypeIdentifier'), new Node\Identifier($case))), + ]); + } + + private function rewriteUses(Node\Stmt\Namespace_ $node): void + { + // Filters reused as the canonical target (survivors, custom service filters) keep their import. + $keepShortNames = array_map(fn (UpgradeApiFilterParameter $p): string => $this->shortName($p->filterClass), $this->parameters); + $removeShortNames = array_diff(array_merge(['ApiFilter'], $this->removedFilterShortNames), $keepShortNames); + $existing = []; + + foreach ($node->stmts as $k => $stmt) { + if (!$stmt instanceof Node\Stmt\Use_) { + continue; + } + + foreach ($stmt->uses as $use) { + if (\in_array($use->name->getLast(), $removeShortNames, true)) { + unset($node->stmts[$k]); + continue 2; + } + + $existing[$use->name->toString()] = true; + } + } + + $node->stmts = array_values($node->stmts); + + $imports = []; + foreach ($this->parameters as $parameter) { + $imports[$parameter->filterClass] = true; + $imports[QueryParameter::class] = true; + if (null !== $parameter->nativeType) { + $imports[BuiltinType::class] = true; + $imports[TypeIdentifier::class] = true; + } + } + + $toAdd = []; + foreach (array_keys($imports) as $fqcn) { + if (!isset($existing[$fqcn])) { + $toAdd[] = $fqcn; + } + } + + sort($toAdd); + foreach (array_reverse($toAdd) as $fqcn) { + array_unshift($node->stmts, new Node\Stmt\Use_([new Node\UseItem(new Node\Name($fqcn))])); + } + } + + private function shortName(string $fqcn): string + { + $parts = explode('\\', $fqcn); + + return end($parts); + } +} diff --git a/src/Symfony/Bundle/Command/UpgradeApiFilterCommand.php b/src/Symfony/Bundle/Command/UpgradeApiFilterCommand.php new file mode 100644 index 00000000000..3ee1a6a8f33 --- /dev/null +++ b/src/Symfony/Bundle/Command/UpgradeApiFilterCommand.php @@ -0,0 +1,226 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Bundle\Command; + +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Util\AttributeFilterExtractorTrait; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterResolver; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterSkipException; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterVisitor; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor\CloningVisitor; +use PhpParser\ParserFactory; +use PhpParser\PrettyPrinter\Standard; +use Psr\Container\ContainerInterface; +use SebastianBergmann\Diff\Differ; +use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Process\Process; + +/** + * Rewrites legacy `#[ApiFilter]` declarations to `QueryParameter` entries on the resource. + * + * Only `#[ApiFilter]`-generated filters (service ids prefixed `annotated_`) are migrated. + * Resources whose filters cannot be expressed as distinct QueryParameters (e.g. an exact and a + * range filter on the same property) are reported and skipped. + * + * This command is a one-shot upgrade helper for the 4.4 → 5.0 filter migration and will be removed in 6.0. + */ +#[AsCommand(name: 'api:upgrade-filter', description: 'Upgrades legacy #[ApiFilter] declarations to QueryParameter')] +final class UpgradeApiFilterCommand extends Command +{ + use AttributeFilterExtractorTrait; + + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, + private readonly ContainerInterface $filterLocator, + private readonly UpgradeApiFilterResolver $resolver, + private readonly ?string $csFixerBinary = null, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('class', InputArgument::OPTIONAL, 'Restrict the upgrade to a single resource class') + ->addOption('dry-run', 'd', InputOption::VALUE_NEGATABLE, 'Output a diff instead of writing files', true) + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Write the files in place'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = !$input->getOption('force') && false !== $input->getOption('dry-run'); + + $classes = ($class = $input->getArgument('class')) ? [$class] : iterator_to_array($this->resourceNameCollectionFactory->create()); + $skipped = []; + $changed = 0; + + foreach ($classes as $resourceClass) { + // Legacy/ fixtures intentionally keep #[ApiFilter] as the regression suite. + if (str_contains($resourceClass, '\\Legacy\\')) { + continue; + } + + try { + $reflection = new \ReflectionClass($resourceClass); + } catch (\ReflectionException) { + continue; + } + + $filters = $this->annotatedFilters($reflection); + if (!$filters) { + continue; + } + + try { + $parameters = $this->resolver->resolve($resourceClass, $filters, $this->reservedFilters($resourceClass)); + } catch (UpgradeApiFilterSkipException $e) { + $skipped[$resourceClass] = $e->getMessage(); + continue; + } + + if (!$parameters || !($file = $reflection->getFileName())) { + continue; + } + + $original = file_get_contents($file); + $updated = $this->transform($original, $resourceClass, $parameters); + + if ($updated === $original) { + continue; + } + + ++$changed; + + if ($dryRun) { + $io->section($resourceClass); + $output->write($this->diff($original, $updated)); + continue; + } + + file_put_contents($file, $updated); + $this->fix($file); + $io->writeln(\sprintf('upgraded %s', $resourceClass)); + } + + foreach ($skipped as $class => $reason) { + $io->warning(\sprintf('Skipped %s: %s', $class, $reason)); + } + + $io->success(\sprintf('%s resource(s) %s.', $changed, $dryRun ? 'would be upgraded (dry-run)' : 'upgraded')); + + return Command::SUCCESS; + } + + /** + * Reads every `#[ApiFilter]` declaration on the resource (keyed by its generated service id so two + * instances of the same filter class stay distinct), pairing each with the configured filter instance + * and its constructor arguments. The `properties` field map is dropped: properties are resolved through + * the runtime description, not re-emitted as a filter constructor argument. + * + * @return list}> + */ + private function annotatedFilters(\ReflectionClass $reflectionClass): array + { + $filters = []; + + foreach ($this->readFilterAttributes($reflectionClass) as $id => [$arguments, $filterClass]) { + if (!$this->filterLocator->has($id)) { + continue; + } + + $filter = $this->filterLocator->get($id); + if (!$filter instanceof FilterInterface) { + continue; + } + + unset($arguments['properties']); + + $filters[] = ['filter' => $filter, 'filterClass' => $filterClass, 'arguments' => $arguments]; + } + + return $filters; + } + + /** + * In-place service filters declared on the resource through the `filters:` array (i.e. not generated + * by `#[ApiFilter]`). Their query keys are reserved: migrating an #[ApiFilter] onto one of them would + * silently shadow the service filter, so such a resource is skipped instead. + * + * @return list + */ + private function reservedFilters(string $resourceClass): array + { + $filters = []; + $seenIds = []; + + foreach ($this->resourceMetadataFactory->create($resourceClass) as $resource) { + foreach ($resource->getOperations() ?? [] as $operation) { + foreach ($operation->getFilters() ?? [] as $filterId) { + if (str_starts_with($filterId, 'annotated_') || isset($seenIds[$filterId]) || !$this->filterLocator->has($filterId)) { + continue; + } + + $seenIds[$filterId] = true; + $filter = $this->filterLocator->get($filterId); + if ($filter instanceof FilterInterface) { + $filters[] = $filter; + } + } + } + } + + return $filters; + } + + /** + * @param list $parameters + */ + private function transform(string $code, string $resourceClass, array $parameters): string + { + $parser = (new ParserFactory())->createForHostVersion(); + $oldStmts = $parser->parse($code); + $oldTokens = $parser->getTokens(); + + $newStmts = (new NodeTraverser(new CloningVisitor()))->traverse($oldStmts); + $newStmts = (new NodeTraverser(new UpgradeApiFilterVisitor($resourceClass, $parameters)))->traverse($newStmts); + + return (new Standard())->printFormatPreserving($newStmts, $oldStmts, $oldTokens); + } + + private function diff(string $from, string $to): string + { + return (new Differ(new UnifiedDiffOutputBuilder("--- original\n+++ upgraded\n")))->diff($from, $to); + } + + private function fix(string $file): void + { + if (!$this->csFixerBinary || !is_file($this->csFixerBinary)) { + return; + } + + (new Process([\PHP_BINARY, $this->csFixerBinary, 'fix', $file, '--quiet']))->run(); + } +} diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 05e66fe8b47..ff3757f933f 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -325,6 +325,10 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $loader->load('api.php'); $loader->load('filter.php'); + if (class_exists(\PhpParser\ParserFactory::class)) { + $loader->load('upgrade.php'); + } + if (class_exists(UuidDenormalizer::class) && class_exists(Uuid::class)) { $loader->load('ramsey_uuid.php'); } @@ -359,6 +363,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.enable_entrypoint', $config['enable_entrypoint']); $container->setParameter('api_platform.enable_docs', $config['enable_docs']); + $container->setParameter('api_platform.enable_head_request_optimization', $config['enable_head_request_optimization']); $container->setParameter('api_platform.title', $config['title']); $container->setParameter('api_platform.description', $config['description']); $container->setParameter('api_platform.version', $config['version']); @@ -404,7 +409,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.http_cache.stale_while_revalidate', $config['defaults']['cache_headers']['stale_while_revalidate'] ?? null); $container->setParameter('api_platform.http_cache.stale_if_error', $config['defaults']['cache_headers']['stale_if_error'] ?? null); $container->setParameter('api_platform.http_cache.invalidation.max_header_length', $config['defaults']['cache_headers']['invalidation']['max_header_length'] ?? $config['http_cache']['invalidation']['max_header_length']); - $container->setParameter('api_platform.http_cache.invalidation.xkey.glue', $config['defaults']['cache_headers']['invalidation']['xkey']['glue'] ?? $config['http_cache']['invalidation']['xkey']['glue']); + $container->setParameter('api_platform.http_cache.invalidation.xkey.glue', $config['defaults']['cache_headers']['invalidation']['xkey']['glue'] ?? ' '); $container->setAlias('api_platform.path_segment_name_generator', $config['path_segment_name_generator']); $container->setAlias('api_platform.inflector', $config['inflector']); @@ -475,13 +480,6 @@ private function registerMetadataConfiguration(ContainerBuilder $container, arra $loader->load('metadata/resource_name.php'); $loader->load('metadata/property_name.php'); - if (!empty($config['resource_class_directories'])) { - $container->setParameter('api_platform.resource_class_directories', array_merge( - $config['resource_class_directories'], - $container->getParameter('api_platform.resource_class_directories') - )); - } - // V3 metadata $loader->load('metadata/php.php'); $loader->load('metadata/xml.php'); @@ -695,6 +693,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.enable_scalar', $config['enable_scalar']); $container->setParameter('api_platform.swagger.api_keys', $config['swagger']['api_keys']); $container->setParameter('api_platform.swagger.persist_authorization', $config['swagger']['persist_authorization']); + $container->setParameter('api_platform.swagger.with_credentials', $config['swagger']['with_credentials']); $container->setParameter('api_platform.swagger.http_auth', $config['swagger']['http_auth']); if ($config['openapi']['swagger_ui_extra_configuration'] && $config['swagger']['swagger_ui_extra_configuration']) { throw new RuntimeException('You can not set "swagger_ui_extra_configuration" twice - in "openapi" and "swagger" section.'); @@ -716,10 +715,20 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array $loader->load('jsonapi.php'); $loader->load('state/jsonapi.php'); + $useIriAsId = $config['jsonapi']['use_iri_as_id']; + if (null === $useIriAsId) { + trigger_deprecation('api-platform/core', '4.4', 'Not setting "api_platform.jsonapi.use_iri_as_id" explicitly is deprecated. Its default value will change from "true" to "false" in API Platform 5.0. Set it to "true" to keep the current behavior or to "false" to use entity identifiers as the "id" field, and silence this deprecation.'); + $useIriAsId = true; + } + $itemNormalizer = $container->getDefinition('api_platform.jsonapi.normalizer.item'); $itemNormalizer->replaceArgument(7, [JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false]); - $itemNormalizer->addArgument($config['jsonapi']['use_iri_as_id']); + $itemNormalizer->addArgument($useIriAsId); $itemNormalizer->addArgument(new Reference('api_platform.jsonapi.resource_linkage_resolver')); + + $itemDenormalizer = $container->getDefinition('api_platform.jsonapi.denormalizer.item'); + $itemDenormalizer->replaceArgument(7, [JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false]); + $itemDenormalizer->addArgument($useIriAsId); } private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void @@ -903,9 +912,7 @@ private function registerHttpCacheConfiguration(ContainerBuilder $container, arr $definition->addTag('api_platform.http_cache.http_client'); } - if (!($urls = $config['http_cache']['invalidation']['urls'])) { - $urls = $config['http_cache']['invalidation']['varnish_urls']; - } + $urls = $config['http_cache']['invalidation']['urls']; foreach ($urls as $key => $url) { $definition = new Definition(ScopingHttpClient::class, [new Reference('http_client'), $url, ['base_uri' => $url] + $config['http_cache']['invalidation']['request_options']]); diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php index 45485824678..836c54d3748 100644 --- a/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php @@ -57,6 +57,8 @@ private function createFilterDefinitions(\ReflectionClass $resourceReflectionCla continue; } + trigger_deprecation('api-platform/core', '4.4', \sprintf('Declaring filters on "%s" with the "#[ApiFilter]" attribute is deprecated, use the "#[QueryParameter]" attribute instead. The "#[ApiFilter]" attribute will be removed in 6.0.', $resourceReflectionClass->getName())); + if (null === $filterReflectionClass = $container->getReflectionClass($filterClass, false)) { throw new InvalidArgumentException(\sprintf('Class "%s" used for service "%s" cannot be found.', $filterClass, $id)); } diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/DataProviderPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/DataProviderPass.php deleted file mode 100644 index 78e3c47afb5..00000000000 --- a/src/Symfony/Bundle/DependencyInjection/Compiler/DataProviderPass.php +++ /dev/null @@ -1,47 +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 ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler; - -use ApiPlatform\State\SerializerAwareProviderInterface; -use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Reference; - -/** - * Registers data providers. - * - * @internal since 4.2 - * - * @author Kévin Dunglas - * @author Vincent Chalamon - * - * TODO: remove in 5.x - */ -final class DataProviderPass implements CompilerPassInterface -{ - /** - * {@inheritdoc} - */ - public function process(ContainerBuilder $container): void - { - $services = $container->findTaggedServiceIds('api_platform.state_provider', true); - - foreach ($services as $id => $tags) { - $definition = $container->getDefinition((string) $id); - if (is_a($definition->getClass(), SerializerAwareProviderInterface::class, true)) { - $definition->addMethodCall('setSerializerLocator', [new Reference('api_platform.serializer_locator')]); - } - } - } -} diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index b70544ffbe6..fb070df0506 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -23,6 +23,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\OptimisticLockException; use GraphQL\GraphQL; use Symfony\Bundle\FrameworkBundle\Controller\ControllerHelper; @@ -94,19 +95,14 @@ public function getConfigTreeBuilder(): TreeBuilder ->addDefaultsIfNotSet() ->children() ->variableNode('serialize_payload_fields')->defaultValue([])->info('Set to null to serialize all payload fields when a validation error is thrown, or set the fields you want to include explicitly.')->end() - ->booleanNode('query_parameter_validation') - ->defaultValue(true) - ->setDeprecated('api-platform/symfony', '4.2', 'Will be removed in API Platform 5.0.') - ->end() ->end() ->end() - // TODO 4.4: deprecate use_iri_as_id defaulting to true ->arrayNode('jsonapi') ->addDefaultsIfNotSet() ->children() ->booleanNode('use_iri_as_id') - ->defaultTrue() - ->info('Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses.') + ->defaultNull() + ->info('Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses. Defaults to true; this default will change to false in API Platform 5.0.') ->end() ->booleanNode('allow_client_generated_id') ->defaultFalse() @@ -131,13 +127,9 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('enable_scalar')->defaultValue(class_exists(TwigBundle::class))->info('Enable Scalar API Reference')->end() ->booleanNode('enable_entrypoint')->defaultTrue()->info('Enable the entrypoint')->end() ->booleanNode('enable_docs')->defaultTrue()->info('Enable the docs')->end() + ->booleanNode('enable_head_request_optimization')->defaultTrue()->info('Skip response body construction on HEAD requests so collections are not iterated. Disable to process HEAD identically to GET.')->end() ->booleanNode('enable_profiler')->defaultTrue()->info('Enable the data collector and the WebProfilerBundle integration.')->end() ->booleanNode('enable_phpdoc_parser')->defaultTrue()->info('Enable resource metadata collector using PHPStan PhpDocParser.')->end() - ->booleanNode('enable_link_security') - ->defaultTrue() - ->info('Enable security for Links (sub resources).') - ->setDeprecated('api-platform/symfony', '4.2', 'This option is always enabled and will be removed in API Platform 5.0.') - ->end() ->arrayNode('collection') ->addDefaultsIfNotSet() ->children() @@ -168,10 +160,6 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() - ->arrayNode('resource_class_directories') - ->prototype('scalar')->end() - ->setDeprecated('api-platform/symfony', '4.1', 'The "resource_class_directories" configuration is deprecated, classes using #[ApiResource] attribute are autoconfigured by the dependency injection container.') - ->end() ->arrayNode('serializer') ->addDefaultsIfNotSet() ->children() @@ -298,10 +286,6 @@ private function addGraphQlSection(ArrayNodeDefinition $rootNode): void ->end() ->integerNode('max_query_depth')->defaultValue(20) ->end() - ->arrayNode('graphql_playground') - ->setDeprecated('api-platform/core', '4.0', 'The "graphql_playground" configuration is deprecated and will be ignored.') - ->canBeEnabled() - ->end() ->integerNode('max_query_complexity')->defaultValue(500) ->end() ->scalarNode('nesting_separator')->defaultValue('_')->info('The separator to use to filter nested fields.')->end() @@ -328,6 +312,7 @@ private function addSwaggerSection(ArrayNodeDefinition $rootNode): void ->addDefaultsIfNotSet() ->children() ->booleanNode('persist_authorization')->defaultValue(false)->info('Persist the SwaggerUI Authorization in the localStorage.')->end() + ->booleanNode('with_credentials')->defaultValue(false)->info('Send credentials (cookies, authorization headers) on Swagger UI cross-origin requests (e.g. when running behind Cloudflare Access).')->end() ->arrayNode('versions') ->info('The active versions of OpenAPI to be exported or used in Swagger UI. The first value is the default.') ->defaultValue($supportedVersions) @@ -411,12 +396,6 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void ->info('Enable the tags-based cache invalidation system.') ->canBeEnabled() ->children() - ->arrayNode('varnish_urls') - ->setDeprecated('api-platform/core', '3.0', 'The "varnish_urls" configuration is deprecated, use "urls" or "scoped_clients".') - ->defaultValue([]) - ->prototype('scalar')->end() - ->info('URLs of the Varnish servers to purge using cache tags when a resource is updated.') - ->end() ->arrayNode('urls') ->defaultValue([]) ->prototype('scalar')->end() @@ -443,16 +422,6 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void ->defaultValue('api_platform.http_cache.purger.varnish') ->info('Specify a purger to use (available values: "api_platform.http_cache.purger.varnish.ban", "api_platform.http_cache.purger.varnish.xkey", "api_platform.http_cache.purger.souin").') ->end() - ->arrayNode('xkey') - ->setDeprecated('api-platform/core', '3.0', 'The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate parameters.') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('glue') - ->defaultValue(' ') - ->info('xkey glue between keys') - ->end() - ->end() - ->end() ->end() ->end() ->end() @@ -624,6 +593,7 @@ private function addExceptionToStatusSection(ArrayNodeDefinition $rootNode): voi SerializerExceptionInterface::class => Response::HTTP_BAD_REQUEST, InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, OptimisticLockException::class => Response::HTTP_CONFLICT, + UniqueConstraintViolationException::class => Response::HTTP_UNPROCESSABLE_ENTITY, ]) ->info('The list of exceptions mapped to their HTTP status code.') ->normalizeKeys(false) diff --git a/src/Symfony/Bundle/Resources/config/api.php b/src/Symfony/Bundle/Resources/config/api.php index 586e2988ebe..817be1aa96a 100644 --- a/src/Symfony/Bundle/Resources/config/api.php +++ b/src/Symfony/Bundle/Resources/config/api.php @@ -29,6 +29,7 @@ use ApiPlatform\Serializer\ConstraintViolationListNormalizer; use ApiPlatform\Serializer\Filter\GroupFilter; use ApiPlatform\Serializer\Filter\PropertyFilter; +use ApiPlatform\Serializer\ItemDenormalizer; use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory; use ApiPlatform\Serializer\Mapping\Loader\PropertyMetadataLoader; @@ -177,6 +178,24 @@ ]) ->tag('serializer.normalizer', ['priority' => -895]); + $services->set('api_platform.serializer.denormalizer.item', ItemDenormalizer::class) + ->args([ + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.iri_converter'), + service('api_platform.resource_class_resolver'), + service('api_platform.property_accessor'), + service('api_platform.name_converter')->ignoreOnInvalid(), + service('serializer.mapping.class_metadata_factory')->ignoreOnInvalid(), + null, + service('api_platform.metadata.resource.metadata_collection_factory')->ignoreOnInvalid(), + service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), + [], + service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + service('api_platform.serializer.operation_resource_resolver'), + ]) + ->tag('serializer.normalizer', ['priority' => -894]); + $services->set('api_platform.normalizer.object', ObjectNormalizer::class) ->args([ service('serializer.mapping.class_metadata_factory'), diff --git a/src/Symfony/Bundle/Resources/config/elasticsearch.php b/src/Symfony/Bundle/Resources/config/elasticsearch.php index 04cdda7d736..267e19bbb66 100644 --- a/src/Symfony/Bundle/Resources/config/elasticsearch.php +++ b/src/Symfony/Bundle/Resources/config/elasticsearch.php @@ -36,6 +36,10 @@ ->decorate('api_platform.serializer.normalizer.item', null, 0) ->args([service('api_platform.elasticsearch.normalizer.item.inner')]); + $services->set('api_platform.elasticsearch.denormalizer.item', ItemNormalizer::class) + ->decorate('api_platform.serializer.denormalizer.item', null, 0) + ->args([service('api_platform.elasticsearch.denormalizer.item.inner')]); + $services->set('api_platform.elasticsearch.normalizer.document', DocumentNormalizer::class) ->args([ service('api_platform.metadata.resource.metadata_collection_factory'), diff --git a/src/Symfony/Bundle/Resources/config/graphql.php b/src/Symfony/Bundle/Resources/config/graphql.php index 0453cc84485..4ed76ada9f5 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.php +++ b/src/Symfony/Bundle/Resources/config/graphql.php @@ -24,6 +24,7 @@ use ApiPlatform\GraphQl\Serializer\Exception\HttpExceptionNormalizer; use ApiPlatform\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer; use ApiPlatform\GraphQl\Serializer\Exception\ValidationExceptionNormalizer; +use ApiPlatform\GraphQl\Serializer\ItemDenormalizer; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\GraphQl\Serializer\ObjectNormalizer; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilder; @@ -250,6 +251,21 @@ ]) ->tag('serializer.normalizer', ['priority' => -890]); + $services->set('api_platform.graphql.denormalizer.item', ItemDenormalizer::class) + ->args([ + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.symfony.iri_converter'), + service('api_platform.resource_class_resolver'), + service('api_platform.property_accessor'), + service('api_platform.name_converter')->ignoreOnInvalid(), + service('serializer.mapping.class_metadata_factory')->ignoreOnInvalid(), + [], + service('api_platform.metadata.resource.metadata_collection_factory')->ignoreOnInvalid(), + service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), + ]) + ->tag('serializer.normalizer', ['priority' => -889]); + $services->set('api_platform.graphql.normalizer.object', ObjectNormalizer::class) ->args([ service('api_platform.normalizer.object'), diff --git a/src/Symfony/Bundle/Resources/config/json_schema.php b/src/Symfony/Bundle/Resources/config/json_schema.php index ab64a31e40d..b1a21cf8c1f 100644 --- a/src/Symfony/Bundle/Resources/config/json_schema.php +++ b/src/Symfony/Bundle/Resources/config/json_schema.php @@ -30,7 +30,6 @@ service('api_platform.metadata.property.metadata_factory'), service('api_platform.name_converter')->ignoreOnInvalid(), service('api_platform.resource_class_resolver'), - [], service('api_platform.json_schema.definition_name_factory')->ignoreOnInvalid(), ]); diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/events.php b/src/Symfony/Bundle/Resources/config/json_streamer/events.php index e5addb83c23..a1d0b2a7b25 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/events.php +++ b/src/Symfony/Bundle/Resources/config/json_streamer/events.php @@ -34,6 +34,7 @@ '%api_platform.collection.pagination.enabled_parameter_name%', '%api_platform.url_generation_strategy%', service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.jsonld.state_provider.json_streamer', HydraJsonStreamerProvider::class) @@ -50,6 +51,7 @@ service('api_platform.resource_class_resolver'), service('api_platform.metadata.operation.metadata_factory'), service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_provider.json_streamer', JsonStreamerProvider::class) diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php b/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php index 17fe3e72c08..e5e8a9feeb9 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php +++ b/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php @@ -31,6 +31,7 @@ '%api_platform.collection.pagination.enabled_parameter_name%', '%api_platform.url_generation_strategy%', service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.jsonld.state_provider.json_streamer', JsonStreamerProvider::class) diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/json.php b/src/Symfony/Bundle/Resources/config/json_streamer/json.php index 40831d878fa..59e17a94149 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/json.php +++ b/src/Symfony/Bundle/Resources/config/json_streamer/json.php @@ -28,6 +28,7 @@ service('api_platform.resource_class_resolver'), service('api_platform.metadata.operation.metadata_factory'), service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_provider.json_streamer', JsonStreamerProvider::class) diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.php b/src/Symfony/Bundle/Resources/config/jsonapi.php index 5c494fd6715..4e6f610c443 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.php +++ b/src/Symfony/Bundle/Resources/config/jsonapi.php @@ -18,6 +18,7 @@ use ApiPlatform\JsonApi\Serializer\ConstraintViolationListNormalizer; use ApiPlatform\JsonApi\Serializer\EntrypointNormalizer; use ApiPlatform\JsonApi\Serializer\ErrorNormalizer; +use ApiPlatform\JsonApi\Serializer\ItemDenormalizer; use ApiPlatform\JsonApi\Serializer\ItemNormalizer; use ApiPlatform\JsonApi\Serializer\ObjectNormalizer; use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; @@ -84,6 +85,23 @@ ]) ->tag('serializer.normalizer', ['priority' => -890]); + $services->set('api_platform.jsonapi.denormalizer.item', ItemDenormalizer::class) + ->args([ + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.iri_converter'), + service('api_platform.resource_class_resolver'), + service('api_platform.property_accessor'), + service('api_platform.jsonapi.name_converter.reserved_attribute_name'), + service('serializer.mapping.class_metadata_factory')->ignoreOnInvalid(), + [], + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), + service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + service('api_platform.serializer.operation_resource_resolver'), + ]) + ->tag('serializer.normalizer', ['priority' => -889]); + $services->set('api_platform.jsonapi.normalizer.object', ObjectNormalizer::class) ->args([ service('api_platform.normalizer.object'), diff --git a/src/Symfony/Bundle/Resources/config/jsonld.php b/src/Symfony/Bundle/Resources/config/jsonld.php index 33859c30723..2bfc88269a5 100644 --- a/src/Symfony/Bundle/Resources/config/jsonld.php +++ b/src/Symfony/Bundle/Resources/config/jsonld.php @@ -15,6 +15,7 @@ use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonLd\Serializer\ErrorNormalizer; +use ApiPlatform\JsonLd\Serializer\ItemDenormalizer; use ApiPlatform\JsonLd\Serializer\ItemNormalizer; use ApiPlatform\JsonLd\Serializer\ObjectNormalizer; use ApiPlatform\Serializer\JsonEncoder; @@ -54,6 +55,23 @@ ]) ->tag('serializer.normalizer', ['priority' => -890]); + $services->set('api_platform.jsonld.denormalizer.item', ItemDenormalizer::class) + ->args([ + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.iri_converter'), + service('api_platform.resource_class_resolver'), + service('api_platform.property_accessor'), + service('api_platform.name_converter')->ignoreOnInvalid(), + service('serializer.mapping.class_metadata_factory')->ignoreOnInvalid(), + '%api_platform.serializer.default_context%', + service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), + service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + service('api_platform.serializer.operation_resource_resolver'), + ]) + ->tag('serializer.normalizer', ['priority' => -889]); + $services->set('api_platform.jsonld.normalizer.error', ErrorNormalizer::class) ->args([ service('api_platform.jsonld.normalizer.item'), diff --git a/src/Symfony/Bundle/Resources/config/openapi.php b/src/Symfony/Bundle/Resources/config/openapi.php index c0cdfd3286b..f164982ae01 100644 --- a/src/Symfony/Bundle/Resources/config/openapi.php +++ b/src/Symfony/Bundle/Resources/config/openapi.php @@ -76,6 +76,7 @@ '%api_platform.openapi.errorResourceClass%', '%api_platform.openapi.validationErrorResourceClass%', '%api_platform.openapi.license.identifier%', + '%api_platform.swagger.with_credentials%', ]); $services->alias(Options::class, 'api_platform.openapi.options'); diff --git a/src/Symfony/Bundle/Resources/config/state/processor.php b/src/Symfony/Bundle/Resources/config/state/processor.php index f44dfb20d8f..b07670d45a8 100644 --- a/src/Symfony/Bundle/Resources/config/state/processor.php +++ b/src/Symfony/Bundle/Resources/config/state/processor.php @@ -29,6 +29,7 @@ service('api_platform.state_processor.serialize.inner'), service('api_platform.serializer'), service('api_platform.serializer.context_builder'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_processor.write', WriteProcessor::class) diff --git a/src/Symfony/Bundle/Resources/config/state/provider.php b/src/Symfony/Bundle/Resources/config/state/provider.php index f31fc2bc7c1..e57c02f59a5 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.php +++ b/src/Symfony/Bundle/Resources/config/state/provider.php @@ -13,11 +13,13 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\Provider\ContentNegotiationProvider; use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\Provider\ParameterProvider; use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\Symfony\EventListener\ErrorListener; +use ApiPlatform\Validator\DenormalizationViolationFactory; return static function (ContainerConfigurator $container) { $services = $container->services(); @@ -40,13 +42,22 @@ service('api_platform.serializer.context_builder'), ]); + $services->set('api_platform.state.denormalization_violation_factory', DenormalizationViolationFactory::class) + ->args([ + service('validator'), + service('translator')->nullOnInvalid(), + ]); + + $services->alias(DenormalizationViolationFactoryInterface::class, 'api_platform.state.denormalization_violation_factory'); + $services->set('api_platform.state_provider.deserialize', DeserializeProvider::class) ->decorate('api_platform.state_provider.main', null, 300) ->args([ service('api_platform.state_provider.deserialize.inner'), service('api_platform.serializer'), service('api_platform.serializer.context_builder'), - service('translator')->nullOnInvalid(), + null, + service('api_platform.state.denormalization_violation_factory')->nullOnInvalid(), ]); $services->set('api_platform.error_listener', ErrorListener::class) diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index 91b784f07eb..ae428bb459d 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\Processor\AddLinkHeaderProcessor; use ApiPlatform\State\Processor\RespondProcessor; use ApiPlatform\State\Processor\SerializeProcessor; @@ -31,6 +32,7 @@ use ApiPlatform\Symfony\EventListener\RespondListener; use ApiPlatform\Symfony\EventListener\SerializeListener; use ApiPlatform\Symfony\EventListener\WriteListener; +use ApiPlatform\Validator\DenormalizationViolationFactory; return static function (ContainerConfigurator $container) { $services = $container->services(); @@ -70,12 +72,21 @@ ]) ->tag('kernel.event_listener', ['event' => 'kernel.request', 'method' => 'onKernelRequest', 'priority' => 4]); + $services->set('api_platform.state.denormalization_violation_factory', DenormalizationViolationFactory::class) + ->args([ + service('validator'), + service('translator')->nullOnInvalid(), + ]); + + $services->alias(DenormalizationViolationFactoryInterface::class, 'api_platform.state.denormalization_violation_factory'); + $services->set('api_platform.state_provider.deserialize', DeserializeProvider::class) ->args([ null, service('api_platform.serializer'), service('api_platform.serializer.context_builder'), - service('translator')->nullOnInvalid(), + null, + service('api_platform.state.denormalization_violation_factory')->nullOnInvalid(), ]); $services->set('api_platform.listener.request.deserialize', DeserializeListener::class) @@ -90,6 +101,7 @@ null, service('api_platform.serializer'), service('api_platform.serializer.context_builder'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_processor.write', WriteProcessor::class) @@ -151,6 +163,7 @@ service('api_platform.state_processor.documentation.serialize.inner'), service('api_platform.serializer'), service('api_platform.serializer.context_builder'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_processor.documentation.write', WriteProcessor::class) diff --git a/src/Symfony/Bundle/Resources/config/upgrade.php b/src/Symfony/Bundle/Resources/config/upgrade.php new file mode 100644 index 00000000000..b53ebf3d1c3 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/upgrade.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterMapper; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterResolver; +use ApiPlatform\Symfony\Bundle\Command\UpgradeApiFilterCommand; + +return static function (ContainerConfigurator $container): void { + $services = $container->services(); + + $services->set('api_platform.upgrade.filter_mapper', UpgradeApiFilterMapper::class); + + $services->set('api_platform.upgrade.filter_resolver', UpgradeApiFilterResolver::class) + ->args([ + service('api_platform.upgrade.filter_mapper'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.resource_class_resolver'), + ]); + + $services->set('api_platform.upgrade.filter_command', UpgradeApiFilterCommand::class) + ->args([ + service('api_platform.metadata.resource.name_collection_factory'), + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.filter_locator'), + service('api_platform.upgrade.filter_resolver'), + ]) + ->tag('console.command'); +}; diff --git a/src/Symfony/Bundle/Resources/public/init-swagger-ui.js b/src/Symfony/Bundle/Resources/public/init-swagger-ui.js index bdf9bb3a8c0..0e8059f7d7a 100644 --- a/src/Symfony/Bundle/Resources/public/init-swagger-ui.js +++ b/src/Symfony/Bundle/Resources/public/init-swagger-ui.js @@ -41,7 +41,7 @@ window.onload = function() { }).observe(document, {childList: true, subtree: true}); const data = JSON.parse(document.getElementById('swagger-data').innerText); - const ui = SwaggerUIBundle(Object.assign({ + const config = { spec: data.spec, dom_id: '#swagger-ui', validatorUrl: null, @@ -56,7 +56,16 @@ window.onload = function() { SwaggerUIBundle.plugins.DownloadUrl, ], layout: 'StandaloneLayout', - }, data.extraConfiguration)); + }; + + if (data.withCredentials) { + config.requestInterceptor = (req) => { + req.credentials = 'include'; + return req; + }; + } + + const ui = SwaggerUIBundle(Object.assign(config, data.extraConfiguration)); if (data.oauth.enabled) { ui.initOAuth({ diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php index eba9d89fed8..065ada1ea14 100644 --- a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php @@ -64,6 +64,7 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable 'url' => $this->urlGenerator->generate('api_doc', ['format' => 'json']), 'spec' => $this->normalizer->normalize($openApi, 'json', []), 'persistAuthorization' => $this->openApiOptions->hasPersistAuthorization(), + 'withCredentials' => $this->openApiOptions->getWithCredentials(), 'oauth' => [ 'enabled' => $this->openApiOptions->getOAuthEnabled(), 'type' => $this->openApiOptions->getOAuthType(), diff --git a/src/Symfony/Bundle/Test/ApiTestCase.php b/src/Symfony/Bundle/Test/ApiTestCase.php index 90bd2a7db96..e1076128deb 100644 --- a/src/Symfony/Bundle/Test/ApiTestCase.php +++ b/src/Symfony/Bundle/Test/ApiTestCase.php @@ -33,13 +33,12 @@ abstract class ApiTestCase extends KernelTestCase /** * If you're using RecreateDatabaseTrait, RefreshDatabaseTrait, ReloadDatabaseTrait from theofidry/AliceBundle, you - * probably need to set this property to false in your test class to avoid recreating the database on each client creation. + * probably need to keep this property to false in your test class to avoid recreating the database on each client creation. * - * - `null` triggers a deprecation message and always boots the kernel * - `false` does not boot the kernel if it's already booted - * - `true` always boots the kernel without any deprecation message + * - `true` always boots the kernel */ - protected static ?bool $alwaysBootKernel = null; + protected static ?bool $alwaysBootKernel = false; private bool $symfonyErrorHandlerWasRegistered = false; @@ -80,15 +79,7 @@ private static function isSymfonyErrorHandlerRegistered(): bool */ protected static function createClient(array $kernelOptions = [], array $defaultOptions = []): Client { - if (null === static::$alwaysBootKernel) { - trigger_deprecation( - 'api-platform/symfony', - '4.1.0', - 'Currently, the kernel will always be booted when a new client is created, but in API Platform 5.0, it will not be booted unless you set `static::$alwaysBootKernel` to `true` (the default will be `false`). See https://github.com/api-platform/core/issues/6971 for more information.', - ); - } - - if (static::$alwaysBootKernel || null === static::$alwaysBootKernel) { + if (static::$alwaysBootKernel) { static::bootKernel($kernelOptions); } diff --git a/src/Symfony/Security/Exception/AccessDeniedException.php b/src/Symfony/Security/Exception/AccessDeniedException.php index e5c594a428b..88349e501f2 100644 --- a/src/Symfony/Security/Exception/AccessDeniedException.php +++ b/src/Symfony/Security/Exception/AccessDeniedException.php @@ -13,14 +13,24 @@ namespace ApiPlatform\Symfony\Security\Exception; +use ApiPlatform\Metadata\Exception\AccessDeniedException as MetadataAccessDeniedException; use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException as ExceptionAccessDeniedException; /** - * TODO: deprecate in favor of Metadata. + * @deprecated since API Platform 4.4, use {@see MetadataAccessDeniedException} instead */ final class AccessDeniedException extends ExceptionAccessDeniedException implements HttpExceptionInterface { + public function __construct(string $message = 'Access Denied.', ?\Throwable $previous = null, int $code = 403, bool $triggerDeprecation = true) + { + if ($triggerDeprecation) { + trigger_deprecation('api-platform/core', '4.4', 'The "%s" class is deprecated, use "%s" instead.', self::class, MetadataAccessDeniedException::class); + } + + parent::__construct($message, $previous, $code); + } + public function getStatusCode(): int { return 403; diff --git a/src/Symfony/Security/State/AccessCheckerProvider.php b/src/Symfony/Security/State/AccessCheckerProvider.php index ec14aceff1c..fa3509767b7 100644 --- a/src/Symfony/Security/State/AccessCheckerProvider.php +++ b/src/Symfony/Security/State/AccessCheckerProvider.php @@ -98,7 +98,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } if (!$this->resourceAccessChecker->isGranted($operation->getClass(), $isGranted, $resourceAccessCheckerContext)) { - $operation instanceof GraphQlOperation ? throw new AccessDeniedHttpException($message ?? 'Access Denied.') : throw new AccessDeniedException($message ?? 'Access Denied.'); + $operation instanceof GraphQlOperation ? throw new AccessDeniedHttpException($message ?? 'Access Denied.') : throw new AccessDeniedException($message ?? 'Access Denied.', null, 403, false); } return 'pre_read' === $this->event ? $this->decorated->provide($operation, $uriVariables, $context) : $body; diff --git a/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterMapperTest.php b/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterMapperTest.php new file mode 100644 index 00000000000..7292a603684 --- /dev/null +++ b/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterMapperTest.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\Bundle\Command; + +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterMapper; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class UpgradeApiFilterMapperTest extends TestCase +{ + /** + * @param array{filterClass: string, castToNativeType: bool, nativeType: ?string, caseSensitive?: bool} $expected + */ + #[DataProvider('ormMappings')] + public function testMapOrm(string $filter, ?string $strategy, ?string $propertyNativeType, bool $isRelation, array $expected): void + { + $mapper = new UpgradeApiFilterMapper(); + $result = $mapper->map($filter, $strategy, $propertyNativeType, $isRelation); + + $this->assertSame($expected['filterClass'], $result->filterClass); + $this->assertSame($expected['castToNativeType'], $result->castToNativeType); + $this->assertSame($expected['nativeType'], $result->nativeType); + $this->assertSame($expected['caseSensitive'] ?? false, $result->caseSensitive); + } + + public static function ormMappings(): iterable + { + $orm = 'ApiPlatform\Doctrine\Orm\Filter\\'; + + yield 'Boolean -> Exact+bool+cast' => [ + $orm.'BooleanFilter', null, 'bool', false, + ['filterClass' => $orm.'ExactFilter', 'castToNativeType' => true, 'nativeType' => 'bool'], + ]; + + yield 'Numeric -> Exact+int+cast' => [ + $orm.'NumericFilter', null, 'int', false, + ['filterClass' => $orm.'ExactFilter', 'castToNativeType' => true, 'nativeType' => 'int'], + ]; + + yield 'Numeric float keeps native float' => [ + $orm.'NumericFilter', null, 'float', false, + ['filterClass' => $orm.'ExactFilter', 'castToNativeType' => true, 'nativeType' => 'float'], + ]; + + yield 'Order -> Sort, no cast/native' => [ + $orm.'OrderFilter', null, null, false, + ['filterClass' => $orm.'SortFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + // Legacy default is case-sensitive; the new search filters default to case-insensitive, + // so a non-"i" strategy must opt back in with caseSensitive: true. + yield 'Search partial -> PartialSearchFilter case-sensitive' => [ + $orm.'SearchFilter', 'partial', 'string', false, + ['filterClass' => $orm.'PartialSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => true], + ]; + + yield 'Search ipartial -> PartialSearchFilter case-insensitive' => [ + $orm.'SearchFilter', 'ipartial', 'string', false, + ['filterClass' => $orm.'PartialSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => false], + ]; + + yield 'Search exact -> ExactFilter' => [ + $orm.'SearchFilter', 'exact', 'string', false, + ['filterClass' => $orm.'ExactFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + yield 'Search iexact -> ExactFilter (no case option)' => [ + $orm.'SearchFilter', 'iexact', 'string', false, + ['filterClass' => $orm.'ExactFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + yield 'Search start -> StartSearchFilter case-sensitive' => [ + $orm.'SearchFilter', 'start', 'string', false, + ['filterClass' => $orm.'StartSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => true], + ]; + + yield 'Search istart -> StartSearchFilter case-insensitive' => [ + $orm.'SearchFilter', 'istart', 'string', false, + ['filterClass' => $orm.'StartSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => false], + ]; + + yield 'Search end -> EndSearchFilter case-sensitive' => [ + $orm.'SearchFilter', 'end', 'string', false, + ['filterClass' => $orm.'EndSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => true], + ]; + + yield 'Search word_start -> WordStartSearchFilter case-sensitive' => [ + $orm.'SearchFilter', 'word_start', 'string', false, + ['filterClass' => $orm.'WordStartSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => true], + ]; + + yield 'Search iword_start -> WordStartSearchFilter case-insensitive' => [ + $orm.'SearchFilter', 'iword_start', 'string', false, + ['filterClass' => $orm.'WordStartSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => false], + ]; + + yield 'Search on relation -> IriFilter' => [ + $orm.'SearchFilter', 'exact', null, true, + ['filterClass' => $orm.'IriFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + yield 'Date survives' => [ + $orm.'DateFilter', null, null, false, + ['filterClass' => $orm.'DateFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + yield 'Range survives' => [ + $orm.'RangeFilter', null, null, false, + ['filterClass' => $orm.'RangeFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + yield 'Exists survives' => [ + $orm.'ExistsFilter', null, null, false, + ['filterClass' => $orm.'ExistsFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + yield 'custom filter passthrough' => [ + 'App\Filter\CustomFilter', null, null, false, + ['filterClass' => 'App\Filter\CustomFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + } + + public function testOdmSearchMapsToOdmCanonical(): void + { + $odm = 'ApiPlatform\Doctrine\Odm\Filter\\'; + $mapper = new UpgradeApiFilterMapper(); + + $result = $mapper->map($odm.'SearchFilter', 'partial', 'string', false); + + $this->assertSame($odm.'PartialSearchFilter', $result->filterClass); + } +} diff --git a/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterResolverTest.php b/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterResolverTest.php new file mode 100644 index 00000000000..c5eee45169c --- /dev/null +++ b/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterResolverTest.php @@ -0,0 +1,377 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\Bundle\Command; + +use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Doctrine\Orm\Filter\NumericFilter; +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterCollisionException; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterMapper; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterNameConversionException; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterResolver; +use Doctrine\Common\Collections\Collection; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\TypeInfo\Type; + +final class UpgradeApiFilterResolverTest extends TestCase +{ + /** + * @param array $nativeTypes property name => native type used to detect relations + * @param list $resourceClasses class names the resolver should treat as API resources + */ + private function resolver(array $nativeTypes = [], array $resourceClasses = []): UpgradeApiFilterResolver + { + return new UpgradeApiFilterResolver( + new UpgradeApiFilterMapper(), + $this->propertyMetadataFactory($nativeTypes), + $this->resourceClassResolver($resourceClasses), + ); + } + + /** + * @param array $nativeTypes + */ + private function propertyMetadataFactory(array $nativeTypes): PropertyMetadataFactoryInterface + { + return new class($nativeTypes) implements PropertyMetadataFactoryInterface { + public function __construct(private array $nativeTypes) + { + } + + public function create(string $resourceClass, string $property, array $options = []): ApiProperty + { + return (new ApiProperty())->withNativeType($this->nativeTypes[$property] ?? Type::string()); + } + }; + } + + /** + * @param list $resourceClasses + */ + private function resourceClassResolver(array $resourceClasses): ResourceClassResolverInterface + { + return new class($resourceClasses) implements ResourceClassResolverInterface { + public function __construct(private array $resourceClasses) + { + } + + public function isResourceClass(string $type): bool + { + return \in_array($type, $this->resourceClasses, true); + } + + public function getResourceClass(mixed $value, ?string $resourceClass = null, bool $strict = false): string + { + return $resourceClass ?? ''; + } + }; + } + + /** + * @param array $arguments + * + * @return array{filter: FilterInterface, filterClass: string, arguments: array} + */ + private function entry(string $filterClass, FilterInterface $filter, array $arguments = []): array + { + return ['filter' => $filter, 'filterClass' => $filterClass, 'arguments' => $arguments]; + } + + private function filter(array $description, ?array $properties = null, ?NameConverterInterface $nameConverter = null): FilterInterface + { + return new class($description, $properties, $nameConverter) implements FilterInterface { + public function __construct(private array $description, private ?array $properties, private ?NameConverterInterface $nameConverter) + { + } + + public function getDescription(string $resourceClass): array + { + return $this->description; + } + + public function getProperties(): ?array + { + return $this->properties; + } + + public function getNameConverter(): ?NameConverterInterface + { + return $this->nameConverter; + } + }; + } + + public function testBooleanFilterResolvesToExact(): void + { + $filter = $this->filter([ + 'active' => ['property' => 'active', 'type' => 'bool', 'strategy' => null], + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [$this->entry(BooleanFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('active', $params[0]->key); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\ExactFilter', $params[0]->filterClass); + $this->assertSame('bool', $params[0]->nativeType); + $this->assertTrue($params[0]->castToNativeType); + } + + public function testSearchFilterStrategyResolvesPerProperty(): void + { + $filter = $this->filter([ + 'name' => ['property' => 'name', 'type' => 'string', 'strategy' => 'partial'], + 'code' => ['property' => 'code', 'type' => 'string', 'strategy' => 'exact'], + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [$this->entry(SearchFilter::class, $filter)]); + + $byKey = []; + foreach ($params as $p) { + $byKey[$p->key] = $p->filterClass; + } + + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter', $byKey['name']); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\ExactFilter', $byKey['code']); + } + + public function testSearchFilterOnRelationResolvesToIri(): void + { + $filter = $this->filter([ + 'groups' => ['property' => 'groups', 'type' => 'string', 'strategy' => 'exact', 'is_collection' => false], + 'groups[]' => ['property' => 'groups', 'type' => 'string', 'strategy' => 'exact', 'is_collection' => true], + ]); + + $params = $this->resolver( + ['groups' => Type::collection(Type::object(Collection::class), Type::object(\stdClass::class))], + [\stdClass::class], + )->resolve('App\Entity\User', [$this->entry(SearchFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('groups', $params[0]->key); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\IriFilter', $params[0]->filterClass); + } + + public function testNestedSearchKeyEmitsExplicitProperty(): void + { + $filter = $this->filter([ + 'colors.prop' => ['property' => 'colors.prop', 'type' => 'string', 'strategy' => 'ipartial'], + ]); + + // colors.prop is a scalar reached through the colors relation, not a relation itself. + $params = $this->resolver(['colors.prop' => Type::string()]) + ->resolve('App\Entity\DummyCar', [$this->entry(SearchFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('colors.prop', $params[0]->key); + $this->assertSame('colors.prop', $params[0]->property); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter', $params[0]->filterClass); + } + + public function testSearchFilterOnScalarStaysSearch(): void + { + $filter = $this->filter([ + 'name' => ['property' => 'name', 'type' => 'string', 'strategy' => 'partial'], + ]); + + $params = $this->resolver(['name' => Type::string()]) + ->resolve('App\Entity\Dummy', [$this->entry(SearchFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter', $params[0]->filterClass); + } + + public function testSearchFilterOnDateFieldIsNotTreatedAsRelation(): void + { + $filter = $this->filter([ + 'dummyDate' => ['property' => 'dummyDate', 'type' => 'string', 'strategy' => 'exact'], + ]); + + // A \DateTime field resolves to an object native type but is not an API resource. + $params = $this->resolver(['dummyDate' => Type::object(\DateTime::class)]) + ->resolve('App\Entity\Dummy', [$this->entry(SearchFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\ExactFilter', $params[0]->filterClass); + } + + public function testDateFilterCarriesNullManagementAsFilterContext(): void + { + $filter = $this->filter([ + 'dateIncludeNullAfter[before]' => ['property' => 'dateIncludeNullAfter', 'type' => 'string', 'strategy' => null], + 'dateIncludeNullAfter[after]' => ['property' => 'dateIncludeNullAfter', 'type' => 'string', 'strategy' => null], + 'plainDate[before]' => ['property' => 'plainDate', 'type' => 'string', 'strategy' => null], + ], [ + 'dateIncludeNullAfter' => DateFilterInterface::INCLUDE_NULL_AFTER, + 'plainDate' => null, + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [$this->entry(DateFilter::class, $filter)]); + + $byKey = []; + foreach ($params as $p) { + $byKey[$p->key] = $p; + } + + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\DateFilter', $byKey['dateIncludeNullAfter']->filterClass); + $this->assertSame(DateFilterInterface::INCLUDE_NULL_AFTER, $byKey['dateIncludeNullAfter']->filterContext); + $this->assertNull($byKey['plainDate']->filterContext); + } + + public function testNameConvertedFilterIsSkipped(): void + { + // The new overlay filters do not denormalize, so a name-converted property cannot be migrated + // faithfully: the resource is skipped. + $filter = $this->filter([ + 'name_converted' => ['property' => 'name_converted', 'type' => 'string', 'strategy' => 'exact'], + ], null, new CamelCaseToSnakeCaseNameConverter()); + + $this->expectException(UpgradeApiFilterNameConversionException::class); + + $this->resolver(['nameConverted' => Type::string()]) + ->resolve('App\Entity\Converted', [$this->entry(SearchFilter::class, $filter)]); + } + + public function testFilterWithoutActualRenamingIsNotSkipped(): void + { + // A name converter that leaves the property unchanged (identity) must not trigger a skip. + $filter = $this->filter([ + 'name' => ['property' => 'name', 'type' => 'string', 'strategy' => 'exact'], + ], null, new CamelCaseToSnakeCaseNameConverter()); + + $params = $this->resolver(['name' => Type::string()]) + ->resolve('App\Entity\Dummy', [$this->entry(SearchFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('name', $params[0]->key); + } + + public function testKeptCustomFilterCarriesConstructorArguments(): void + { + $filter = $this->filter([ + 'foobargroups[]' => ['property' => null, 'type' => 'string', 'strategy' => null], + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [ + $this->entry('App\Filter\GroupFilter', $filter, ['parameterName' => 'foobargroups']), + ]); + + $this->assertCount(1, $params); + $this->assertSame('foobargroups', $params[0]->key); + $this->assertSame('App\Filter\GroupFilter', $params[0]->filterClass); + $this->assertSame(['parameterName' => 'foobargroups'], $params[0]->arguments); + } + + public function testRemappedFilterDropsConstructorArguments(): void + { + $filter = $this->filter([ + 'active' => ['property' => 'active', 'type' => 'bool', 'strategy' => null], + ]); + + // BooleanFilter is remapped to ExactFilter, whose constructor differs, so legacy args are dropped. + $params = $this->resolver()->resolve('App\Entity\Dummy', [ + $this->entry(BooleanFilter::class, $filter, ['someLegacyArg' => true]), + ]); + + $this->assertSame([], $params[0]->arguments); + } + + public function testExistsFilterCollapsesToTemplateKey(): void + { + $filter = $this->filter([ + 'exists[active]' => ['property' => 'active', 'type' => 'bool', 'strategy' => null], + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [$this->entry('App\Filter\ExistsFilter', $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('exists[:property]', $params[0]->key); + $this->assertNull($params[0]->property); + } + + public function testOrderFilterCollapsesToTemplateKey(): void + { + $filter = $this->filter([ + 'order[createdAt]' => ['property' => 'createdAt', 'type' => 'string', 'strategy' => null], + 'order[name]' => ['property' => 'name', 'type' => 'string', 'strategy' => null], + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [$this->entry(OrderFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('order[:property]', $params[0]->key); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\SortFilter', $params[0]->filterClass); + } + + public function testRangeOperatorKeysCollapseToBaseProperty(): void + { + $filter = $this->filter([ + 'quantity[gt]' => ['property' => 'quantity', 'type' => 'string', 'strategy' => null], + 'quantity[lt]' => ['property' => 'quantity', 'type' => 'string', 'strategy' => null], + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [$this->entry(RangeFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('quantity', $params[0]->key); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\RangeFilter', $params[0]->filterClass); + } + + public function testCollisionOnSameKeyThrows(): void + { + $numeric = $this->filter([ + 'quantity' => ['property' => 'quantity', 'type' => 'int', 'strategy' => null], + ]); + $range = $this->filter([ + 'quantity[gt]' => ['property' => 'quantity', 'type' => 'string', 'strategy' => null], + ]); + + $this->expectException(UpgradeApiFilterCollisionException::class); + + $this->resolver()->resolve('App\Entity\Dummy', [ + $this->entry(NumericFilter::class, $numeric), + $this->entry(RangeFilter::class, $range), + ]); + } + + public function testCollisionWithReservedServiceFilterKeyThrows(): void + { + // An #[ApiFilter] SearchFilter on dummyDate would shadow an in-place service DateFilter + // declared through the resource `filters:` array on the same query key. + $search = $this->filter([ + 'dummyDate' => ['property' => 'dummyDate', 'type' => 'string', 'strategy' => 'exact'], + ]); + $serviceDateFilter = $this->filter([ + 'dummyDate[before]' => ['property' => 'dummyDate', 'type' => 'string', 'strategy' => null], + 'dummyDate[after]' => ['property' => 'dummyDate', 'type' => 'string', 'strategy' => null], + ]); + + $this->expectException(UpgradeApiFilterCollisionException::class); + + $this->resolver()->resolve( + 'App\Entity\Dummy', + [$this->entry(SearchFilter::class, $search)], + [$serviceDateFilter], + ); + } +} diff --git a/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterVisitorTest.php b/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterVisitorTest.php new file mode 100644 index 00000000000..19ad6effbd1 --- /dev/null +++ b/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterVisitorTest.php @@ -0,0 +1,369 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\Bundle\Command; + +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterParameter; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterVisitor; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor\CloningVisitor; +use PhpParser\ParserFactory; +use PhpParser\PrettyPrinter\Standard; +use PHPUnit\Framework\TestCase; + +final class UpgradeApiFilterVisitorTest extends TestCase +{ + private function transform(string $code, UpgradeApiFilterVisitor $visitor): string + { + $parser = (new ParserFactory())->createForHostVersion(); + $oldStmts = $parser->parse($code); + $oldTokens = $parser->getTokens(); + + $newStmts = (new NodeTraverser(new CloningVisitor()))->traverse($oldStmts); + $newStmts = (new NodeTraverser($visitor))->traverse($newStmts); + + return (new Standard())->printFormatPreserving($newStmts, $oldStmts, $oldTokens); + } + + public function testBooleanFilterBecomesExactFilterQueryParameter(): void + { + $before = <<<'PHP' + new QueryParameter(filter: new ExactFilter(), nativeType: new BuiltinType(TypeIdentifier::BOOL), castToNativeType: true)])] +#[ORM\Entity] +class ConvertedBoolean +{ + public $nameConverted; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedBoolean', [ + new UpgradeApiFilterParameter( + key: 'nameConverted', + filterClass: 'ApiPlatform\Doctrine\Orm\Filter\ExactFilter', + nativeType: 'bool', + castToNativeType: true, + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } + + public function testCustomServiceFilterKeepsConstructorArguments(): void + { + $before = <<<'PHP' + 'foobargroups'])] +#[ApiResource] +class DummyCar +{ + public $id; +} +PHP; + + $after = <<<'PHP' + new QueryParameter(filter: new GroupFilter(parameterName: 'foobargroups'))])] +class DummyCar +{ + public $id; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar', [ + new UpgradeApiFilterParameter( + key: 'foobargroups', + filterClass: 'ApiPlatform\Serializer\Filter\GroupFilter', + arguments: ['parameterName' => 'foobargroups'], + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } + + public function testCustomServiceFilterIsWrappedAsIs(): void + { + $before = <<<'PHP' + new QueryParameter(filter: new CustomFilter())])] +class DummyResource +{ + public $id; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5648\DummyResource', [ + new UpgradeApiFilterParameter( + key: 'id', + filterClass: 'ApiPlatform\Tests\Fixtures\TestBundle\Filter\CustomFilter', + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } + + public function testPropertyLevelApiFilterIsStripped(): void + { + $before = <<<'PHP' + new QueryParameter(filter: new ExactFilter())])] +class DummyCarColor +{ + private string $prop = ''; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor', [ + new UpgradeApiFilterParameter( + key: 'prop', + filterClass: 'ApiPlatform\Doctrine\Orm\Filter\ExactFilter', + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } + + public function testCaseSensitiveSearchFilterEmitsConstructorArgument(): void + { + $before = <<<'PHP' + 'partial'])] +#[ApiResource] +class DummyCar +{ + public $name; +} +PHP; + + $after = <<<'PHP' + new QueryParameter(filter: new PartialSearchFilter(caseSensitive: true))])] +class DummyCar +{ + public $name; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar', [ + new UpgradeApiFilterParameter( + key: 'name', + filterClass: 'ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter', + caseSensitive: true, + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } + + public function testDateFilterEmitsFilterContextConstant(): void + { + $before = <<<'PHP' + DateFilter::INCLUDE_NULL_AFTER])] +#[ApiResource] +class DummyDate +{ + public $dateIncludeNullAfter; +} +PHP; + + $after = <<<'PHP' + new QueryParameter(filter: new DateFilter(), filterContext: DateFilter::INCLUDE_NULL_AFTER)])] +class DummyDate +{ + public $dateIncludeNullAfter; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDate', [ + new UpgradeApiFilterParameter( + key: 'dateIncludeNullAfter', + filterClass: 'ApiPlatform\Doctrine\Orm\Filter\DateFilter', + filterContext: 'include_null_after', + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } + + public function testSurvivingFilterKeepsClassWithExplicitProperty(): void + { + $before = <<<'PHP' + new QueryParameter(filter: new ExistsFilter())])] +class ConvertedString +{ + public $nameConverted; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedString', [ + new UpgradeApiFilterParameter( + key: 'nameConverted', + filterClass: 'ApiPlatform\Doctrine\Orm\Filter\ExistsFilter', + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } +} diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 69e3ff03c98..689b17faa70 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -272,6 +272,9 @@ public function testCommonConfiguration(): void foreach ($services as $service) { $this->assertNotContainerHasService($service); } + + $this->assertTrue($this->container->hasParameter('api_platform.enable_head_request_optimization')); + $this->assertTrue($this->container->getParameter('api_platform.enable_head_request_optimization')); } public function testSwaggerUiDisabledConfiguration(): void diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/JsonApiUseIriAsIdDeprecationTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/JsonApiUseIriAsIdDeprecationTest.php new file mode 100644 index 00000000000..83f34ce3c49 --- /dev/null +++ b/src/Symfony/Tests/Bundle/DependencyInjection/JsonApiUseIriAsIdDeprecationTest.php @@ -0,0 +1,138 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\Bundle\DependencyInjection; + +use ApiPlatform\Metadata\Exception\ExceptionInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Symfony\Bundle\DependencyInjection\ApiPlatformExtension; +use ApiPlatform\Tests\Fixtures\TestBundle\TestBundle; +use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Doctrine\ORM\OptimisticLockException; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\SecurityBundle\SecurityBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; +use Symfony\Component\HttpFoundation\Response; + +final class JsonApiUseIriAsIdDeprecationTest extends TestCase +{ + private ContainerBuilder $container; + + protected function setUp(): void + { + $containerParameterBag = new ParameterBag([ + 'kernel.bundles' => [ + 'DoctrineBundle' => DoctrineBundle::class, + 'SecurityBundle' => SecurityBundle::class, + 'TwigBundle' => TwigBundle::class, + ], + 'kernel.bundles_metadata' => [ + 'TestBundle' => [ + 'parent' => null, + 'path' => realpath(__DIR__.'/../../../Fixtures/TestBundle'), + 'namespace' => TestBundle::class, + ], + ], + 'kernel.project_dir' => __DIR__.'/../../../Fixtures/app', + 'kernel.debug' => false, + 'kernel.environment' => 'test', + ]); + + $this->container = new ContainerBuilder($containerParameterBag); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testNotSettingUseIriAsIdIsDeprecatedAndResolvesToTrue(): void + { + $this->expectUserDeprecationMessage('Since api-platform/core 4.4: Not setting "api_platform.jsonapi.use_iri_as_id" explicitly is deprecated. Its default value will change from "true" to "false" in API Platform 5.0. Set it to "true" to keep the current behavior or to "false" to use entity identifiers as the "id" field, and silence this deprecation.'); + + (new ApiPlatformExtension())->load($this->buildConfig(), $this->container); + + $this->assertTrue($this->container->getDefinition('api_platform.jsonapi.normalizer.item')->getArgument(13)); + $this->assertTrue($this->container->getDefinition('api_platform.jsonapi.denormalizer.item')->getArgument(12)); + } + + public function testSettingUseIriAsIdToFalseDoesNotDeprecateAndResolvesToFalse(): void + { + (new ApiPlatformExtension())->load($this->buildConfig(['use_iri_as_id' => false]), $this->container); + + $this->assertFalse($this->container->getDefinition('api_platform.jsonapi.normalizer.item')->getArgument(13)); + $this->assertFalse($this->container->getDefinition('api_platform.jsonapi.denormalizer.item')->getArgument(12)); + } + + public function testSettingUseIriAsIdToTrueDoesNotDeprecateAndResolvesToTrue(): void + { + (new ApiPlatformExtension())->load($this->buildConfig(['use_iri_as_id' => true]), $this->container); + + $this->assertTrue($this->container->getDefinition('api_platform.jsonapi.normalizer.item')->getArgument(13)); + $this->assertTrue($this->container->getDefinition('api_platform.jsonapi.denormalizer.item')->getArgument(12)); + } + + private function buildConfig(?array $jsonapi = null): array + { + $config = ['api_platform' => [ + 'title' => 'title', + 'description' => 'description', + 'version' => 'version', + 'enable_json_streamer' => false, + 'serializer' => ['hydra_prefix' => true], + 'formats' => [ + 'json' => ['mime_types' => ['json']], + 'jsonld' => ['mime_types' => ['application/ld+json']], + 'jsonapi' => ['mime_types' => ['application/vnd.api+json']], + ], + 'doctrine_mongodb_odm' => [ + 'enabled' => true, + ], + 'defaults' => [ + 'extra_properties' => [], + 'url_generation_strategy' => UrlGeneratorInterface::ABS_URL, + ], + 'error_formats' => [ + 'jsonproblem' => ['application/problem+json'], + 'jsonld' => ['application/ld+json'], + ], + 'patch_formats' => [], + 'exception_to_status' => [ + ExceptionInterface::class => Response::HTTP_BAD_REQUEST, + InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, + OptimisticLockException::class => Response::HTTP_CONFLICT, + ], + 'show_webby' => true, + 'eager_loading' => [ + 'enabled' => true, + 'max_joins' => 30, + 'force_eager' => true, + 'fetch_partial' => false, + ], + 'asset_package' => null, + 'enable_entrypoint' => true, + 'enable_docs' => true, + 'enable_swagger' => true, + 'enable_swagger_ui' => true, + 'use_symfony_listeners' => false, + ]]; + + if (null !== $jsonapi) { + $config['api_platform']['jsonapi'] = $jsonapi; + } + + return $config; + } +} diff --git a/src/Symfony/Tests/Security/Exception/AccessDeniedExceptionTest.php b/src/Symfony/Tests/Security/Exception/AccessDeniedExceptionTest.php new file mode 100644 index 00000000000..f10d0fa3c85 --- /dev/null +++ b/src/Symfony/Tests/Security/Exception/AccessDeniedExceptionTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Symfony\Security\Exception; + +use ApiPlatform\Symfony\Security\Exception\AccessDeniedException; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\TestCase; + +class AccessDeniedExceptionTest extends TestCase +{ + #[IgnoreDeprecations] + public function testInstantiationTriggersDeprecation(): void + { + $this->expectUserDeprecationMessage('Since api-platform/core 4.4: The "ApiPlatform\Symfony\Security\Exception\AccessDeniedException" class is deprecated, use "ApiPlatform\Metadata\Exception\AccessDeniedException" instead.'); + + new AccessDeniedException(); + } + + #[IgnoreDeprecations] + public function testKeepsBaseExceptionBehavior(): void + { + $previous = new \RuntimeException('previous'); + $exception = new AccessDeniedException('Custom message', $previous, 403); + + $this->assertSame('Custom message', $exception->getMessage()); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame(403, $exception->getStatusCode()); + $this->assertSame([], $exception->getHeaders()); + } +} diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php index 9fd2a6f7536..d79d69fe6e9 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php @@ -16,10 +16,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaChoiceRestriction; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Choice; @@ -39,32 +37,6 @@ protected function setUp(): void $this->propertySchemaChoiceRestriction = new PropertySchemaChoiceRestriction(); } - #[IgnoreDeprecations] - public function testSupports(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'supported string' => [new Choice(choices: ['a', 'b']), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), true], - 'supported int' => [new Choice(choices: [1, 2]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'supported float' => [new Choice(choices: [1.1, 2.2]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported string/int/float with union types' => [new Choice(choices: [1, 2, 1.1, 2.2, 'a', 'b']), (new ApiProperty())->withBuiltinTypes([ - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_STRING), - ]), true], - - 'not supported constraint' => [new Positive(), new ApiProperty(), false], - 'not supported type' => [new Choice(choices: [new \stdClass(), new \stdClass()]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT)]), false], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaChoiceRestriction->supports($constraint, $propertyMetadata)); - } - } - #[DataProvider('supportsNativeProvider')] public function testSupportsNative(Constraint $constraint, ApiProperty $propertyMetadata, bool $expectedResult): void { @@ -84,67 +56,6 @@ public static function supportsNativeProvider(): \Generator yield 'not supported type' => [new Choice(choices: [new \stdClass(), new \stdClass()]), (new ApiProperty())->withNativeType(Type::object()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'single string choice' => [new Choice(choices: ['a', 'b']), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['enum' => ['a', 'b']]], - 'multi string choice' => [new Choice(choices: ['a', 'b'], multiple: true), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b']]]], - 'multi string choice min' => [new Choice(choices: ['a', 'b'], multiple: true, min: 2), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b']], 'minItems' => 2]], - 'multi string choice max' => [new Choice(choices: ['a', 'b', 'c', 'd'], multiple: true, max: 4), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'maxItems' => 4]], - 'multi string choice min/max' => [new Choice(choices: ['a', 'b', 'c', 'd'], multiple: true, min: 2, max: 4), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2, 'maxItems' => 4]], - - 'single int choice' => [new Choice(choices: [1, 2]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['enum' => [1, 2]]], - 'multi int choice' => [new Choice(choices: [1, 2], multiple: true), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2]]]], - 'multi int choice min' => [new Choice(choices: [1, 2], multiple: true, min: 2), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2]], 'minItems' => 2]], - 'multi int choice max' => [new Choice(choices: [1, 2, 3, 4], multiple: true, max: 4), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2, 3, 4]], 'maxItems' => 4]], - 'multi int choice min/max' => [new Choice(choices: [1, 2, 3, 4], multiple: true, min: 2, max: 4), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2, 3, 4]], 'minItems' => 2, 'maxItems' => 4]], - - 'single float choice' => [new Choice(choices: [1.1, 2.2]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['enum' => [1.1, 2.2]]], - 'multi float choice' => [new Choice(choices: [1.1, 2.2], multiple: true), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2]]]], - 'multi float choice min' => [new Choice(choices: [1.1, 2.2], multiple: true, min: 2), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2]], 'minItems' => 2]], - 'multi float choice max' => [new Choice(choices: [1.1, 2.2, 3.3, 4.4], multiple: true, max: 4), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2, 3.3, 4.4]], 'maxItems' => 4]], - 'multi float choice min/max' => [new Choice(choices: [1.1, 2.2, 3.3, 4.4], multiple: true, min: 2, max: 4), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2, 3.3, 4.4]], 'minItems' => 2, 'maxItems' => 4]], - - 'single string/int/float choice with union types' => [new Choice(choices: [1, 2, 'a', 'b', 1.1, 2.2]), (new ApiProperty())->withBuiltinTypes([ - new LegacyType(LegacyType::BUILTIN_TYPE_STRING), - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), - ]), ['enum' => [1, 2, 'a', 'b', 1.1, 2.2]]], - 'multi string/int/float choice with union types' => [new Choice(choices: [1, 2, 'a', 'b', 1.1, 2.2], multiple: true), (new ApiProperty())->withBuiltinTypes([ - new LegacyType(LegacyType::BUILTIN_TYPE_STRING), - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), - ]), ['type' => 'array', 'items' => ['type' => ['number', 'string'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2]]]], - 'multi string/int/float choice min with union types' => [new Choice(choices: [1, 2, 'a', 'b', 1.1, 2.2], multiple: true, min: 2), (new ApiProperty())->withBuiltinTypes([ - new LegacyType(LegacyType::BUILTIN_TYPE_STRING), - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), - ]), ['type' => 'array', 'items' => ['type' => ['number', 'string'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2]], 'minItems' => 2]], - 'multi string/int/float choice max with union types' => [new Choice(choices: [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4], multiple: true, max: 4), (new ApiProperty())->withBuiltinTypes([ - new LegacyType(LegacyType::BUILTIN_TYPE_STRING), - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), - ]), ['type' => 'array', 'items' => ['type' => ['number', 'string'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4]], 'maxItems' => 4]], - 'multi string/int/float choice min/max with union types' => [new Choice(choices: [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4], multiple: true, min: 2, max: 4), (new ApiProperty())->withBuiltinTypes([ - new LegacyType(LegacyType::BUILTIN_TYPE_STRING), - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), - ]), ['type' => 'array', 'items' => ['type' => ['number', 'string'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4]], 'minItems' => 2, 'maxItems' => 4]], - - 'single choice callback' => [new Choice(callback: ChoiceCallback::getChoices(...)), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['enum' => ['a', 'b', 'c', 'd']]], - 'multi choice callback' => [new Choice(callback: ChoiceCallback::getChoices(...), multiple: true), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']]]], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaChoiceRestriction->create($constraint, $propertyMetadata)); - } - } - #[DataProvider('createNativeProvider')] public function testCreateNative(Choice $constraint, ApiProperty $propertyMetadata, array $expectedResult): void { diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php index 257ef139707..8a1505ced29 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php @@ -16,10 +16,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaGreaterThanOrEqualRestriction; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; @@ -40,27 +38,6 @@ protected function setUp(): void $this->propertySchemaGreaterThanOrEqualRestriction = new PropertySchemaGreaterThanOrEqualRestriction(); } - #[IgnoreDeprecations] - public function testSupports(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'supported int/float with union types' => [new GreaterThanOrEqual(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported int' => [new GreaterThanOrEqual(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'supported float' => [new GreaterThanOrEqual(value: 10.99), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported positive or zero' => [new PositiveOrZero(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'not supported positive' => [new Positive(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - 'not supported property path' => [new GreaterThanOrEqual(propertyPath: 'greaterThanMe'), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaGreaterThanOrEqualRestriction->supports($constraint, $propertyMetadata)); - } - } - #[DataProvider('supportsProviderWithNativeType')] public function testSupportsWithNativeType(Constraint $constraint, ApiProperty $propertyMetadata, bool $expectedResult): void { @@ -77,16 +54,6 @@ public static function supportsProviderWithNativeType(): \Generator yield 'native type: not supported property path' => [new GreaterThanOrEqual(propertyPath: 'greaterThanMe'), (new ApiProperty())->withNativeType(Type::int()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - self::assertEquals(['minimum' => 10], $this->propertySchemaGreaterThanOrEqualRestriction->create(new GreaterThanOrEqual(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]))); - } - public function testCreateWithNativeType(): void { self::assertEquals(['minimum' => 10], $this->propertySchemaGreaterThanOrEqualRestriction->create(new GreaterThanOrEqual(value: 10), (new ApiProperty())->withNativeType(Type::int()))); diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php index 591af26ff52..ba5f9c400b3 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php @@ -16,10 +16,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaGreaterThanRestriction; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\GreaterThan; @@ -40,27 +38,6 @@ protected function setUp(): void $this->propertySchemaGreaterThanRestriction = new PropertySchemaGreaterThanRestriction(); } - #[IgnoreDeprecations] - public function testSupports(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'supported int/float with union types' => [new GreaterThan(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported int' => [new GreaterThan(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'supported float' => [new GreaterThan(value: 10.99), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported positive' => [new Positive(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'not supported positive or zero' => [new PositiveOrZero(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - 'not supported property path' => [new GreaterThan(propertyPath: 'greaterThanMe'), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaGreaterThanRestriction->supports($constraint, $propertyMetadata)); - } - } - #[DataProvider('supportsProviderWithNativeType')] public function testSupportsWithNativeType(Constraint $constraint, ApiProperty $propertyMetadata, bool $expectedResult): void { @@ -77,19 +54,6 @@ public static function supportsProviderWithNativeType(): \Generator yield 'native type: not supported property path' => [new GreaterThan(propertyPath: 'greaterThanMe'), (new ApiProperty())->withNativeType(Type::int()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - self::assertEquals([ - 'exclusiveMinimum' => 10, - 'minimum' => 10, - ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThan(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]))); - } - public function testCreateWithNativeType(): void { self::assertEquals([ diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php index d50ee64b01f..86bca89718f 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php @@ -16,10 +16,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLessThanOrEqualRestriction; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\LessThanOrEqual; @@ -40,27 +38,6 @@ protected function setUp(): void $this->propertySchemaLessThanOrEqualRestriction = new PropertySchemaLessThanOrEqualRestriction(); } - #[IgnoreDeprecations] - public function testSupports(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'supported int/float with union types' => [new LessThanOrEqual(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported int' => [new LessThanOrEqual(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'supported float' => [new LessThanOrEqual(value: 10.99), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported negative or zero' => [new NegativeOrZero(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'not supported negative' => [new Negative(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - 'not supported property path' => [new LessThanOrEqual(propertyPath: 'greaterThanMe'), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaLessThanOrEqualRestriction->supports($constraint, $propertyMetadata)); - } - } - #[DataProvider('supportsProviderWithNativeType')] public function testSupportsWithNativeType(Constraint $constraint, ApiProperty $propertyMetadata, bool $expectedResult): void { @@ -77,16 +54,6 @@ public static function supportsProviderWithNativeType(): \Generator yield 'native type: not supported property path' => [new LessThanOrEqual(propertyPath: 'greaterThanMe'), (new ApiProperty())->withNativeType(Type::int()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - self::assertEquals(['maximum' => 10], $this->propertySchemaLessThanOrEqualRestriction->create(new LessThanOrEqual(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]))); - } - public function testCreateWithNativeType(): void { self::assertEquals(['maximum' => 10], $this->propertySchemaLessThanOrEqualRestriction->create(new LessThanOrEqual(value: 10), (new ApiProperty())->withNativeType(Type::int()))); diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php index ca403b15687..42b0d9b4798 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php @@ -16,10 +16,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLessThanRestriction; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\LessThan; @@ -40,27 +38,6 @@ protected function setUp(): void $this->propertySchemaLessThanRestriction = new PropertySchemaLessThanRestriction(); } - #[IgnoreDeprecations] - public function testSupports(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'supported int/float with union types' => [new LessThan(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported int' => [new LessThan(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'supported float' => [new LessThan(value: 10.99), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported negative' => [new Negative(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'not supported negative or zero' => [new NegativeOrZero(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - 'not supported property path' => [new LessThan(propertyPath: 'greaterThanMe'), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaLessThanRestriction->supports($constraint, $propertyMetadata)); - } - } - #[DataProvider('supportsProviderWithNativeType')] public function testSupportsWithNativeType(Constraint $constraint, ApiProperty $propertyMetadata, bool $expectedResult): void { @@ -77,19 +54,6 @@ public static function supportsProviderWithNativeType(): \Generator yield 'native type: not supported property path' => [new LessThan(propertyPath: 'greaterThanMe'), (new ApiProperty())->withNativeType(Type::int()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - self::assertEquals([ - 'exclusiveMaximum' => 10, - 'maximum' => 10, - ], $this->propertySchemaLessThanRestriction->create(new LessThan(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]))); - } - public function testCreateWithNativeType(): void { self::assertEquals([ diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaOneOfRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaOneOfRestrictionTest.php index b5b219aeda2..41cda4771c1 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaOneOfRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaOneOfRestrictionTest.php @@ -21,7 +21,6 @@ use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\AtLeastOneOf; @@ -80,25 +79,6 @@ public static function supportsProviderWithNativeType(): \Generator yield 'native type: not supported' => [new Positive(), (new ApiProperty())->withNativeType(Type::mixed()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'not supported constraints' => [new AtLeastOneOf([new Positive(), new Length(min: 3)]), new ApiProperty(), []], - 'one supported constraint' => [new AtLeastOneOf([new Positive(), new Length(min: 3)]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), [ - 'oneOf' => [['minLength' => 3]], - ]], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaOneOfRestriction->create($constraint, $propertyMetadata)); - } - } - #[DataProvider('createProviderWithNativeType')] public function testCreateWithNativeType(AtLeastOneOf $constraint, ApiProperty $propertyMetadata, array $expectedResult): void { diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php index b6c7325329f..d98d556343d 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php @@ -16,10 +16,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRangeRestriction; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Length; @@ -39,27 +37,6 @@ protected function setUp(): void $this->propertySchemaRangeRestriction = new PropertySchemaRangeRestriction(); } - #[IgnoreDeprecations] - public function testSupports(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'supported int/float with union types' => [new Range(min: 1, max: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported int' => [new Range(min: 1, max: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'supported float' => [new Range(min: 1, max: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - - 'not supported constraint' => [new Length(min: 1), new ApiProperty(), false], - 'not supported type' => [new Range(min: 1), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), false], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaRangeRestriction->supports($constraint, $propertyMetadata)); - } - } - #[DataProvider('supportsProviderWithNativeType')] public function testSupportsWithNativeType(Constraint $constraint, ApiProperty $propertyMetadata, bool $expectedResult): void { @@ -76,28 +53,6 @@ public static function supportsProviderWithNativeType(): \Generator yield 'native type: not supported type' => [new Range(min: 1), (new ApiProperty())->withNativeType(Type::string()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'int min' => [new Range(min: 1), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['minimum' => 1]], - 'int max' => [new Range(max: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['maximum' => 10]], - 'int min max' => [new Range(min: 1, max: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['minimum' => 1, 'maximum' => 10]], - - 'float min' => [new Range(min: 1.5), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['minimum' => 1.5]], - 'float max' => [new Range(max: 10.5), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['maximum' => 10.5]], - 'float min max' => [new Range(min: 1.5, max: 10.5), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['minimum' => 1.5, 'maximum' => 10.5]], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaRangeRestriction->create($constraint, $propertyMetadata)); - } - } - #[DataProvider('createProviderWithNativeType')] public function testCreateWithNativeType(Range $constraint, ApiProperty $propertyMetadata, array $expectedResult): void { diff --git a/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 9b743e9e015..2aa1abf35dd 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -45,10 +45,8 @@ use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaUniqueRestriction; use ApiPlatform\Symfony\Validator\Metadata\Property\ValidatorPropertyMetadataFactory; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraints\GroupSequence; use Symfony\Component\Validator\Constraints\Hostname; @@ -561,46 +559,6 @@ public function testCreateWithPropertyUniqueRestriction(): void $this->assertEquals(['uniqueItems' => true], $schema); } - #[IgnoreDeprecations] - public function testLegacyCreateWithRangeConstraint(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'min int' => ['type' => new LegacyType(LegacyType::BUILTIN_TYPE_INT), 'property' => 'dummyIntMin', 'expectedSchema' => ['minimum' => 1]], - 'max int' => ['type' => new LegacyType(LegacyType::BUILTIN_TYPE_INT), 'property' => 'dummyIntMax', 'expectedSchema' => ['maximum' => 10]], - 'min/max int' => ['type' => new LegacyType(LegacyType::BUILTIN_TYPE_INT), 'property' => 'dummyIntMinMax', 'expectedSchema' => ['minimum' => 1, 'maximum' => 10]], - 'min float' => ['type' => new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMin', 'expectedSchema' => ['minimum' => 1.5]], - 'max float' => ['type' => new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMax', 'expectedSchema' => ['maximum' => 10.5]], - 'min/max float' => ['type' => new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMinMax', 'expectedSchema' => ['minimum' => 1.5, 'maximum' => 10.5]], - ]; - - foreach ($cases as ['type' => $type, 'property' => $property, 'expectedSchema' => $expectedSchema]) { - $validatorClassMetadata = new ClassMetadata(DummyRangeValidatedEntity::class); - (new AttributeLoader())->loadClassMetadata($validatorClassMetadata); - - $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); - $validatorMetadataFactory->getMetadataFor(DummyRangeValidatedEntity::class) - ->willReturn($validatorClassMetadata) - ->shouldBeCalled(); - - $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $decoratedPropertyMetadataFactory->create(DummyRangeValidatedEntity::class, $property, [])->willReturn( - (new ApiProperty())->withBuiltinTypes([$type]) - )->shouldBeCalled(); - $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( - $validatorMetadataFactory->reveal(), - $decoratedPropertyMetadataFactory->reveal(), - [new PropertySchemaRangeRestriction()] - ); - $schema = $validationPropertyMetadataFactory->create(DummyRangeValidatedEntity::class, $property)->getSchema(); - - $this->assertEquals($expectedSchema, $schema); - } - } - #[DataProvider('provideRangeConstraintCasesWithNativeType')] public function testCreateWithRangeConstraintWithNativeType(Type $type, string $property, array $expectedSchema): void // Use new Type { @@ -636,49 +594,6 @@ public static function provideRangeConstraintCasesWithNativeType(): \Generator yield 'native type: min/max float' => ['type' => Type::float(), 'property' => 'dummyFloatMinMax', 'expectedSchema' => ['minimum' => 1.5, 'maximum' => 10.5]]; } - #[IgnoreDeprecations] - public function testCreateWithPropertyChoiceRestriction(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'single choice' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummySingleChoice', 'expectedSchema' => ['enum' => ['a', 'b']]], - 'single choice callback' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummySingleChoiceCallback', 'expectedSchema' => ['enum' => ['a', 'b', 'c', 'd']]], - 'multi choice' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummyMultiChoice', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b']]]], - 'multi choice callback' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummyMultiChoiceCallback', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']]]], - 'multi choice min' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummyMultiChoiceMin', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2]], - 'multi choice max' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummyMultiChoiceMax', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'maxItems' => 4]], - 'multi choice min/max' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummyMultiChoiceMinMax', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2, 'maxItems' => 4]], - ]; - - foreach ($cases as ['propertyMetadata' => $propertyMetadata, 'property' => $property, 'expectedSchema' => $expectedSchema]) { - $validatorClassMetadata = new ClassMetadata(DummyValidatedChoiceEntity::class); - (new AttributeLoader())->loadClassMetadata($validatorClassMetadata); - - $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); - $validatorMetadataFactory->getMetadataFor(DummyValidatedChoiceEntity::class) - ->willReturn($validatorClassMetadata) - ->shouldBeCalled(); - - $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $decoratedPropertyMetadataFactory->create(DummyValidatedChoiceEntity::class, $property, [])->willReturn( - $propertyMetadata - )->shouldBeCalled(); - - $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( - $validatorMetadataFactory->reveal(), - $decoratedPropertyMetadataFactory->reveal(), - [new PropertySchemaChoiceRestriction()] - ); - - $schema = $validationPropertyMetadataFactory->create(DummyValidatedChoiceEntity::class, $property)->getSchema(); - - $this->assertEquals($expectedSchema, $schema); - } - } - #[DataProvider('provideChoiceConstraintCasesWithNativeType')] public function testCreateWithPropertyChoiceRestrictionWithNativeType(ApiProperty $propertyMetadata, string $property, array $expectedSchema): void { @@ -821,87 +736,6 @@ public function testCreateWithPropertyCollectionRestriction(): void ], $schema); } - #[IgnoreDeprecations] - public function testCreateWithPropertyNumericRestriction(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), - 'property' => 'greaterThanMe', - 'expectedSchema' => ['exclusiveMinimum' => 10, 'minimum' => 10], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), - 'property' => 'greaterThanOrEqualToMe', - 'expectedSchema' => ['minimum' => 10.99], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), - 'property' => 'lessThanMe', - 'expectedSchema' => ['exclusiveMaximum' => 99, 'maximum' => 99], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), - 'property' => 'lessThanOrEqualToMe', - 'expectedSchema' => ['maximum' => 99.33], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), - 'property' => 'positive', - 'expectedSchema' => ['exclusiveMinimum' => 0, 'minimum' => 0], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), - 'property' => 'positiveOrZero', - 'expectedSchema' => ['minimum' => 0], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), - 'property' => 'negative', - 'expectedSchema' => ['exclusiveMaximum' => 0, 'maximum' => 0], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), - 'property' => 'negativeOrZero', - 'expectedSchema' => ['maximum' => 0], - ], - ]; - - foreach ($cases as ['propertyMetadata' => $propertyMetadata, 'property' => $property, 'expectedSchema' => $expectedSchema]) { - $validatorClassMetadata = new ClassMetadata(DummyNumericValidatedEntity::class); - (new AttributeLoader())->loadClassMetadata($validatorClassMetadata); - - $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); - $validatorMetadataFactory->getMetadataFor(DummyNumericValidatedEntity::class) - ->willReturn($validatorClassMetadata) - ->shouldBeCalled(); - - $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $decoratedPropertyMetadataFactory->create(DummyNumericValidatedEntity::class, $property, [])->willReturn( - $propertyMetadata - )->shouldBeCalled(); - - $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( - $validatorMetadataFactory->reveal(), - $decoratedPropertyMetadataFactory->reveal(), - [ - new PropertySchemaGreaterThanOrEqualRestriction(), - new PropertySchemaGreaterThanRestriction(), - new PropertySchemaLessThanOrEqualRestriction(), - new PropertySchemaLessThanRestriction(), - ] - ); - - $schema = $validationPropertyMetadataFactory->create(DummyNumericValidatedEntity::class, $property)->getSchema(); - - $this->assertEquals($expectedSchema, $schema); - } - } - #[DataProvider('provideNumericConstraintCasesWithNativeType')] public function testCreateWithPropertyNumericRestrictionWithNativeType(ApiProperty $propertyMetadata, string $property, array $expectedSchema): void { diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php index f23b20a5d8a..7a1e0d58c1f 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php @@ -15,8 +15,6 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Util\TypeHelper; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\TypeIdentifier; @@ -84,41 +82,24 @@ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): return false; } - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $nativeType = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false - ? Type::string() - : $propertyMetadata->getNativeType(); + $nativeType = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false + ? Type::string() + : $propertyMetadata->getNativeType(); - $isValidScalarType = static fn (Type $t): bool => $t->isSatisfiedBy( - static fn (Type $subType): bool => $subType->isIdentifiedBy(TypeIdentifier::STRING, TypeIdentifier::INT, TypeIdentifier::FLOAT) - ); + $isValidScalarType = static fn (Type $t): bool => $t->isSatisfiedBy( + static fn (Type $subType): bool => $subType->isIdentifiedBy(TypeIdentifier::STRING, TypeIdentifier::INT, TypeIdentifier::FLOAT) + ); - if ($isValidScalarType($nativeType)) { - return true; - } - - if ($nativeType->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { - if (null !== ($collectionValueType = TypeHelper::getCollectionValueType($nativeType)) && $isValidScalarType($collectionValueType)) { - return true; - } - } - - return false; - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_STRING]; + if ($isValidScalarType($nativeType)) { + return true; } - if ( - null !== ($builtinType = ($propertyMetadata->getBuiltinTypes()[0] ?? null)) - && $builtinType->isCollection() - && \count($builtinType->getCollectionValueTypes()) > 0 - ) { - $types = array_unique(array_merge($types, array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $builtinType->getCollectionValueTypes()))); + if ($nativeType->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { + if (null !== ($collectionValueType = TypeHelper::getCollectionValueType($nativeType)) && $isValidScalarType($collectionValueType)) { + return true; + } } - return \count($types) > 0 && \count(array_intersect($types, [LegacyType::BUILTIN_TYPE_STRING, LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT])) > 0; + return false; } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php index 7d8c9d05428..d9251a1141f 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Symfony\Validator\Metadata\Property\Restriction; use ApiPlatform\Metadata\ApiProperty; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraint; @@ -44,19 +42,10 @@ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): return false; } - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false - ? Type::int() - : $propertyMetadata->getNativeType(); + $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false + ? Type::int() + : $propertyMetadata->getNativeType(); - return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_INT]; - } - - return \count($types) > 0 && \count(array_intersect($types, [LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT])) > 0; + return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php index 0e33eca2ea7..1d1d95800a1 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Symfony\Validator\Metadata\Property\Restriction; use ApiPlatform\Metadata\ApiProperty; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraint; @@ -48,19 +46,10 @@ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): return false; } - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false - ? Type::int() - : $propertyMetadata->getNativeType(); + $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false + ? Type::int() + : $propertyMetadata->getNativeType(); - return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_INT]; - } - - return \count($types) && array_intersect($types, [LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT]); + return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php index 07b2f76d588..d3b22652189 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Symfony\Validator\Metadata\Property\Restriction; use ApiPlatform\Metadata\ApiProperty; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraint; @@ -53,17 +51,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false ? Type::string() : $propertyMetadata->getNativeType(); + $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false ? Type::string() : $propertyMetadata->getNativeType(); - return $constraint instanceof Length && $type?->isIdentifiedBy(TypeIdentifier::STRING); - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_STRING]; - } - - return $constraint instanceof Length && \count($types) && \in_array(LegacyType::BUILTIN_TYPE_STRING, $types, true); + return $constraint instanceof Length && $type?->isIdentifiedBy(TypeIdentifier::STRING); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php index f1141818a07..bde2f7d8045 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Symfony\Validator\Metadata\Property\Restriction; use ApiPlatform\Metadata\ApiProperty; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraint; @@ -47,19 +45,10 @@ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): return false; } - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false - ? Type::int() - : $propertyMetadata->getNativeType(); + $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false + ? Type::int() + : $propertyMetadata->getNativeType(); - return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_INT]; - } - - return \count($types) > 0 && \count(array_intersect($types, [LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT])) > 0; + return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php index 7af4d9f7567..4a072ff99f9 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Symfony\Validator\Metadata\Property\Restriction; use ApiPlatform\Metadata\ApiProperty; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraint; @@ -45,19 +43,10 @@ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): return false; } - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false - ? Type::int() - : $propertyMetadata->getNativeType(); + $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false + ? Type::int() + : $propertyMetadata->getNativeType(); - return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_INT]; - } - - return \count($types) > 0 && \count(array_intersect($types, [LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT])) > 0; + return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php index 833af136c6d..a03c1c52d0b 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Symfony\Validator\Metadata\Property\Restriction; use ApiPlatform\Metadata\ApiProperty; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraint; @@ -55,19 +53,10 @@ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): return false; } - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false - ? Type::int() - : $propertyMetadata->getNativeType(); + $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false + ? Type::int() + : $propertyMetadata->getNativeType(); - return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_INT]; - } - - return \count($types) > 0 && \count(array_intersect($types, [LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT])) > 0; + return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); } } diff --git a/src/Symfony/Validator/State/ParameterValidatorProvider.php b/src/Symfony/Validator/State/ParameterValidatorProvider.php index b814f378dd8..7ce17b5f605 100644 --- a/src/Symfony/Validator/State/ParameterValidatorProvider.php +++ b/src/Symfony/Validator/State/ParameterValidatorProvider.php @@ -88,9 +88,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $violation->getInvalidValue(), $violation->getPlural(), $violation->getCode(), - // TODO: remove these with symfony ^7 - method_exists($violation, 'getConstraint') ? $violation->getConstraint() : null, // @phpstan-ignore-line symfony/validator 6.4 is still allowed and this may be true - method_exists($violation, 'getCause') ? $violation->getCause() : null // @phpstan-ignore-line symfony/validator 6.4 is still allowed and this may be true + $violation->getConstraint(), + $violation->getCause() )); } } diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index e7dab8da52f..b920fa082bb 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -29,41 +29,42 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.3", - "api-platform/http-cache": "^4.3", - "api-platform/json-schema": "^4.3", - "api-platform/jsonld": "^4.3", - "api-platform/hydra": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3.12", - "api-platform/state": "^4.3", - "api-platform/validator": "^4.3.1", - "api-platform/openapi": "^4.3", - "symfony/asset": "^6.4 || ^7.0 || ^8.0", - "symfony/finder": "^6.4 || ^7.0 || ^8.0", - "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/security-core": "^6.4 || ^7.0 || ^8.0", + "api-platform/documentation": "^5.0@alpha", + "api-platform/http-cache": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/jsonld": "^5.0@alpha", + "api-platform/hydra": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", + "api-platform/validator": "^5.0@alpha", + "api-platform/openapi": "^5.0@alpha", + "symfony/asset": "^7.4 || ^8.0", + "symfony/finder": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/security-core": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, "require-dev": { - "api-platform/doctrine-common": "^4.3", - "api-platform/doctrine-odm": "^4.3", - "api-platform/doctrine-orm": "^4.3", - "api-platform/elasticsearch": "^4.3", - "api-platform/graphql": "^4.3", - "api-platform/hal": "^4.3", + "api-platform/doctrine-common": "^5.0@alpha", + "api-platform/doctrine-odm": "^5.0@alpha", + "api-platform/doctrine-orm": "^5.0@alpha", + "api-platform/elasticsearch": "^5.0@alpha", + "api-platform/graphql": "^5.0@alpha", + "api-platform/hal": "^5.0@alpha", + "api-platform/json-api": "^5.0@alpha", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", - "symfony/intl": "^6.4 || ^7.0 || ^8.0", + "symfony/expression-language": "^7.4 || ^8.0", + "symfony/intl": "^7.4 || ^8.0", "symfony/mercure-bundle": "^0.4.3|^0.5", - "symfony/object-mapper": "^7.0 || ^8.0", - "symfony/routing": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", + "symfony/object-mapper": "^7.4 || ^8.0", + "symfony/routing": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", "webonyx/graphql-php": "^15.0" }, "suggest": { @@ -108,10 +109,10 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev" + "dev-main": "5.0.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Validator/DenormalizationViolationFactory.php b/src/Validator/DenormalizationViolationFactory.php new file mode 100644 index 00000000000..2296c38ed9c --- /dev/null +++ b/src/Validator/DenormalizationViolationFactory.php @@ -0,0 +1,248 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Validator; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; +use ApiPlatform\Validator\Exception\ValidationException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationInterface; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Exception\NoSuchMetadataException; +use Symfony\Component\Validator\Mapping\ClassMetadataInterface; +use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; +use Symfony\Contracts\Translation\LocaleAwareInterface; +use Symfony\Contracts\Translation\TranslatorInterface; +use Symfony\Contracts\Translation\TranslatorTrait; + +/** + * Constraint-aware denormalization violation factory — Symfony Validator flavor. + * + * Rule table (see issue #7981): + * + * | Exception "current type" | Matching constraint | Emitted violation | + * |--------------------------|----------------------|---------------------------------------------------| + * | null | NotBlank | NotBlank::IS_BLANK_ERROR + constraint message | + * | null | NotNull | NotNull::IS_NULL_ERROR + constraint message | + * | any wrong type | Type | Type::INVALID_TYPE_ERROR + constraint message | + * | any wrong type | any other constraint | generic Type violation @ 422 | + * | any wrong type | (no constraint) | none — single-error path rethrows → 400 | + * + * In collect mode (PartialDenormalizationException), unconstrained errors still emit + * a generic Type violation so the response stays consistent with prior behavior. + * + * @author Antoine Bluchet + */ +final class DenormalizationViolationFactory implements DenormalizationViolationFactoryInterface +{ + private TranslatorInterface $translator; + + public function __construct( + private readonly MetadataFactoryInterface $metadataFactory, + ?TranslatorInterface $translator = null, + ) { + if (null === $translator) { + $translator = new class implements TranslatorInterface, LocaleAwareInterface { + use TranslatorTrait; + }; + $translator->setLocale('en'); + } + + $this->translator = $translator; + } + + public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void + { + if ($exception instanceof NotNormalizableValueException) { + $violation = $this->buildViolation($exception, $operation); + if (null === $violation) { + return; + } + + throw new ValidationException(new ConstraintViolationList([$violation])); + } + + $violations = new ConstraintViolationList(); + $errors = method_exists($exception, 'getNotNormalizableValueErrors') ? $exception->getNotNormalizableValueErrors() : $exception->getErrors(); + foreach ($errors as $error) { + if (!$error instanceof NotNormalizableValueException) { + continue; + } + $violations->add($this->buildViolation($error, $operation) ?? $this->buildViolation($error, $operation, true)); + } + + if (\count($violations) > 0) { + throw new ValidationException($violations); + } + } + + /** + * Returns a violation for the given error. + * + * When `$generic` is true, emits a Type-based fallback regardless of property metadata + * (used in collect mode to keep one violation per error). When false, returns null if + * no matching constraint is declared on the property — caller rethrows. + */ + private function buildViolation(NotNormalizableValueException $exception, Operation $operation, bool $generic = false): ?ConstraintViolationInterface + { + $path = $exception->getPath(); + if (null === $path || '' === $path) { + return $generic ? $this->emitViolation($exception, null, (string) Type::INVALID_TYPE_ERROR) : null; + } + + if ($generic) { + return $this->emitViolation($exception, null, (string) Type::INVALID_TYPE_ERROR); + } + + $class = $operation->getClass(); + if (null === $class || (!class_exists($class) && !interface_exists($class))) { + return null; + } + + try { + $classMetadata = $this->metadataFactory->getMetadataFor($class); + } catch (NoSuchMetadataException) { + return null; + } + + if (!$classMetadata instanceof ClassMetadataInterface || !$classMetadata->hasPropertyMetadata($path)) { + return null; + } + + $validationGroups = ($operation->getValidationContext() ?? [])['groups'] ?? null; + $constraints = $this->collectConstraints($classMetadata, $path, $validationGroups); + if (!$constraints) { + return null; + } + + $isNull = 'null' === strtolower((string) $exception->getCurrentType()); + + if ($isNull) { + if (isset($constraints[NotBlank::class])) { + return $this->emitViolation($exception, $constraints[NotBlank::class], (string) NotBlank::IS_BLANK_ERROR); + } + if (isset($constraints[NotNull::class])) { + return $this->emitViolation($exception, $constraints[NotNull::class], (string) NotNull::IS_NULL_ERROR); + } + } + + if (isset($constraints[Type::class])) { + return $this->emitViolation($exception, $constraints[Type::class], (string) Type::INVALID_TYPE_ERROR); + } + + // Property has constraints but none match by class → still 422 with a generic Type message. + return $this->emitViolation($exception, new Type([]), (string) Type::INVALID_TYPE_ERROR); + } + + /** + * @param array|null $validationGroups + * + * @return array, Constraint> indexed by constraint class; later entries overwrite earlier + */ + private function collectConstraints(ClassMetadataInterface $classMetadata, string $property, ?array $validationGroups): array + { + $groups = $validationGroups ?: [Constraint::DEFAULT_GROUP]; + $constraints = []; + + foreach ($classMetadata->getPropertyMetadata($property) as $propertyMetadata) { + foreach ($groups as $group) { + foreach ($propertyMetadata->findConstraints($group) as $constraint) { + $constraints[$constraint::class] = $constraint; + } + } + } + + return $constraints; + } + + private function emitViolation(NotNormalizableValueException $exception, ?Constraint $constraint, string $code): ConstraintViolation + { + $parameters = []; + if ($exception->canUseMessageForUser()) { + $parameters['hint'] = $exception->getMessage(); + } + + $expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes()); + + // No constraint + no expected types + user-friendly message → use the exception message verbatim. + if (null === $constraint && !$expectedTypes && $exception->canUseMessageForUser()) { + $message = $exception->getMessage(); + + return new ConstraintViolation($message, $message, $parameters, null, $exception->getPath(), null, null, $code); + } + + $message = $this->resolveMessage($constraint, $expectedTypes); + $translationParameters = []; + if ($expectedTypes && str_contains($message, '{{ type }}')) { + $translationParameters['{{ type }}'] = implode('|', $expectedTypes); + } + + return new ConstraintViolation( + $this->translator->trans($message, $translationParameters, 'validators'), + $message, + $parameters, + null, + $exception->getPath(), + null, + null, + $code, + $constraint, + ); + } + + /** + * @param string[] $expectedTypes + */ + private function resolveMessage(?Constraint $constraint, array $expectedTypes): string + { + if ($constraint instanceof NotBlank || $constraint instanceof NotNull || $constraint instanceof Type) { + return $constraint->message; + } + + return (new Type($expectedTypes))->message; + } + + /** + * @param string[]|null $expectedTypes + * + * @return string[] + */ + private function normalizeExpectedTypes(?array $expectedTypes): array + { + $normalized = []; + foreach ($expectedTypes ?? [] as $expectedType) { + if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType))) { + // A backed enum is sent over the wire as its backing scalar (e.g. "string"), not as the + // PHP enum class, so report the JSON-visible type rather than the internal FQCN (#8388). + if (is_subclass_of($expectedType, \BackedEnum::class) && ($backingType = (new \ReflectionEnum($expectedType))->getBackingType())) { + $normalized[] = (string) $backingType; + continue; + } + + $pos = strrpos($expectedType, '\\'); + $normalized[] = false === $pos ? $expectedType : substr($expectedType, $pos + 1); + continue; + } + $normalized[] = $expectedType; + } + + return array_values(array_unique($normalized)); + } +} diff --git a/src/Validator/Exception/ValidationException.php b/src/Validator/Exception/ValidationException.php index 19a3d129d54..d1e70899719 100644 --- a/src/Validator/Exception/ValidationException.php +++ b/src/Validator/Exception/ValidationException.php @@ -104,22 +104,11 @@ class ValidationException extends RuntimeException implements ConstraintViolatio protected ?string $errorTitle = null; private ConstraintViolationListInterface $constraintViolationList; - public function __construct(string|ConstraintViolationListInterface $message = new ConstraintViolationList(), string|int|null $code = null, int|\Throwable|null $previous = null, \Throwable|string|null $errorTitle = null) + public function __construct(ConstraintViolationListInterface $message = new ConstraintViolationList(), string|int|null $code = null, int|\Throwable|null $previous = null, \Throwable|string|null $errorTitle = null) { $this->errorTitle = $errorTitle; - - if ($message instanceof ConstraintViolationListInterface) { - $this->constraintViolationList = $message; - parent::__construct($this->__toString(), $code ?? 0, $previous); - $this->detail = $this->getMessage(); - - return; - } - - $this->constraintViolationList = new ConstraintViolationList(); - - trigger_deprecation('api_platform/core', '5.0', \sprintf('The "%s" exception will have a "%s" first argument in 5.x.', self::class, ConstraintViolationListInterface::class)); - parent::__construct($message ?: $this->__toString(), $code ?? 0, $previous); + $this->constraintViolationList = $message; + parent::__construct($this->__toString(), $code ?? 0, $previous); $this->detail = $this->getMessage(); } diff --git a/src/Validator/Tests/DenormalizationViolationFactoryTest.php b/src/Validator/Tests/DenormalizationViolationFactoryTest.php new file mode 100644 index 00000000000..5befc8cd93b --- /dev/null +++ b/src/Validator/Tests/DenormalizationViolationFactoryTest.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Validator\Tests; + +use ApiPlatform\Metadata\Post; +use ApiPlatform\Validator\DenormalizationViolationFactory; +use ApiPlatform\Validator\Exception\ValidationException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\Mapping\Factory\LazyLoadingMetadataFactory; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; + +final class DenormalizationViolationFactoryTest extends TestCase +{ + private DenormalizationViolationFactory $factory; + + protected function setUp(): void + { + $this->factory = new DenormalizationViolationFactory( + new LazyLoadingMetadataFactory(new AttributeLoader()), + ); + } + + public function testNullCurrentTypeWithNotBlankThrowsValidationException(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'name'); + + try { + $this->factory->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $violation = $e->getConstraintViolationList()[0]; + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $violation->getCode()); + $this->assertSame('name', $violation->getPropertyPath()); + } + } + + public function testNullCurrentTypeWithNotNullThrowsValidationException(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'description'); + + try { + $this->factory->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) NotNull::IS_NULL_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + + public function testWrongTypeWithTypeConstraintThrowsValidationException(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'abc', ['float'], 'score'); + + try { + $this->factory->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + + public function testWrongTypeWithOtherConstraintThrowsGenericTypeViolation(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 123, ['string'], 'choice'); + + try { + $this->factory->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + + public function testWrongTypeWithoutConstraintReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'abc', ['float'], 'rawFloat'); + + // Returns without throwing → caller rethrows for 400. + $this->factory->handle($exception, $this->operation()); + $this->expectNotToPerformAssertions(); + } + + public function testUnknownClassReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'name'); + + $this->factory->handle($exception, $this->operation('NotAClass')); + $this->expectNotToPerformAssertions(); + } + + public function testUnknownPropertyReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'missingProperty'); + + $this->factory->handle($exception, $this->operation()); + $this->expectNotToPerformAssertions(); + } + + public function testNestedPathReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'address.street'); + + $this->factory->handle($exception, $this->operation()); + $this->expectNotToPerformAssertions(); + } + + public function testGroupFilteringExcludesConstraintsOutsideActiveGroups(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'adminOnly'); + + // Default group → constraint scoped to "admin" excluded → returns void. + $this->factory->handle($exception, $this->operation()); + + // Active "admin" group → matches. + try { + $this->factory->handle($exception, $this->operation(DenormHandlerFixture::class, ['admin'])); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + + public function testHandlePartialAggregatesAllErrors(): void + { + $errors = [ + NotNormalizableValueException::createForUnexpectedDataType('msg', null, ['string'], 'name'), + NotNormalizableValueException::createForUnexpectedDataType('msg', 'abc', ['float'], 'rawFloat'), + ]; + $partial = new PartialDenormalizationException(null, $errors); + + try { + $this->factory->handle($partial, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertCount(2, $e->getConstraintViolationList()); + $codes = []; + foreach ($e->getConstraintViolationList() as $violation) { + $codes[$violation->getPropertyPath()] = $violation->getCode(); + } + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $codes['name']); + // Unconstrained → generic Type fallback @ INVALID_TYPE_ERROR + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $codes['rawFloat']); + } + } + + /** + * @param array|null $groups + */ + private function operation(string $class = DenormHandlerFixture::class, ?array $groups = null): Post + { + $operation = new Post(class: $class); + if (null !== $groups) { + $operation = $operation->withValidationContext(['groups' => $groups]); + } + + return $operation; + } +} + +class DenormHandlerFixture +{ + #[NotBlank] + public string $name = ''; + + #[NotNull] + public string $description = ''; + + #[Type('numeric')] + public float $score = 0.0; + + #[Assert\Choice(choices: ['a', 'b'])] + public string $choice = 'a'; + + public float $rawFloat = 0.0; + + #[NotBlank(groups: ['admin'])] + public string $adminOnly = ''; +} diff --git a/src/Validator/composer.json b/src/Validator/composer.json index 392e1f5a8bd..44ebaba8f31 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -23,12 +23,13 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "symfony/type-info": "^7.3 || ^8.0", - "symfony/http-kernel": "^6.4.13 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.1 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.1 || ^8.0", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0" + "api-platform/metadata": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", + "symfony/type-info": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", @@ -51,13 +52,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/tests/Fixtures/TestBundle/ApiResource/DenormalizationValidationResource.php b/tests/Fixtures/TestBundle/ApiResource/DenormalizationValidationResource.php new file mode 100644 index 00000000000..43e253d86b2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DenormalizationValidationResource.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new Post( + uriTemplate: '/denormalization_validation_resources', + processor: self::class.'::process', + ), + new Post( + uriTemplate: '/denormalization_validation_resources_collect', + processor: self::class.'::process', + collectDenormalizationErrors: true, + ), + ], +)] +class DenormalizationValidationResource +{ + public int $id = 1; + + #[Assert\NotBlank] + public string $name = ''; + + #[Assert\NotNull] + public string $description = ''; + + #[Assert\Type('numeric')] + public float $score = 0.0; + + public float $rawFloat = 0.0; + + public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/HeadSpyResource.php b/tests/Fixtures/TestBundle/ApiResource/HeadSpyResource.php new file mode 100644 index 00000000000..6c63337130f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/HeadSpyResource.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\State\SpyPaginator; + +#[ApiResource( + shortName: 'HeadSpyResource', + operations: [ + new GetCollection( + uriTemplate: '/head_spy_resources', + provider: [self::class, 'provide'], + ), + new GetCollection( + uriTemplate: '/head_spy_stream_resources', + provider: [self::class, 'provide'], + jsonStream: true, + ), + ], +)] +final class HeadSpyResource +{ + public string $id = ''; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): SpyPaginator + { + return new SpyPaginator(); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7939BarResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue7939BarResource.php new file mode 100644 index 00000000000..485c9e75aee --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7939BarResource.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/issue7939_foos/{fooId}/bars/{id}', + uriVariables: [ + 'fooId' => new Link(fromClass: Issue7939FooResource::class, toProperty: 'foo'), + 'id' => new Link(fromClass: self::class), + ], + provider: [self::class, 'provide'], + ), + ], +)] +final class Issue7939BarResource +{ + private const PARENTS = ['B' => 'F2']; + + public string $id = ''; + public ?Issue7939FooResource $foo = null; + + public static function parentOf(string $barId): ?string + { + return self::PARENTS[$barId] ?? null; + } + + public static function provide(Operation $operation, array $uriVariables = []) + { + $id = (string) ($uriVariables['id'] ?? ''); + $parent = self::parentOf($id); + + if (null === $parent) { + return null; + } + + $bar = new self(); + $bar->id = $id; + $foo = new Issue7939FooResource(); + $foo->id = $parent; + $bar->foo = $foo; + + return $bar; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7939BazResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue7939BazResource.php new file mode 100644 index 00000000000..51f49c569c1 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7939BazResource.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\State\ParameterProvider\ReadLinkParameterProvider; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/issue7939_foos/{fooId}/bars/{barId}/baz', + uriVariables: [ + 'fooId' => new Link(fromClass: Issue7939FooResource::class), + 'barId' => new Link( + fromClass: Issue7939BarResource::class, + identifiers: ['id'], + provider: ReadLinkParameterProvider::class, + ), + ], + provider: [self::class, 'provide'], + ), + new Get( + uriTemplate: '/issue7939_foos/{fooId}/bars/{barId}/baz_strict', + uriVariables: [ + 'fooId' => new Link( + fromClass: Issue7939FooResource::class, + provider: [self::class, 'validateParent'], + ), + 'barId' => new Link( + fromClass: Issue7939BarResource::class, + identifiers: ['id'], + provider: ReadLinkParameterProvider::class, + ), + ], + provider: [self::class, 'provide'], + ), + ], +)] +final class Issue7939BazResource +{ + public string $id = '1'; + public string $barId = ''; + public string $fooId = ''; + + public static function provide(Operation $operation, array $uriVariables = []) + { + $r = new self(); + $r->fooId = (string) ($uriVariables['fooId'] ?? ''); + $r->barId = (string) ($uriVariables['barId'] ?? ''); + + return $r; + } + + public static function validateParent(Parameter $parameter, array $values = [], array $context = []): ?Operation + { + $barId = (string) ($values['barId'] ?? ''); + $fooId = (string) ($values['fooId'] ?? ''); + + if (Issue7939BarResource::parentOf($barId) !== $fooId) { + throw new NotFoundHttpException('Bar does not belong to the requested Foo.'); + } + + return $context['operation'] ?? null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7939FooResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue7939FooResource.php new file mode 100644 index 00000000000..670179b82cd --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7939FooResource.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/issue7939_foos/{id}', + provider: [self::class, 'provide'], + ), + ], +)] +final class Issue7939FooResource +{ + public string $id = ''; + + public static function provide(Operation $operation, array $uriVariables = []) + { + $r = new self(); + $r->id = (string) ($uriVariables['id'] ?? ''); + + return $r; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php index 18cd539f17a..ab545ac6936 100644 --- a/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php @@ -20,6 +20,7 @@ shortName: 'JsonLdContextDummy', provider: [self::class, 'provide'], processor: [self::class, 'process'], + jsonldContext: ['dct' => 'http://purl.org/dc/terms/'], )] class JsonLdContextDummy { @@ -29,6 +30,9 @@ class JsonLdContextDummy #[ApiProperty(iris: ['https://schema.org/name'])] public ?string $name = null; + #[ApiProperty(iris: ['dct:title'])] + public ?string $title = null; + #[ApiProperty(iris: ['https://schema.org/alternateName'])] public ?string $alias = null; diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php index 076f8752819..5719f305a0c 100644 --- a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php @@ -13,26 +13,21 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd; -use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Serializer\Filter\PropertyFilter; use Symfony\Component\Serializer\Attribute\Groups; -#[ApiResource( - shortName: 'JsonLdNonResourceContainer', - normalizationContext: ['groups' => ['jsonld_non_resource']], - operations: [ - new Get( - uriTemplate: '/jsonld_non_resource_containers/{id}', - uriVariables: ['id'], - provider: [self::class, 'provide'], - ), - ], -)] -#[ApiFilter(PropertyFilter::class)] +#[ApiResource(shortName: 'JsonLdNonResourceContainer', normalizationContext: ['groups' => ['jsonld_non_resource']], operations: [ + new Get( + uriTemplate: '/jsonld_non_resource_containers/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), +], parameters: ['properties' => new QueryParameter(filter: new PropertyFilter())])] class NonResourceContainer { #[ApiProperty(identifier: true)] diff --git a/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php index 4eb2f7eae91..9ea0d4a7049 100644 --- a/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php +++ b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php @@ -13,23 +13,20 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\PropertyFilter; -use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Serializer\Filter\PropertyFilter; -#[ApiResource( - operations: [ - new Get( - uriTemplate: '/sparse_fieldset_parents/{id}', - uriVariables: ['id'], - provider: [self::class, 'provide'], - ), - ], -)] -#[ApiFilter(PropertyFilter::class)] +#[ApiResource(operations: [ + new Get( + uriTemplate: '/sparse_fieldset_parents/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), +], parameters: ['properties' => new QueryParameter(filter: new PropertyFilter())])] final class SparseFieldsetParent { public function __construct( diff --git a/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php b/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php new file mode 100644 index 00000000000..2456a1d1482 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ThrowOnNotFound; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; + +#[ApiResource(operations: [ + new Post( + uriTemplate: '/throw_on_not_found_feeders/{id}/feed', + throwOnNotFound: true, + provider: [Feeder::class, 'provide'], + read: true, + ), + new Post( + uriTemplate: '/throw_on_not_found_feeders/{id}/feed_default', + provider: [Feeder::class, 'provide'], + read: true, + ), +])] +final class Feeder +{ + public ?int $id = null; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index c9385f1f41c..06d5fa16fa2 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -14,11 +14,14 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Odm\Filter\EndSearchFilter; use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Doctrine\Odm\Filter\FreeTextQueryFilter; use ApiPlatform\Doctrine\Odm\Filter\IriFilter; use ApiPlatform\Doctrine\Odm\Filter\OrFilter; use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Odm\Filter\StartSearchFilter; +use ApiPlatform\Doctrine\Odm\Filter\WordStartSearchFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; @@ -45,8 +48,35 @@ filter: new PartialSearchFilter(true), property: 'name', ), + 'nameEnd' => new QueryParameter( + filter: new EndSearchFilter(false), + property: 'name', + ), + 'nameEndNoProperty' => new QueryParameter(filter: new EndSearchFilter()), + 'nameEndSensitive' => new QueryParameter( + filter: new EndSearchFilter(true), + property: 'name', + ), + 'nameStart' => new QueryParameter( + filter: new StartSearchFilter(false), + property: 'name', + ), + 'nameStartNoProperty' => new QueryParameter(filter: new StartSearchFilter()), + 'nameStartSensitive' => new QueryParameter( + filter: new StartSearchFilter(true), + property: 'name', + ), + 'nameWordStart' => new QueryParameter( + filter: new WordStartSearchFilter(false), + property: 'name', + ), + 'nameWordStartNoProperty' => new QueryParameter(filter: new WordStartSearchFilter()), 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), + 'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([ + 'name' => new OrFilter(new PartialSearchFilter()), + 'ean' => new OrFilter(new ExactFilter()), + ]), description: 'Partial name match or exact ean match'), 'ownerNamePartial' => new QueryParameter( filter: new PartialSearchFilter(), property: 'owner.name', diff --git a/tests/Fixtures/TestBundle/Document/Company.php b/tests/Fixtures/TestBundle/Document/Company.php index aa6b3e7ae7d..98000290b8c 100644 --- a/tests/Fixtures/TestBundle/Document/Company.php +++ b/tests/Fixtures/TestBundle/Document/Company.php @@ -26,6 +26,7 @@ #[Get] #[Post] #[ApiResource( + shortName: 'CompanyByRoom', uriTemplate: '/employees/{employeeId}/rooms/{roomId}/company/{companyId}', uriVariables: ['employeeId' => ['from_class' => Employee::class, 'from_property' => 'company']] )] diff --git a/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php index 8964c3d49da..eaa0793b2d6 100644 --- a/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php +++ b/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; -use ApiPlatform\Doctrine\Odm\Filter\BooleanFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -25,13 +25,15 @@ #[GetCollection( parameters: [ 'active' => new QueryParameter( - filter: new BooleanFilter(), + filter: new ExactFilter(), nativeType: new BuiltinType(TypeIdentifier::BOOL), + castToNativeType: true, ), 'enabled' => new QueryParameter( - filter: new BooleanFilter(), + filter: new ExactFilter(), property: 'active', nativeType: new BuiltinType(TypeIdentifier::BOOL), + castToNativeType: true, ), ], )] diff --git a/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php b/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php index 30ae305d677..363d8ee2da6 100644 --- a/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php +++ b/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php @@ -13,25 +13,33 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; -use ApiPlatform\Doctrine\Odm\Filter\NumericFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; #[ApiResource] #[GetCollection( paginationItemsPerPage: 5, parameters: [ 'quantity' => new QueryParameter( - filter: new NumericFilter(), + filter: new ExactFilter(), + nativeType: new BuiltinType(TypeIdentifier::INT), + castToNativeType: true, ), 'amount' => new QueryParameter( - filter: new NumericFilter(), + filter: new ExactFilter(), property: 'quantity', + nativeType: new BuiltinType(TypeIdentifier::INT), + castToNativeType: true, ), 'ratio' => new QueryParameter( - filter: new NumericFilter(), + filter: new ExactFilter(), + nativeType: new BuiltinType(TypeIdentifier::FLOAT), + castToNativeType: true, ), ], )] diff --git a/tests/Fixtures/TestBundle/Document/FilteredOrderParameter.php b/tests/Fixtures/TestBundle/Document/FilteredOrderParameter.php index a08313f57d7..d0d994b275e 100644 --- a/tests/Fixtures/TestBundle/Document/FilteredOrderParameter.php +++ b/tests/Fixtures/TestBundle/Document/FilteredOrderParameter.php @@ -14,41 +14,29 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; -use ApiPlatform\Doctrine\Odm\Filter\OrderFilter; +use ApiPlatform\Doctrine\Odm\Filter\SortFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; -use Symfony\Component\TypeInfo\Type\BuiltinType; -use Symfony\Component\TypeInfo\TypeIdentifier; #[ApiResource] #[GetCollection( paginationItemsPerPage: 5, parameters: [ 'createdAt' => new QueryParameter( - filter: new OrderFilter(), - nativeType: new BuiltinType(TypeIdentifier::STRING) + filter: new SortFilter(), ), 'date' => new QueryParameter( - filter: new OrderFilter(), + filter: new SortFilter(), property: 'createdAt', - nativeType: new BuiltinType(TypeIdentifier::STRING) ), 'date_null_always_first' => new QueryParameter( - filter: new OrderFilter(), + filter: new SortFilter(nullsComparison: OrderFilterInterface::NULLS_ALWAYS_FIRST), property: 'createdAt', - filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], - nativeType: new BuiltinType(TypeIdentifier::STRING) - ), - 'date_null_always_first_old_way' => new QueryParameter( - filter: new OrderFilter(properties: ['createdAt' => ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST]]), - property: 'createdAt', - nativeType: new BuiltinType(TypeIdentifier::STRING) ), 'order[:property]' => new QueryParameter( - filter: new OrderFilter(), - filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], + filter: new SortFilter(nullsComparison: OrderFilterInterface::NULLS_ALWAYS_FIRST), ), ], )] diff --git a/tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php b/tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php index 51eb57b6caa..6ebfc5862ee 100644 --- a/tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php +++ b/tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php @@ -13,7 +13,8 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; -use ApiPlatform\Doctrine\Odm\Filter\RangeFilter; +use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -25,11 +26,11 @@ paginationItemsPerPage: 5, parameters: [ 'quantity' => new QueryParameter( - filter: new RangeFilter(), + filter: new ComparisonFilter(new ExactFilter()), openApi: new Parameter('quantity', 'query', allowEmptyValue: true) ), 'amount' => new QueryParameter( - filter: new RangeFilter(), + filter: new ComparisonFilter(new ExactFilter()), property: 'quantity', openApi: new Parameter('amount', 'query', allowEmptyValue: true) ), diff --git a/tests/Fixtures/TestBundle/Document/GraphQlFilteredResource.php b/tests/Fixtures/TestBundle/Document/GraphQlFilteredResource.php new file mode 100644 index 00000000000..d334256c12b --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/GraphQlFilteredResource.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Odm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Serializer\Attribute as Serializer; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * ODM mirror of the QueryParameter-based GraphQL parity fixture. + */ +#[ApiResource( + normalizationContext: ['groups' => ['graphql_filtered']], + graphQlOperations: [ + new Query(), + new QueryCollection( + parameters: [ + 'name' => new QueryParameter(filter: new ExactFilter()), + 'colors.prop' => new QueryParameter(filter: new PartialSearchFilter(), property: 'colors.prop'), + 'colors.price' => new QueryParameter(filter: new ComparisonFilter(new ExactFilter()), property: 'colors.price', nativeType: new BuiltinType(TypeIdentifier::INT)), + 'order[:property]' => new QueryParameter(filter: new SortFilter()), + ], + ), + ], +)] +#[ODM\Document] +class GraphQlFilteredResource +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + #[Serializer\Groups(['graphql_filtered'])] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + #[Serializer\Groups(['graphql_filtered'])] + private string $name = ''; + + /** + * @var Collection + */ + #[ODM\ReferenceMany(targetDocument: GraphQlFilteredResourceColor::class, mappedBy: 'resource')] + #[Serializer\Groups(['graphql_filtered'])] + private Collection $colors; + + public function __construct() + { + $this->colors = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + /** + * @return Collection + */ + public function getColors(): Collection + { + return $this->colors; + } + + public function setColors(Collection $colors): void + { + $this->colors = $colors; + } + + public function addColor(GraphQlFilteredResourceColor $color): void + { + if (!$this->colors->contains($color)) { + $this->colors->add($color); + $color->setResource($this); + } + } +} diff --git a/tests/Fixtures/TestBundle/Document/GraphQlFilteredResourceColor.php b/tests/Fixtures/TestBundle/Document/GraphQlFilteredResourceColor.php new file mode 100644 index 00000000000..f9926d2b788 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/GraphQlFilteredResourceColor.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Serializer\Attribute as Serializer; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * ODM mirror of the QueryParameter-based "color" collection resource. + */ +#[ApiResource( + normalizationContext: ['groups' => ['graphql_filtered']], + graphQlOperations: [ + new Query(), + new QueryCollection( + parameters: [ + 'prop' => new QueryParameter(filter: new PartialSearchFilter(), property: 'prop'), + 'price' => new QueryParameter(filter: new ComparisonFilter(new ExactFilter()), property: 'price', nativeType: new BuiltinType(TypeIdentifier::INT)), + ], + ), + ], +)] +#[ODM\Document] +class GraphQlFilteredResourceColor +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + #[Serializer\Groups(['graphql_filtered'])] + private ?int $id = null; + + #[ODM\ReferenceOne(targetDocument: GraphQlFilteredResource::class, inversedBy: 'colors', storeAs: 'id')] + private ?GraphQlFilteredResource $resource = null; + + #[ODM\Field(type: 'string')] + #[Serializer\Groups(['graphql_filtered'])] + private string $prop = ''; + + #[ODM\Field(type: 'int')] + #[Serializer\Groups(['graphql_filtered'])] + private int $price = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getResource(): ?GraphQlFilteredResource + { + return $this->resource; + } + + public function setResource(?GraphQlFilteredResource $resource): void + { + $this->resource = $resource; + } + + public function getProp(): string + { + return $this->prop; + } + + public function setProp(string $prop): void + { + $this->prop = $prop; + } + + public function getPrice(): int + { + return $this->price; + } + + public function setPrice(int $price): void + { + $this->price = $price; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Legacy/FilteredAttributeParameter.php b/tests/Fixtures/TestBundle/Document/Legacy/FilteredAttributeParameter.php new file mode 100644 index 00000000000..c90be7a008f --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Legacy/FilteredAttributeParameter.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy; + +use ApiPlatform\Doctrine\Odm\Filter\DateFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExistsFilter; +use ApiPlatform\Doctrine\Odm\Filter\RangeFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Legacy regression fixture: Date/Range/Exists filters survive into 5.0 (rewritten standalone), + * so the deprecated #[ApiFilter] attribute declaration must keep working for users. The canonical + * QueryParameter form lives at Document\Filtered{Date,Range,Exists}Parameter. Remove the + * #[ApiFilter] coverage here once the attribute is gone (6.0). + */ +#[ApiResource] +#[GetCollection(uriTemplate: 'legacy_filtered_attribute_parameters{._format}')] +#[ApiFilter(DateFilter::class, properties: ['createdAt'])] +#[ApiFilter(RangeFilter::class, properties: ['quantity'])] +#[ApiFilter(ExistsFilter::class, properties: ['description'])] +#[ODM\Document] +class FilteredAttributeParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'date_immutable', nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + + #[ODM\Field(type: 'int', nullable: true)] + public ?int $quantity = null, + + #[ODM\Field(type: 'string', nullable: true)] + public ?string $description = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function getDescription(): ?string + { + return $this->description; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Legacy/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Document/Legacy/FilteredBooleanParameter.php new file mode 100644 index 00000000000..7aad50050d6 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Legacy/FilteredBooleanParameter.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy; + +use ApiPlatform\Doctrine\Odm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Legacy regression fixture: keeps the deprecated BooleanFilter alive until 6.0. + * The canonical replacement (ExactFilter + boolean nativeType) lives at + * Document\FilteredBooleanParameter. + */ +#[ApiResource] +#[GetCollection( + uriTemplate: 'legacy_filtered_boolean_parameters{._format}', + parameters: [ + 'active' => new QueryParameter( + filter: new BooleanFilter(), + nativeType: new BuiltinType(TypeIdentifier::BOOL), + ), + 'enabled' => new QueryParameter( + filter: new BooleanFilter(), + property: 'active', + nativeType: new BuiltinType(TypeIdentifier::BOOL), + ), + ], +)] +#[ODM\Document] +class FilteredBooleanParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'bool', nullable: true)] + public ?bool $active = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(?bool $active): void + { + $this->active = $active; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Legacy/FilteredNumericParameter.php b/tests/Fixtures/TestBundle/Document/Legacy/FilteredNumericParameter.php new file mode 100644 index 00000000000..b56f2eb38ac --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Legacy/FilteredNumericParameter.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy; + +use ApiPlatform\Doctrine\Odm\Filter\NumericFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Legacy regression fixture: keeps the deprecated NumericFilter alive until 6.0. + * The canonical replacement (ExactFilter + numeric nativeType) lives at + * Document\FilteredNumericParameter. + */ +#[ApiResource] +#[GetCollection( + uriTemplate: 'legacy_filtered_numeric_parameters{._format}', + paginationItemsPerPage: 5, + parameters: [ + 'quantity' => new QueryParameter( + filter: new NumericFilter(), + ), + 'amount' => new QueryParameter( + filter: new NumericFilter(), + property: 'quantity', + ), + 'ratio' => new QueryParameter( + filter: new NumericFilter(), + ), + ], +)] +#[ODM\Document] +class FilteredNumericParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'int', nullable: true)] + public ?int $quantity = null, + + #[ODM\Field(type: 'float', nullable: true)] + public ?float $ratio = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function setQuantity(?int $quantity): void + { + $this->quantity = $quantity; + } + + public function getRatio(): ?float + { + return $this->ratio; + } + + public function setRatio(?float $ratio): void + { + $this->ratio = $ratio; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Legacy/FilteredOrderParameter.php b/tests/Fixtures/TestBundle/Document/Legacy/FilteredOrderParameter.php new file mode 100644 index 00000000000..bb67ffef4c5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Legacy/FilteredOrderParameter.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy; + +use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; +use ApiPlatform\Doctrine\Odm\Filter\OrderFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Legacy regression fixture: keeps the deprecated OrderFilter alive until 6.0, including the + * per-property `properties` nulls_comparison config form. The canonical replacement (SortFilter) + * lives at Document\FilteredOrderParameter. + */ +#[ApiResource] +#[GetCollection( + uriTemplate: 'legacy_filtered_order_parameters{._format}', + paginationItemsPerPage: 5, + parameters: [ + 'createdAt' => new QueryParameter( + filter: new OrderFilter(), + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'date' => new QueryParameter( + filter: new OrderFilter(), + property: 'createdAt', + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'date_null_always_first' => new QueryParameter( + filter: new OrderFilter(), + property: 'createdAt', + filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'date_null_always_first_old_way' => new QueryParameter( + filter: new OrderFilter(properties: ['createdAt' => ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST]]), + property: 'createdAt', + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'order[:property]' => new QueryParameter( + filter: new OrderFilter(), + filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], + ), + ], +)] +#[ODM\Document] +class FilteredOrderParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'date_immutable', nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Document/Legacy/SearchFilterParameter.php similarity index 87% rename from tests/Fixtures/TestBundle/Document/SearchFilterParameter.php rename to tests/Fixtures/TestBundle/Document/Legacy/SearchFilterParameter.php index f29268f455e..40c15d57dcc 100644 --- a/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Document/Legacy/SearchFilterParameter.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy; use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\ApiFilter; @@ -23,8 +23,14 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Filter\QueryParameterOdmFilter; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +/** + * Legacy regression fixture: keeps the deprecated SearchFilter alive until 6.0 through the custom + * ODMSearchFilterValueTransformer / ODMSearchTextAndDateFilter wrappers and the #[ApiFilter] + * attribute aliases referenced by QueryParameter. Canonical scalar/search coverage lives on + * ProductWithQueryParameter (ExactFilter/PartialSearchFilter). Remove in 6.0. + */ #[GetCollection( - uriTemplate: 'search_filter_parameter{._format}', + uriTemplate: 'legacy_search_filter_parameter{._format}', parameters: [ 'foo' => new QueryParameter(filter: 'app_odm_search_filter_via_parameter'), 'fooAlias' => new QueryParameter(filter: 'app_odm_search_filter_via_parameter', property: 'foo'), diff --git a/tests/Fixtures/TestBundle/Entity/Cart.php b/tests/Fixtures/TestBundle/Entity/Cart.php index 1adf0cad569..d5cc8d0d77c 100644 --- a/tests/Fixtures/TestBundle/Entity/Cart.php +++ b/tests/Fixtures/TestBundle/Entity/Cart.php @@ -14,22 +14,21 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Orm\State\Options; -use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SortComputedFieldFilter; +use ApiPlatform\Tests\Fixtures\TestBundle\Repository\CartRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use Doctrine\ORM\QueryBuilder; -#[ORM\Entity] +#[ORM\Entity(repositoryClass: CartRepository::class)] #[GetCollection( normalizationContext: ['hydra_prefix' => false], paginationItemsPerPage: 3, paginationPartial: false, - stateOptions: new Options(handleLinks: [self::class, 'handleLinks']), + stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'), processor: [self::class, 'process'], write: true, parameters: [ @@ -53,15 +52,6 @@ public static function process(mixed $data, Operation $operation, array $uriVari return $data; } - public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void - { - $rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o'; - $itemsAlias = $queryNameGenerator->generateParameterName('items'); - $queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias) - ->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias)) - ->addGroupBy(\sprintf('%s.id', $rootAlias)); - } - public int|string|null $totalQuantity; #[ORM\Id] diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index 3f785481b44..4a5c0d861ae 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -14,11 +14,14 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Orm\Filter\EndSearchFilter; use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter; use ApiPlatform\Doctrine\Orm\Filter\IriFilter; use ApiPlatform\Doctrine\Orm\Filter\OrFilter; use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Orm\Filter\StartSearchFilter; +use ApiPlatform\Doctrine\Orm\Filter\WordStartSearchFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; @@ -45,8 +48,35 @@ filter: new PartialSearchFilter(true), property: 'name', ), + 'nameEnd' => new QueryParameter( + filter: new EndSearchFilter(), + property: 'name', + ), + 'nameEndNoProperty' => new QueryParameter(filter: new EndSearchFilter()), + 'nameEndSensitive' => new QueryParameter( + filter: new EndSearchFilter(true), + property: 'name', + ), + 'nameStart' => new QueryParameter( + filter: new StartSearchFilter(), + property: 'name', + ), + 'nameStartNoProperty' => new QueryParameter(filter: new StartSearchFilter()), + 'nameStartSensitive' => new QueryParameter( + filter: new StartSearchFilter(true), + property: 'name', + ), + 'nameWordStart' => new QueryParameter( + filter: new WordStartSearchFilter(), + property: 'name', + ), + 'nameWordStartNoProperty' => new QueryParameter(filter: new WordStartSearchFilter()), 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), + 'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([ + 'name' => new OrFilter(new PartialSearchFilter()), + 'ean' => new OrFilter(new ExactFilter()), + ]), description: 'Partial name match or exact ean match'), 'ownerNamePartial' => new QueryParameter( filter: new PartialSearchFilter(), property: 'owner.name', diff --git a/tests/Fixtures/TestBundle/Entity/DummyCar.php b/tests/Fixtures/TestBundle/Entity/DummyCar.php index 7749d3ccc9b..d61f9ba83d2 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCar.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCar.php @@ -13,16 +13,17 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\Serializer\Filter\GroupFilter; use ApiPlatform\Serializer\Filter\PropertyFilter; @@ -30,13 +31,10 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute as Serializer; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; -#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)] -#[ApiFilter(BooleanFilter::class)] -#[ApiFilter(PropertyFilter::class, arguments: ['parameterName' => 'foobar'])] -#[ApiFilter(GroupFilter::class, arguments: ['parameterName' => 'foobargroups'])] -#[ApiFilter(GroupFilter::class, arguments: ['parameterName' => 'foobargroups_override'], id: 'override')] -#[ApiResource(operations: [new Get(openapi: new OpenApiOperation(tags: [])), new Put(), new Delete(), new Post(), new GetCollection()], sunset: '2050-01-01', normalizationContext: ['groups' => ['colors']])] +#[ApiResource(operations: [new Get(openapi: new OpenApiOperation(tags: [])), new Put(), new Delete(), new Post(), new GetCollection()], sunset: '2050-01-01', normalizationContext: ['groups' => ['colors']], parameters: ['availableAt' => new QueryParameter(filter: new DateFilter(), filterContext: DateFilter::EXCLUDE_NULL), 'canSell' => new QueryParameter(filter: new ExactFilter(), nativeType: new BuiltinType(TypeIdentifier::BOOL), castToNativeType: true), 'foobar' => new QueryParameter(filter: new PropertyFilter(parameterName: 'foobar')), 'foobargroups' => new QueryParameter(filter: new GroupFilter(parameterName: 'foobargroups')), 'foobargroups_override' => new QueryParameter(filter: new GroupFilter(parameterName: 'foobargroups_override')), 'colors.prop' => new QueryParameter(filter: new PartialSearchFilter(), property: 'colors.prop'), 'colors' => new QueryParameter(filter: new IriFilter()), 'secondColors' => new QueryParameter(filter: new IriFilter()), 'thirdColors' => new QueryParameter(filter: new IriFilter()), 'uuid' => new QueryParameter(filter: new IriFilter()), 'name' => new QueryParameter(filter: new PartialSearchFilter(caseSensitive: true)), 'brand' => new QueryParameter(filter: new ExactFilter())])] #[ORM\Entity] class DummyCar { @@ -46,19 +44,15 @@ class DummyCar #[ORM\Id] #[ORM\OneToOne(targetEntity: DummyCarIdentifier::class, cascade: ['persist'])] private DummyCarIdentifier $id; - #[ApiFilter(SearchFilter::class, properties: ['colors.prop' => 'ipartial', 'colors' => 'exact'])] #[ORM\OneToMany(targetEntity: DummyCarColor::class, mappedBy: 'car')] #[Serializer\Groups(['colors'])] private Collection|iterable $colors; - #[ApiFilter(SearchFilter::class, strategy: 'exact')] #[ORM\OneToMany(targetEntity: DummyCarColor::class, mappedBy: 'car')] #[Serializer\Groups(['colors'])] private Collection|iterable|null $secondColors = null; - #[ApiFilter(SearchFilter::class, strategy: 'exact')] #[ORM\OneToMany(targetEntity: DummyCarColor::class, mappedBy: 'car')] #[Serializer\Groups(['colors'])] private Collection|iterable|null $thirdColors = null; - #[ApiFilter(SearchFilter::class, strategy: 'exact')] #[ORM\ManyToMany(targetEntity: UuidIdentifierDummy::class, indexBy: 'uuid')] #[ORM\JoinColumn(name: 'car_id', referencedColumnName: 'id_id')] #[ORM\InverseJoinColumn(name: 'uuid_uuid', referencedColumnName: 'uuid')] @@ -66,14 +60,12 @@ class DummyCar #[Serializer\Groups(['colors'])] private Collection|iterable|null $uuid = null; - #[ApiFilter(SearchFilter::class, strategy: 'partial')] #[ORM\Column(type: 'string')] private string $name; #[ORM\Column(type: 'boolean')] private bool $canSell; #[ORM\Column(type: 'datetime')] private \DateTime $availableAt; - #[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IEXACT)] #[Serializer\Groups(['colors'])] #[Serializer\SerializedName('carBrand')] #[ORM\Column] diff --git a/tests/Fixtures/TestBundle/Entity/DummyCarColor.php b/tests/Fixtures/TestBundle/Entity/DummyCarColor.php index f1d90e2ebac..c516539b3c1 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCarColor.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCarColor.php @@ -13,14 +13,14 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; -#[ApiResource] +#[ApiResource(parameters: ['prop' => new QueryParameter(filter: new ExactFilter())])] #[ORM\Entity] class DummyCarColor { @@ -35,7 +35,6 @@ class DummyCarColor #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE', referencedColumnName: 'id_id')] #[Assert\NotBlank] private DummyCar $car; - #[ApiFilter(SearchFilter::class)] #[ORM\Column(nullable: false)] #[Assert\NotBlank] #[Groups(['colors'])] diff --git a/tests/Fixtures/TestBundle/Entity/DummyPhp8.php b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php index 26fa301ca7c..a7ef7a834ef 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyPhp8.php +++ b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php @@ -13,13 +13,13 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; -#[ApiResource(description: 'Hey PHP 8')] +#[ApiResource(description: 'Hey PHP 8', parameters: ['filtered' => new QueryParameter(filter: new ExactFilter())])] #[ORM\Entity] class DummyPhp8 { @@ -27,7 +27,6 @@ class DummyPhp8 #[ORM\Id] #[ORM\Column(type: 'integer')] public $id; - #[ApiFilter(SearchFilter::class)] #[ORM\Column] public $filtered; diff --git a/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php index 259c2aafa48..1fcab51559b 100644 --- a/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php +++ b/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -25,13 +25,15 @@ #[GetCollection( parameters: [ 'active' => new QueryParameter( - filter: new BooleanFilter(), + filter: new ExactFilter(), nativeType: new BuiltinType(TypeIdentifier::BOOL), + castToNativeType: true, ), 'enabled' => new QueryParameter( - filter: new BooleanFilter(), + filter: new ExactFilter(), property: 'active', nativeType: new BuiltinType(TypeIdentifier::BOOL), + castToNativeType: true, ), ], )] diff --git a/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php index 20e1e152be5..1a1a68b94b8 100644 --- a/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php +++ b/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php @@ -13,25 +13,33 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\NumericFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; #[ApiResource] #[GetCollection( paginationItemsPerPage: 5, parameters: [ 'quantity' => new QueryParameter( - filter: new NumericFilter(), + filter: new ExactFilter(), + nativeType: new BuiltinType(TypeIdentifier::INT), + castToNativeType: true, ), 'amount' => new QueryParameter( - filter: new NumericFilter(), + filter: new ExactFilter(), property: 'quantity', + nativeType: new BuiltinType(TypeIdentifier::INT), + castToNativeType: true, ), 'ratio' => new QueryParameter( - filter: new NumericFilter(), + filter: new ExactFilter(), + nativeType: new BuiltinType(TypeIdentifier::FLOAT), + castToNativeType: true, ), ], )] diff --git a/tests/Fixtures/TestBundle/Entity/FilteredOrderParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredOrderParameter.php index 21bf7dbaa1a..78cb7a2c97e 100644 --- a/tests/Fixtures/TestBundle/Entity/FilteredOrderParameter.php +++ b/tests/Fixtures/TestBundle/Entity/FilteredOrderParameter.php @@ -14,41 +14,29 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; -use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Doctrine\Orm\Filter\SortFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\TypeInfo\Type\BuiltinType; -use Symfony\Component\TypeInfo\TypeIdentifier; #[ApiResource] #[GetCollection( paginationItemsPerPage: 5, parameters: [ 'createdAt' => new QueryParameter( - filter: new OrderFilter(), - nativeType: new BuiltinType(TypeIdentifier::STRING) + filter: new SortFilter(), ), 'date' => new QueryParameter( - filter: new OrderFilter(), + filter: new SortFilter(), property: 'createdAt', - nativeType: new BuiltinType(TypeIdentifier::STRING) ), 'date_null_always_first' => new QueryParameter( - filter: new OrderFilter(), + filter: new SortFilter(nullsComparison: OrderFilterInterface::NULLS_ALWAYS_FIRST), property: 'createdAt', - filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], - nativeType: new BuiltinType(TypeIdentifier::STRING) - ), - 'date_null_always_first_old_way' => new QueryParameter( - filter: new OrderFilter(properties: ['createdAt' => ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST]]), - property: 'createdAt', - nativeType: new BuiltinType(TypeIdentifier::STRING) ), 'order[:property]' => new QueryParameter( - filter: new OrderFilter(), - filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], + filter: new SortFilter(nullsComparison: OrderFilterInterface::NULLS_ALWAYS_FIRST), ), ], )] diff --git a/tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php index dace7ef7e1e..f4261bc1b75 100644 --- a/tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php +++ b/tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php @@ -13,7 +13,8 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; +use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -25,11 +26,11 @@ paginationItemsPerPage: 5, parameters: [ 'quantity' => new QueryParameter( - filter: new RangeFilter(), + filter: new ComparisonFilter(new ExactFilter()), openApi: new Parameter('quantity', 'query', allowEmptyValue: true) ), 'amount' => new QueryParameter( - filter: new RangeFilter(), + filter: new ComparisonFilter(new ExactFilter()), property: 'quantity', openApi: new Parameter('amount', 'query', allowEmptyValue: true) ), diff --git a/tests/Fixtures/TestBundle/Entity/GraphQlFilteredResource.php b/tests/Fixtures/TestBundle/Entity/GraphQlFilteredResource.php new file mode 100644 index 00000000000..1a68984b8dd --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/GraphQlFilteredResource.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Orm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute as Serializer; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Parameter-based (QueryParameter) mirror of the DummyCar <-> DummyCarColor relationship, + * used to prove GraphQL filter-argument parity between the canonical + * Operation::getParameters() path and the legacy Operation::getFilters() path. + */ +#[ApiResource( + normalizationContext: ['groups' => ['graphql_filtered']], + graphQlOperations: [ + new Query(), + new QueryCollection( + parameters: [ + 'name' => new QueryParameter(filter: new ExactFilter()), + 'colors.prop' => new QueryParameter(filter: new PartialSearchFilter(), property: 'colors.prop'), + 'colors.price' => new QueryParameter(filter: new ComparisonFilter(new ExactFilter()), property: 'colors.price', nativeType: new BuiltinType(TypeIdentifier::INT)), + 'order[:property]' => new QueryParameter(filter: new SortFilter()), + ], + ), + ], +)] +#[ORM\Entity] +class GraphQlFilteredResource +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + #[Serializer\Groups(['graphql_filtered'])] + private ?int $id = null; + + #[ORM\Column(type: 'string')] + #[Serializer\Groups(['graphql_filtered'])] + private string $name = ''; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: GraphQlFilteredResourceColor::class, mappedBy: 'resource')] + #[Serializer\Groups(['graphql_filtered'])] + private Collection $colors; + + public function __construct() + { + $this->colors = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + /** + * @return Collection + */ + public function getColors(): Collection + { + return $this->colors; + } + + public function setColors(Collection $colors): void + { + $this->colors = $colors; + } + + public function addColor(GraphQlFilteredResourceColor $color): void + { + if (!$this->colors->contains($color)) { + $this->colors->add($color); + $color->setResource($this); + } + } +} diff --git a/tests/Fixtures/TestBundle/Entity/GraphQlFilteredResourceColor.php b/tests/Fixtures/TestBundle/Entity/GraphQlFilteredResourceColor.php new file mode 100644 index 00000000000..2ebc2c4eb28 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/GraphQlFilteredResourceColor.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute as Serializer; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * QueryParameter-based "color" collection resource mirroring DummyCarColor. + * Declares its filters via QueryParameter so the nested `colors(prop: ...)` / + * `colors(price: {gt: ...})` arguments must be derived from getParameters(). + */ +#[ApiResource( + normalizationContext: ['groups' => ['graphql_filtered']], + graphQlOperations: [ + new Query(), + new QueryCollection( + parameters: [ + 'prop' => new QueryParameter(filter: new PartialSearchFilter(), property: 'prop'), + 'price' => new QueryParameter(filter: new ComparisonFilter(new ExactFilter()), property: 'price', nativeType: new BuiltinType(TypeIdentifier::INT)), + ], + ), + ], +)] +#[ORM\Entity] +class GraphQlFilteredResourceColor +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + #[Serializer\Groups(['graphql_filtered'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: GraphQlFilteredResource::class, inversedBy: 'colors')] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + private ?GraphQlFilteredResource $resource = null; + + #[ORM\Column(type: 'string')] + #[Serializer\Groups(['graphql_filtered'])] + private string $prop = ''; + + #[ORM\Column(type: 'integer')] + #[Serializer\Groups(['graphql_filtered'])] + private int $price = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getResource(): ?GraphQlFilteredResource + { + return $this->resource; + } + + public function setResource(?GraphQlFilteredResource $resource): void + { + $this->resource = $resource; + } + + public function getProp(): string + { + return $this->prop; + } + + public function setProp(string $prop): void + { + $this->prop = $prop; + } + + public function getPrice(): int + { + return $this->price; + } + + public function setPrice(int $price): void + { + $this->price = $price; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue5735/Issue5735User.php b/tests/Fixtures/TestBundle/Entity/Issue5735/Issue5735User.php index f5018064b6c..7f60f3a096e 100644 --- a/tests/Fixtures/TestBundle/Entity/Issue5735/Issue5735User.php +++ b/tests/Fixtures/TestBundle/Entity/Issue5735/Issue5735User.php @@ -13,25 +13,21 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; -#[ApiResource( - operations: [ - new Get(), - new GetCollection(), - ], - routePrefix: '/issue5735' -)] -#[ApiFilter(SearchFilter::class, properties: ['groups' => 'exact'])] +#[ApiResource(operations: [ + new Get(), + new GetCollection(), +], routePrefix: '/issue5735', parameters: ['groups' => new QueryParameter(filter: new IriFilter())])] #[ORM\Entity] #[ORM\Table(name: 'issue5735_user')] class Issue5735User diff --git a/tests/Fixtures/TestBundle/Entity/Issue7126/DummyForBackedEnumFilter.php b/tests/Fixtures/TestBundle/Entity/Issue7126/DummyForBackedEnumFilter.php index b60a974e9e0..0ab60421fdc 100644 --- a/tests/Fixtures/TestBundle/Entity/Issue7126/DummyForBackedEnumFilter.php +++ b/tests/Fixtures/TestBundle/Entity/Issue7126/DummyForBackedEnumFilter.php @@ -13,15 +13,18 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7126; -use ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; #[GetCollection( uriTemplate: 'backed_enum_filter{._format}', + parameters: [ + 'stringBackedEnum' => new QueryParameter(filter: new ExactFilter()), + 'integerBackedEnum' => new QueryParameter(filter: new ExactFilter()), + ], )] -#[ApiFilter(BackedEnumFilter::class, properties: ['stringBackedEnum', 'integerBackedEnum'])] #[ORM\Entity] class DummyForBackedEnumFilter { diff --git a/tests/Fixtures/TestBundle/Entity/Issue8085/DatedCursorDummy.php b/tests/Fixtures/TestBundle/Entity/Issue8085/DatedCursorDummy.php index 654402930a5..d0da06b045c 100644 --- a/tests/Fixtures/TestBundle/Entity/Issue8085/DatedCursorDummy.php +++ b/tests/Fixtures/TestBundle/Entity/Issue8085/DatedCursorDummy.php @@ -14,22 +14,18 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue8085; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; -use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; -#[ApiResource( - operations: [ - new GetCollection( - paginationItemsPerPage: 3, - paginationPartial: true, - paginationViaCursor: [['field' => 'createdAt', 'direction' => 'DESC']], - ), - ], - graphQlOperations: [], -)] -#[ApiFilter(DateFilter::class, properties: ['createdAt'])] +#[ApiResource(operations: [ + new GetCollection( + paginationItemsPerPage: 3, + paginationPartial: true, + paginationViaCursor: [['field' => 'createdAt', 'direction' => 'DESC']], + ), +], graphQlOperations: [], parameters: ['createdAt' => new QueryParameter(filter: new DateFilter())])] #[ORM\Entity] #[ORM\Table(name: 'issue_8085_dated_cursor_dummy')] class DatedCursorDummy diff --git a/tests/Fixtures/TestBundle/Entity/DummyExceptionToStatus.php b/tests/Fixtures/TestBundle/Entity/Legacy/DummyExceptionToStatus.php similarity index 97% rename from tests/Fixtures/TestBundle/Entity/DummyExceptionToStatus.php rename to tests/Fixtures/TestBundle/Entity/Legacy/DummyExceptionToStatus.php index 8bf8f6693c2..1dd5550c686 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyExceptionToStatus.php +++ b/tests/Fixtures/TestBundle/Entity/Legacy/DummyExceptionToStatus.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; diff --git a/tests/Fixtures/TestBundle/Entity/Legacy/DummyForBackedEnumFilter.php b/tests/Fixtures/TestBundle/Entity/Legacy/DummyForBackedEnumFilter.php new file mode 100644 index 00000000000..7cc8e542f04 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Legacy/DummyForBackedEnumFilter.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy; + +use ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7126\IntegerBackedEnum; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7126\StringBackedEnum; +use Doctrine\ORM\Mapping as ORM; + +/** + * Legacy regression fixture: keeps the deprecated #[ApiFilter(BackedEnumFilter)] attribute path + * alive until 6.0. The canonical replacement lives at Entity\Issue7126\DummyForBackedEnumFilter + * (QueryParameter + ExactFilter). + */ +#[GetCollection( + uriTemplate: 'legacy_backed_enum_filter{._format}', +)] +#[ApiFilter(BackedEnumFilter::class, properties: ['stringBackedEnum', 'integerBackedEnum'])] +#[ORM\Entity] +class DummyForBackedEnumFilter +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(nullable: true, enumType: StringBackedEnum::class)] + private ?StringBackedEnum $stringBackedEnum = null; + + #[ORM\Column(nullable: true, enumType: IntegerBackedEnum::class)] + private ?IntegerBackedEnum $integerBackedEnum = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getStringBackedEnum(): ?StringBackedEnum + { + return $this->stringBackedEnum; + } + + public function setStringBackedEnum(StringBackedEnum $stringBackedEnum): void + { + $this->stringBackedEnum = $stringBackedEnum; + } + + public function getIntegerBackedEnum(): ?IntegerBackedEnum + { + return $this->integerBackedEnum; + } + + public function setIntegerBackedEnum(IntegerBackedEnum $integerBackedEnum): void + { + $this->integerBackedEnum = $integerBackedEnum; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Legacy/FilteredAttributeParameter.php b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredAttributeParameter.php new file mode 100644 index 00000000000..a1629c0b351 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredAttributeParameter.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy; + +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter; +use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use Doctrine\ORM\Mapping as ORM; + +/** + * Legacy regression fixture: Date/Range/Exists filters survive into 5.0 (rewritten standalone), + * so the deprecated #[ApiFilter] attribute declaration must keep working for users. The canonical + * QueryParameter form lives at Entity\Filtered{Date,Range,Exists}Parameter. Remove the #[ApiFilter] + * coverage here once the attribute is gone (6.0). + */ +#[ApiResource] +#[GetCollection(uriTemplate: 'legacy_filtered_attribute_parameters{._format}')] +#[ApiFilter(DateFilter::class, properties: ['createdAt'])] +#[ApiFilter(RangeFilter::class, properties: ['quantity'])] +#[ApiFilter(ExistsFilter::class, properties: ['description'])] +#[ORM\Entity] +class FilteredAttributeParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + + #[ORM\Column(nullable: true)] + public ?int $quantity = null, + + #[ORM\Column(nullable: true)] + public ?string $description = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function getDescription(): ?string + { + return $this->description; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Legacy/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredBooleanParameter.php new file mode 100644 index 00000000000..40b94dbe182 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredBooleanParameter.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy; + +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Legacy regression fixture: keeps the deprecated BooleanFilter alive until 6.0. + * The canonical replacement (ExactFilter + boolean nativeType) lives at + * Entity\FilteredBooleanParameter. + */ +#[ApiResource] +#[GetCollection( + uriTemplate: 'legacy_filtered_boolean_parameters{._format}', + parameters: [ + 'active' => new QueryParameter( + filter: new BooleanFilter(), + nativeType: new BuiltinType(TypeIdentifier::BOOL), + ), + 'enabled' => new QueryParameter( + filter: new BooleanFilter(), + property: 'active', + nativeType: new BuiltinType(TypeIdentifier::BOOL), + ), + ], +)] +#[ORM\Entity] +class FilteredBooleanParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?bool $active = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(?bool $isActive): void + { + $this->active = $isActive; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Legacy/FilteredNumericParameter.php b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredNumericParameter.php new file mode 100644 index 00000000000..68ac9987a0f --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredNumericParameter.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy; + +use ApiPlatform\Doctrine\Orm\Filter\NumericFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +/** + * Legacy regression fixture: keeps the deprecated NumericFilter alive until 6.0. + * The canonical replacement (ExactFilter + numeric nativeType) lives at + * Entity\FilteredNumericParameter. + */ +#[ApiResource] +#[GetCollection( + uriTemplate: 'legacy_filtered_numeric_parameters{._format}', + paginationItemsPerPage: 5, + parameters: [ + 'quantity' => new QueryParameter( + filter: new NumericFilter(), + ), + 'amount' => new QueryParameter( + filter: new NumericFilter(), + property: 'quantity', + ), + 'ratio' => new QueryParameter( + filter: new NumericFilter(), + ), + ], +)] +#[ORM\Entity] +class FilteredNumericParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?int $quantity = null, + + #[ORM\Column(nullable: true)] + public ?float $ratio = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function setQuantity(?int $quantity): void + { + $this->quantity = $quantity; + } + + public function getRatio(): ?float + { + return $this->ratio; + } + + public function setRatio(?float $ratio): void + { + $this->ratio = $ratio; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Legacy/FilteredOrderParameter.php b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredOrderParameter.php new file mode 100644 index 00000000000..0ba2c172578 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredOrderParameter.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy; + +use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Legacy regression fixture: keeps the deprecated OrderFilter alive until 6.0, including the + * per-property `properties` nulls_comparison config form. The canonical replacement (SortFilter) + * lives at Entity\FilteredOrderParameter. + */ +#[ApiResource] +#[GetCollection( + uriTemplate: 'legacy_filtered_order_parameters{._format}', + paginationItemsPerPage: 5, + parameters: [ + 'createdAt' => new QueryParameter( + filter: new OrderFilter(), + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'date' => new QueryParameter( + filter: new OrderFilter(), + property: 'createdAt', + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'date_null_always_first' => new QueryParameter( + filter: new OrderFilter(), + property: 'createdAt', + filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'date_null_always_first_old_way' => new QueryParameter( + filter: new OrderFilter(properties: ['createdAt' => ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST]]), + property: 'createdAt', + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'order[:property]' => new QueryParameter( + filter: new OrderFilter(), + filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], + ), + ], +)] +#[ORM\Entity] +class FilteredOrderParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/Legacy/SearchFilterParameter.php similarity index 88% rename from tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php rename to tests/Fixtures/TestBundle/Entity/Legacy/SearchFilterParameter.php index 17247f85526..b25a41abbcd 100644 --- a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Entity/Legacy/SearchFilterParameter.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy; use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\ApiFilter; @@ -24,9 +24,15 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchTextAndDateFilter; use Doctrine\ORM\Mapping as ORM; +/** + * Legacy regression fixture: keeps the deprecated SearchFilter alive until 6.0 through the custom + * SearchFilterValueTransformer / SearchTextAndDateFilter wrappers and the #[ApiFilter] attribute + * aliases referenced by QueryParameter. Canonical scalar/search coverage lives on + * ProductWithQueryParameter (ExactFilter/PartialSearchFilter). Remove in 6.0. + */ #[ApiResource(openapi: false)] #[GetCollection( - uriTemplate: 'search_filter_parameter{._format}', + uriTemplate: 'legacy_search_filter_parameter{._format}', parameters: [ 'foo' => new QueryParameter(filter: 'app_search_filter_via_parameter'), 'fooAlias' => new QueryParameter(filter: 'app_search_filter_via_parameter', property: 'foo'), diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php index b8a3f562363..1765296e027 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -14,9 +14,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; -use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; @@ -24,6 +22,7 @@ use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -35,15 +34,10 @@ * * @author Kévin Dunglas */ -#[ApiResource( - graphQlOperations: [ - new Query(name: 'item_query'), - new Mutation(name: 'update', normalizationContext: ['groups' => ['chicago', 'fakemanytomany']], denormalizationContext: ['groups' => ['friends']]), - ], - types: ['https://schema.org/Product'], - normalizationContext: ['groups' => ['friends']], - filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'] -)] +#[ApiResource(graphQlOperations: [ + new Query(name: 'item_query'), + new Mutation(name: 'update', normalizationContext: ['groups' => ['chicago', 'fakemanytomany']], denormalizationContext: ['groups' => ['friends']]), +], types: ['https://schema.org/Product'], normalizationContext: ['groups' => ['friends']], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], parameters: ['id' => new QueryParameter(filter: new ExactFilter()), 'symfony' => new QueryParameter(filter: new ExactFilter()), 'dummyDate' => new QueryParameter(filter: new DateFilter())])] #[ApiResource(uriTemplate: '/dummies/{id}/related_dummies{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies')], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new GetCollection()])] #[ApiResource(uriTemplate: '/dummies/{id}/related_dummies/{relatedDummies}{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] #[ApiResource(uriTemplate: '/related_dummies/{id}/id{._format}', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] @@ -51,7 +45,6 @@ #[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy/related_dummies/{relatedDummies}{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy'), 'owningDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owning_dummy', fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] #[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy/related_dummies{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy'), 'ownedDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owned_dummy', fromProperty: 'relatedDummies')], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new GetCollection()])] #[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy/related_dummies/{relatedDummies}{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy'), 'ownedDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owned_dummy', fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] -#[ApiFilter(filterClass: SearchFilter::class, properties: ['id'])] #[ORM\Entity] class RelatedDummy extends ParentDummy implements \Stringable { @@ -73,8 +66,6 @@ class RelatedDummy extends ParentDummy implements \Stringable #[ApiProperty(deprecationReason: 'This property is deprecated for upgrade test')] #[ORM\Column] #[Groups(['barcelona', 'chicago', 'friends'])] - #[ApiFilter(filterClass: SearchFilter::class)] - #[ApiFilter(filterClass: ExistsFilter::class)] protected $symfony = 'symfony'; /** @@ -83,7 +74,6 @@ class RelatedDummy extends ParentDummy implements \Stringable #[ORM\Column(type: 'datetime', nullable: true)] #[Assert\DateTime] #[Groups(['friends'])] - #[ApiFilter(filterClass: DateFilter::class)] public $dummyDate; #[ORM\ManyToOne(targetEntity: ThirdLevel::class, cascade: ['persist'], inversedBy: 'relatedDummies')] diff --git a/tests/Fixtures/TestBundle/Entity/SoMany.php b/tests/Fixtures/TestBundle/Entity/SoMany.php index e3770b8007d..1de33ba402b 100644 --- a/tests/Fixtures/TestBundle/Entity/SoMany.php +++ b/tests/Fixtures/TestBundle/Entity/SoMany.php @@ -13,15 +13,13 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\SortFilter; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; -#[ApiFilter(RangeFilter::class, properties: ['id'])] -#[ApiFilter(OrderFilter::class, properties: ['id' => 'DESC'])] -#[ApiResource(paginationPartial: true, paginationViaCursor: [['field' => 'id', 'direction' => 'DESC']])] +#[ApiResource(paginationPartial: true, paginationViaCursor: [['field' => 'id', 'direction' => 'DESC']], parameters: ['id' => new QueryParameter(filter: new RangeFilter()), 'order[:property]' => new QueryParameter(filter: new SortFilter())])] #[ORM\Entity] class SoMany { diff --git a/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php b/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php index 01134af2974..51eda6a66dc 100644 --- a/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php +++ b/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php @@ -18,7 +18,6 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use GraphQL\Type\Definition\Type as GraphQLType; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; /** @@ -32,22 +31,6 @@ public function __construct(private readonly TypeConverterInterface $defaultType { } - /** - * {@inheritdoc} - */ - public function convertType(LegacyType $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth): GraphQLType|string|null - { - if ('dummyDate' === $property - && \in_array($rootResource, [Dummy::class, DummyDocument::class], true) - && LegacyType::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() - && is_a($type->getClassName(), \DateTimeInterface::class, true) - ) { - return \DateTime::class; - } - - return $this->defaultTypeConverter->convertType($type, $input, $rootOperation, $resourceClass, $rootResource, $property, $depth); - } - /** * {@inheritdoc} */ diff --git a/tests/Fixtures/TestBundle/Metadata/ProviderResourceMetadatatCollectionFactory.php b/tests/Fixtures/TestBundle/Metadata/ProviderResourceMetadatatCollectionFactory.php index 95bcbea3682..3b8f2359128 100644 --- a/tests/Fixtures/TestBundle/Metadata/ProviderResourceMetadatatCollectionFactory.php +++ b/tests/Fixtures/TestBundle/Metadata/ProviderResourceMetadatatCollectionFactory.php @@ -21,11 +21,9 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ResourceInterface; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Taxon; use ApiPlatform\Tests\Fixtures\TestBundle\Model\ResourceInterface as ResourceInterfaceDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Model\SerializableResource; use ApiPlatform\Tests\Fixtures\TestBundle\Model\TaxonInterface; use ApiPlatform\Tests\Fixtures\TestBundle\State\ContainNonResourceProvider; use ApiPlatform\Tests\Fixtures\TestBundle\State\ResourceInterfaceImplementationProvider; -use ApiPlatform\Tests\Fixtures\TestBundle\State\SerializableProvider; use ApiPlatform\Tests\Fixtures\TestBundle\State\TaxonItemProvider; class ProviderResourceMetadatatCollectionFactory implements ResourceMetadataCollectionFactoryInterface @@ -49,10 +47,6 @@ public function create(string $resourceClass): ResourceMetadataCollection return $this->setProvider($resourceMetadataCollection, ContainNonResourceProvider::class); } - if (SerializableResource::class === $resourceClass) { - return $this->setProvider($resourceMetadataCollection, SerializableProvider::class); - } - if (Taxon::class === $resourceClass || TaxonDocument::class === $resourceClass || TaxonInterface::class === $resourceClass) { return $this->setProvider($resourceMetadataCollection, TaxonItemProvider::class); } diff --git a/tests/Fixtures/TestBundle/Repository/CartRepository.php b/tests/Fixtures/TestBundle/Repository/CartRepository.php new file mode 100644 index 00000000000..5f5ba5b4e87 --- /dev/null +++ b/tests/Fixtures/TestBundle/Repository/CartRepository.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 ApiPlatform\Tests\Fixtures\TestBundle\Repository; + +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Cart; +use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\QueryBuilder; + +/** + * @extends EntityRepository + */ +class CartRepository extends EntityRepository +{ + public function getCartsWithTotalQuantity(): QueryBuilder + { + $queryBuilder = $this->createQueryBuilder('o'); + $queryBuilder->leftJoin('o.items', 'items') + ->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity') + ->addGroupBy('o.id'); + + return $queryBuilder; + } +} diff --git a/tests/Fixtures/TestBundle/Resources/config/api_resources_odm/properties.xml b/tests/Fixtures/TestBundle/Resources/config/api_resources_odm/properties.xml index 22c4b56aad8..f714f755f96 100644 --- a/tests/Fixtures/TestBundle/Resources/config/api_resources_odm/properties.xml +++ b/tests/Fixtures/TestBundle/Resources/config/api_resources_odm/properties.xml @@ -8,11 +8,7 @@ readable="true" writable="false" identifier="true"/> - - string - - + description="Comment message" readable="true" writable="true"/> diff --git a/tests/Fixtures/TestBundle/Resources/config/api_resources_orm/properties.xml b/tests/Fixtures/TestBundle/Resources/config/api_resources_orm/properties.xml index 208b4e9e431..0a9f756d204 100644 --- a/tests/Fixtures/TestBundle/Resources/config/api_resources_orm/properties.xml +++ b/tests/Fixtures/TestBundle/Resources/config/api_resources_orm/properties.xml @@ -8,11 +8,7 @@ readable="true" writable="false" identifier="true"/> - - string - - + description="Comment message" readable="true" writable="true"/> diff --git a/tests/Fixtures/TestBundle/State/SerializableProvider.php b/tests/Fixtures/TestBundle/State/SerializableProvider.php deleted file mode 100644 index 3eca79faf54..00000000000 --- a/tests/Fixtures/TestBundle/State/SerializableProvider.php +++ /dev/null @@ -1,43 +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 ApiPlatform\Tests\Fixtures\TestBundle\State; - -use ApiPlatform\Metadata\Operation; -use ApiPlatform\State\ProviderInterface; -use ApiPlatform\State\SerializerAwareProviderInterface; -use ApiPlatform\State\SerializerAwareProviderTrait; - -/** - * @author Vincent Chalamon - * - * @deprecated in 4.2, to be removed in 5.0 because it violates the dependency injection principle. - */ -class SerializableProvider implements ProviderInterface, SerializerAwareProviderInterface -{ - use SerializerAwareProviderTrait; - - /** - * {@inheritDoc} - */ - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object - { - return $this->getSerializer()->deserialize(<<<'JSON' -{ - "id": 1, - "foo": "Lorem", - "bar": "Ipsum" -} -JSON, $operation->getClass(), 'json'); - } -} diff --git a/tests/Fixtures/TestBundle/State/SpyPaginator.php b/tests/Fixtures/TestBundle/State/SpyPaginator.php new file mode 100644 index 00000000000..0cb05130953 --- /dev/null +++ b/tests/Fixtures/TestBundle/State/SpyPaginator.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\State; + +use ApiPlatform\State\Pagination\PaginatorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpException; + +/** + * A paginator whose scalar metadata is canned but whose rows are never meant to be + * read: getIterator() and count() throw. A HEAD request must return without iterating, + * proving no row SELECT was issued. + * + * @implements PaginatorInterface + * @implements \IteratorAggregate + */ +final class SpyPaginator implements PaginatorInterface, \IteratorAggregate +{ + public function getCurrentPage(): float + { + return 1.; + } + + public function getItemsPerPage(): float + { + return 30.; + } + + public function getLastPage(): float + { + return 1.; + } + + public function getTotalItems(): float + { + return 42.; + } + + public function count(): int + { + throw new HttpException(Response::HTTP_I_AM_A_TEAPOT, 'iterated on HEAD'); + } + + public function getIterator(): \Iterator + { + throw new HttpException(Response::HTTP_I_AM_A_TEAPOT, 'iterated on HEAD'); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 531eb2b30de..9add790eab7 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -60,6 +60,8 @@ api_platform: jsonapi: ['application/vnd.api+json'] html: ['text/html'] xml: ['application/xml', 'text/xml'] + jsonapi: + use_iri_as_id: true graphql: enabled: true nesting_separator: __ @@ -82,8 +84,6 @@ api_platform: http_cache: invalidation: enabled: true - # TODO: remove in 5.0 - enable_link_security: true # see also defaults in AppKernel doctrine_mongodb_odm: false mapping: @@ -162,11 +162,6 @@ services: tags: - name: 'api_platform.state_provider' - ApiPlatform\Tests\Fixtures\TestBundle\State\SerializableProvider: - class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\SerializableProvider' - tags: - - name: 'api_platform.state_provider' - ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider' tags: diff --git a/tests/Functional/AttributeResourceTest.php b/tests/Functional/AttributeResourceTest.php index 9c959ff4044..753fc8a39c7 100644 --- a/tests/Functional/AttributeResourceTest.php +++ b/tests/Functional/AttributeResourceTest.php @@ -89,9 +89,9 @@ public function testAliasedResourceRedirectsAndShowsTarget(): void $this->assertResponseHeaderSame('Location', '/attribute_resources/2'); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/AttributeResource', + '@context' => '/contexts/AttributeResource2', '@id' => '/attribute_resources/2', - '@type' => 'AttributeResource', + '@type' => 'AttributeResource2', 'identifier' => 2, 'dummy' => '/dummies/1', 'name' => 'Foo', @@ -109,9 +109,9 @@ public function testPatchAliasedResource(): void $this->assertResponseHeaderSame('Location', '/attribute_resources/2'); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/AttributeResource', + '@context' => '/contexts/AttributeResource2', '@id' => '/attribute_resources/2', - '@type' => 'AttributeResource', + '@type' => 'AttributeResource2', 'identifier' => 2, 'dummy' => '/dummies/1', 'name' => 'Patched', diff --git a/tests/Functional/CrudUriVariablesTest.php b/tests/Functional/CrudUriVariablesTest.php index 695d3b6938c..27aed1c63aa 100644 --- a/tests/Functional/CrudUriVariablesTest.php +++ b/tests/Functional/CrudUriVariablesTest.php @@ -112,7 +112,7 @@ public function testGetEmployeesCollectionByCompany(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/Employee', + '@context' => '/contexts/Employee3', '@id' => '/companies/2/employees', '@type' => 'hydra:Collection', 'hydra:member' => [ @@ -143,9 +143,9 @@ public function testGetCompanyOfEmployee(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonEquals([ - '@context' => '/contexts/Company', + '@context' => '/contexts/Company2', '@id' => '/employees/1/company', - '@type' => 'Company', + '@type' => 'Company2', 'id' => 1, 'name' => 'Foo Company 1', 'employees' => [], @@ -162,9 +162,9 @@ public function testGetEmployeeWithCompanyUriVariable(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonEquals([ - '@context' => '/contexts/Employee', + '@context' => '/contexts/Employee2', '@id' => '/companies/1/employees/1', - '@type' => 'Employee', + '@type' => 'Employee2', 'id' => 1, 'name' => 'foo', 'company' => '/companies/1', diff --git a/tests/Functional/CustomIdentifierWithSubresourceTest.php b/tests/Functional/CustomIdentifierWithSubresourceTest.php index fd91785325b..42699db37d4 100644 --- a/tests/Functional/CustomIdentifierWithSubresourceTest.php +++ b/tests/Functional/CustomIdentifierWithSubresourceTest.php @@ -101,7 +101,7 @@ public function testGetChildDummiesOfParentBySlug(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/SlugChildDummy', + '@context' => '/contexts/SlugChildDummy2', '@id' => '/slug_parent_dummies/parent-dummy/child_dummies', '@type' => 'hydra:Collection', 'hydra:member' => [ @@ -126,9 +126,9 @@ public function testGetParentOfChildBySlug(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/SlugParentDummy', + '@context' => '/contexts/SlugParentDummy3', '@id' => '/slug_child_dummies/child-dummy/parent_dummy', - '@type' => 'SlugParentDummy', + '@type' => 'SlugParentDummy3', 'id' => 1, 'slug' => 'parent-dummy', 'childDummies' => ['/slug_child_dummies/child-dummy'], diff --git a/tests/Functional/DenormalizationValidationTest.php b/tests/Functional/DenormalizationValidationTest.php new file mode 100644 index 00000000000..e0c8311cea9 --- /dev/null +++ b/tests/Functional/DenormalizationValidationTest.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DenormalizationValidationResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Type; + +/** + * @see https://github.com/api-platform/core/issues/7981 + */ +final class DenormalizationValidationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DenormalizationValidationResource::class]; + } + + public function testNullOnNotBlankPropertyProduces422WithNotBlankViolation(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => null], + ]); + + $this->assertResponseStatusCodeSame(422); + $content = $response->toArray(false); + $violation = $this->findViolation($content['violations'] ?? [], 'name'); + $this->assertNotNull($violation, 'Expected a violation on "name".'); + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $violation['code'] ?? null); + } + + public function testNullOnNotNullPropertyProduces422WithNotNullViolation(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['description' => null], + ]); + + $this->assertResponseStatusCodeSame(422); + $content = $response->toArray(false); + $violation = $this->findViolation($content['violations'] ?? [], 'description'); + $this->assertNotNull($violation, 'Expected a violation on "description".'); + $this->assertSame((string) NotNull::IS_NULL_ERROR, $violation['code'] ?? null); + } + + public function testWrongTypeOnTypeConstrainedPropertyProduces422WithTypeViolation(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['score' => 'abc'], + ]); + + $this->assertResponseStatusCodeSame(422); + $content = $response->toArray(false); + $violation = $this->findViolation($content['violations'] ?? [], 'score'); + $this->assertNotNull($violation, 'Expected a violation on "score".'); + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violation['code'] ?? null); + } + + public function testWrongTypeWithoutConstraintProduces400(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['rawFloat' => 'abc'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testCollectMixedConstrainedAndUnconstrainedProduces422WithSpecificCodes(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources_collect', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => null, + 'score' => 'abc', + 'rawFloat' => 'abc', + ], + ]); + + $this->assertResponseStatusCodeSame(422); + $content = $response->toArray(false); + $violations = $content['violations'] ?? []; + + $nameViolation = $this->findViolation($violations, 'name'); + $this->assertNotNull($nameViolation); + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $nameViolation['code'] ?? null); + + $scoreViolation = $this->findViolation($violations, 'score'); + $this->assertNotNull($scoreViolation); + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $scoreViolation['code'] ?? null); + + // Unconstrained property still translates to a generic Type violation in collect mode + // (consistent with prior behavior — collect mode never re-throws single errors). + $rawFloatViolation = $this->findViolation($violations, 'rawFloat'); + $this->assertNotNull($rawFloatViolation); + } + + private function findViolation(array $violations, string $propertyPath): ?array + { + foreach ($violations as $violation) { + if (($violation['propertyPath'] ?? null) === $propertyPath) { + return $violation; + } + } + + return null; + } +} diff --git a/tests/Functional/DocumentationActionTest.php b/tests/Functional/DocumentationActionTest.php index 69d8ba90fde..e587e03228c 100644 --- a/tests/Functional/DocumentationActionTest.php +++ b/tests/Functional/DocumentationActionTest.php @@ -98,7 +98,7 @@ public function testJsonDocumentationIsAccessibleWhenSwaggerUiIsDisabled(): void $client->request('GET', '/docs.jsonopenapi', ['headers' => ['Accept' => 'application/vnd.openapi+json']]); $this->assertResponseIsSuccessful(); - $this->assertJsonContains(['openapi' => '3.1.0']); + $this->assertJsonContains(['openapi' => '3.2.0']); $this->assertJsonContains(['info' => ['title' => 'My Dummy API']]); } @@ -163,7 +163,7 @@ public function testJsonDocumentationIsAccessibleWhenSwaggerUiIsEnabled(): void $client->request('GET', '/docs.jsonopenapi', ['headers' => ['Accept' => 'application/vnd.openapi+json']]); $this->assertResponseIsSuccessful(); - $this->assertJsonContains(['openapi' => '3.1.0']); + $this->assertJsonContains(['openapi' => '3.2.0']); $this->assertJsonContains(['info' => ['title' => 'My Dummy API']]); } diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index 5b6e8cd587e..e469989fa2e 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -17,8 +17,6 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EnumValidationResource; use ApiPlatform\Tests\SetupClassResourcesTrait; use Composer\InstalledVersions; -use Composer\Semver\VersionParser; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** * @see https://github.com/api-platform/core/issues/8183 @@ -67,13 +65,8 @@ public function testInvalidBackedEnumValueProducesValidationViolation(): void $this->assertNotNull($genderViolation, 'Expected a constraint violation on "gender" property.'); } - #[IgnoreDeprecations] public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): void { - if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { - $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); - } - $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['gender' => 'unknown'], @@ -87,13 +80,8 @@ public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): vo /** * @see https://github.com/api-platform/core/issues/8388 */ - #[IgnoreDeprecations] public function testWrongTypeForBackedEnumReportsAcceptedScalarTypes(): void { - if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { - $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); - } - $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['gender' => true], diff --git a/tests/Functional/ExceptionToStatusTest.php b/tests/Functional/ExceptionToStatusTest.php index 66563a6d022..f95fdd9a69c 100644 --- a/tests/Functional/ExceptionToStatusTest.php +++ b/tests/Functional/ExceptionToStatusTest.php @@ -16,7 +16,7 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ErrorWithOverridenStatus; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5924\TooManyRequests; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyExceptionToStatus; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\DummyExceptionToStatus; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; diff --git a/tests/Functional/GraphQl/ParameterFilterParityTest.php b/tests/Functional/GraphQl/ParameterFilterParityTest.php new file mode 100644 index 00000000000..292f9ccfec4 --- /dev/null +++ b/tests/Functional/GraphQl/ParameterFilterParityTest.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\GraphQlFilteredResource as GraphQlFilteredResourceDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\GraphQlFilteredResourceColor as GraphQlFilteredResourceColorDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\GraphQlFilteredResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\GraphQlFilteredResourceColor; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; + +/** + * Parity safety-net: the GraphQL filter *arguments* generated from the canonical + * Operation::getParameters() (#[QueryParameter]) path must match what the legacy + * Operation::getFilters() (#[ApiFilter]) path produces on DummyCar/DummyCarColor + * (see FilterTest::testNestedCollectionFilter and the ComparisonFilter operator forms). + * + * Covers the three parity gaps the unified FieldsBuilder arg-tree pipeline closes: + * the nested `colors(prop:)` argument from a dotted parameter key, the + * ComparisonFilter gt/gte/lt/lte/ne operator form, and the `order: [..]` list shape. + */ +final class ParameterFilterParityTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + GraphQlFilteredResource::class, + GraphQlFilteredResourceColor::class, + ]; + } + + private function recreate(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? GraphQlFilteredResourceDocument::class : GraphQlFilteredResource::class, + $this->isMongoDB() ? GraphQlFilteredResourceColorDocument::class : GraphQlFilteredResourceColor::class, + ]); + } + + public function testNestedCollectionSearchArgumentFromQueryParameter(): void + { + $this->recreate(); + $this->seedResourceWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + graphQlFilteredResource(id: "/graph_ql_filtered_resources/1") { + id + colors(prop: "blue") { + edges { node { id prop } } + } + } + } + QUERY); + + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + + $edges = $json['data']['graphQlFilteredResource']['colors']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('blue', $edges[0]['node']['prop']); + } + + public function testComparisonOperatorArgumentFromQueryParameter(): void + { + $this->recreate(); + $this->seedResourceWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + graphQlFilteredResource(id: "/graph_ql_filtered_resources/1") { + id + colors(price: {gt: 10}) { + edges { node { id prop price } } + } + } + } + QUERY); + + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + + $edges = $json['data']['graphQlFilteredResource']['colors']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('blue', $edges[0]['node']['prop']); + } + + public function testRootExactSearchArgumentFromQueryParameter(): void + { + $this->recreate(); + $this->seedResourceWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + graphQlFilteredResources(name: "mustli") { + edges { node { id name } } + } + } + QUERY); + + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + + $edges = $json['data']['graphQlFilteredResources']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('mustli', $edges[0]['node']['name']); + } + + public function testOrderArgumentFromQueryParameter(): void + { + $this->recreate(); + $this->seedResourceWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + graphQlFilteredResources(order: [{name: "DESC"}]) { + edges { node { id name } } + } + } + QUERY); + + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + $this->assertResponseIsSuccessful(); + } + + private function seedResourceWithColors(): void + { + $manager = $this->getManager(); + $resourceClass = $this->isMongoDB() ? GraphQlFilteredResourceDocument::class : GraphQlFilteredResource::class; + $colorClass = $this->isMongoDB() ? GraphQlFilteredResourceColorDocument::class : GraphQlFilteredResourceColor::class; + + $resource = new $resourceClass(); + $resource->setName('mustli'); + $manager->persist($resource); + $manager->flush(); + + $red = new $colorClass(); + $red->setProp('red'); + $red->setPrice(5); + $red->setResource($resource); + $manager->persist($red); + + $blue = new $colorClass(); + $blue->setProp('blue'); + $blue->setPrice(20); + $blue->setResource($resource); + $manager->persist($blue); + $manager->flush(); + + $resource->setColors(new ArrayCollection([$red, $blue])); + $manager->persist($resource); + $manager->flush(); + } +} diff --git a/tests/Functional/HeadAllowWithoutGetTest.php b/tests/Functional/HeadAllowWithoutGetTest.php new file mode 100644 index 00000000000..09e9112ad53 --- /dev/null +++ b/tests/Functional/HeadAllowWithoutGetTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\PostNoOutputResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * RFC 9110 §10.2.1: the Allow header must advertise only methods that are actually + * valid for the target resource. HEAD is defined as GET-without-body (§9.3.2), so a + * resource that declares no GET operation does not support HEAD — a real HEAD request + * returns 405. The advertised Allow header must therefore not claim HEAD either. + */ +final class HeadAllowWithoutGetTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [PostNoOutputResource::class]; + } + + public function testHeadIsNotAdvertisedWithoutGetOperation(): void + { + $client = self::createClient(); + + $client->request('HEAD', '/jsonld_post_no_output', ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertResponseStatusCodeSame(405); + + $response = $client->request('POST', '/jsonld_post_no_output', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['lorem' => 'x'], + ]); + + $headers = array_change_key_case($response->getHeaders(false)); + $this->assertArrayHasKey('allow', $headers); + $this->assertStringNotContainsString('HEAD', $headers['allow'][0]); + } +} diff --git a/tests/Functional/HeadRequestTest.php b/tests/Functional/HeadRequestTest.php new file mode 100644 index 00000000000..1ec42d0e22f --- /dev/null +++ b/tests/Functional/HeadRequestTest.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\HeadSpyResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Bundle\FrameworkBundle\Controller\ControllerHelper; +use Symfony\Component\JsonStreamer\JsonStreamWriter; + +/** + * On a HEAD request, API Platform must skip body construction so that the (lazy) + * collection is never iterated: zero row SELECT. The spy paginator throws on + * getIterator()/count(); a HEAD that does not throw proves no iteration occurred. + */ +final class HeadRequestTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [HeadSpyResource::class]; + } + + public function testHeadDoesNotIterateCollection(): void + { + $response = self::createClient()->request('HEAD', '/head_spy_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertEmpty($response->getContent(false)); + + $headers = array_change_key_case($response->getHeaders(false)); + $this->assertArrayHasKey('content-type', $headers); + $this->assertStringStartsWith('application/ld+json', $headers['content-type'][0]); + $this->assertArrayHasKey('vary', $headers); + $this->assertStringContainsString('Accept', $headers['vary'][0]); + } + + public function testGetIteratesCollection(): void + { + self::createClient()->request('GET', '/head_spy_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(418); + } + + public function testOptionsIsUnaffected(): void + { + $response = self::createClient()->request('OPTIONS', '/head_spy_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $headers = array_change_key_case($response->getHeaders(false)); + $this->assertArrayHasKey('allow', $headers); + $this->assertStringContainsString('GET', $headers['allow'][0]); + } + + public function testHeadDoesNotIterateJsonStreamCollection(): void + { + if (false === (class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class))) { + $this->markTestSkipped('JsonStreamer component not installed.'); + } + + $response = self::createClient()->request('HEAD', '/head_spy_stream_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertEmpty($response->getContent(false)); + + $headers = array_change_key_case($response->getHeaders(false)); + $this->assertArrayHasKey('content-type', $headers); + $this->assertArrayHasKey('vary', $headers); + $this->assertStringContainsString('Accept', $headers['vary'][0]); + } +} diff --git a/tests/Functional/HeadRequestWithoutOptimizationTest.php b/tests/Functional/HeadRequestWithoutOptimizationTest.php new file mode 100644 index 00000000000..5c1ad9de14d --- /dev/null +++ b/tests/Functional/HeadRequestWithoutOptimizationTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\HeadSpyResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class HeadRequestWithoutOptimizationAppKernel extends \AppKernel +{ + public function getCacheDir(): string + { + return parent::getCacheDir().'/head_no_opt'; + } + + public function getLogDir(): string + { + return parent::getLogDir().'/head_no_opt'; + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $loader->load(static function (ContainerBuilder $container): void { + $container->loadFromExtension('api_platform', [ + 'enable_head_request_optimization' => false, + ]); + }); + } +} + +/** + * Opt-out: with enable_head_request_optimization disabled, a HEAD request must + * behave like GET again — the body is built, so the (lazy) collection IS iterated. + * The spy paginator throws a fixed 418 on iteration; seeing it proves the flag + * restores the previous GET-equivalent behavior. + */ +final class HeadRequestWithoutOptimizationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [HeadSpyResource::class]; + } + + protected static function getKernelClass(): string + { + return HeadRequestWithoutOptimizationAppKernel::class; + } + + public function testHeadIteratesCollectionWhenOptimizationDisabled(): void + { + self::createClient()->request('HEAD', '/head_spy_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(418); + } +} diff --git a/tests/Functional/JsonApi/ErrorTest.php b/tests/Functional/JsonApi/ErrorTest.php index 96a813c1d0b..f665605f19f 100644 --- a/tests/Functional/JsonApi/ErrorTest.php +++ b/tests/Functional/JsonApi/ErrorTest.php @@ -43,8 +43,7 @@ public function testErrorResourceRendersInJsonApiFormat(): void $this->assertJsonContains([ 'errors' => [ [ - // TODO: change this to '400' in 5.x - 'status' => 400, + 'status' => '400', 'detail' => 'Resource "nonexistent" not found.', ], ], @@ -91,7 +90,7 @@ public function testRfc7807ErrorRendersJsonApiFormat(): void $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); $body = $response->toArray(false); $this->assertSame('An error occurred', $body['errors'][0]['title']); - $this->assertSame(400, $body['errors'][0]['status']); + $this->assertSame('400', $body['errors'][0]['status']); $this->assertArrayHasKey('detail', $body['errors'][0]); $this->assertArrayHasKey('type', $body['errors'][0]); } @@ -110,7 +109,7 @@ public function testNotFoundRouteRendersJsonApiFormat(): void $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); $body = $response->toArray(false); $this->assertSame('An error occurred', $body['errors'][0]['title']); - $this->assertSame(404, $body['errors'][0]['status']); + $this->assertSame('404', $body['errors'][0]['status']); $this->assertArrayHasKey('detail', $body['errors'][0]); $this->assertArrayHasKey('type', $body['errors'][0]); } diff --git a/tests/Functional/JsonLd/ContextTest.php b/tests/Functional/JsonLd/ContextTest.php index dd768a9cf55..873e9d43a39 100644 --- a/tests/Functional/JsonLd/ContextTest.php +++ b/tests/Functional/JsonLd/ContextTest.php @@ -112,4 +112,31 @@ public function testEmbeddedRelationMappingIsPlainString(): void $body = $response->toArray(); $this->assertSame('JsonLdContextDummy/embedded', $body['@context']['embedded']); } + + public function testResourceLevelJsonLdContextAddsNamespacePrefixes(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://purl.org/dc/terms/', $body['@context']['dct']); + $this->assertSame('dct:title', $body['@context']['title']); + } + + public function testErrorContextIsResolvedThroughItsResource(): void + { + $response = self::createClient()->request('GET', '/contexts/Error'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('@context', $body); + $this->assertSame('http://www.w3.org/ns/hydra/core#', $body['@context']['hydra']); + } + + public function testConstraintViolationContextIsResolvedThroughItsResource(): void + { + $response = self::createClient()->request('GET', '/contexts/ConstraintViolation'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('@context', $body); + $this->assertSame('http://www.w3.org/ns/hydra/core#', $body['@context']['hydra']); + } } diff --git a/tests/Functional/JsonLd/InheritanceIriTest.php b/tests/Functional/JsonLd/InheritanceIriTest.php index 1e1e415d610..36580c4d365 100644 --- a/tests/Functional/JsonLd/InheritanceIriTest.php +++ b/tests/Functional/JsonLd/InheritanceIriTest.php @@ -49,13 +49,13 @@ public function testCollectionItemsUseConcreteSubtypeIris(): void $this->assertSame([ [ '@id' => '/contractor_5438/1', - '@type' => 'Contractor', + '@type' => 'Contractor5438', 'id' => 1, 'name' => 'a', ], [ '@id' => '/employee_5438/2', - '@type' => 'Employee', + '@type' => 'Employee5438', 'id' => 2, 'name' => 'b', ], diff --git a/tests/Functional/JsonLd/SerializableItemDataProviderTest.php b/tests/Functional/JsonLd/SerializableItemDataProviderTest.php deleted file mode 100644 index d104d93868b..00000000000 --- a/tests/Functional/JsonLd/SerializableItemDataProviderTest.php +++ /dev/null @@ -1,52 +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 ApiPlatform\Tests\Functional\JsonLd; - -use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Tests\Fixtures\TestBundle\Model\SerializableResource; -use ApiPlatform\Tests\SetupClassResourcesTrait; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; - -final class SerializableItemDataProviderTest extends ApiTestCase -{ - use SetupClassResourcesTrait; - - protected static ?bool $alwaysBootKernel = false; - - /** - * @return class-string[] - */ - public static function getResources(): array - { - return [SerializableResource::class]; - } - - #[IgnoreDeprecations] - public function testGetSerializableResource(): void - { - $this->expectUserDeprecationMessage('Since api-platform/core 4.2: The "ApiPlatform\State\SerializerAwareProviderInterface" interface is deprecated and will be removed in 5.0. It violates the dependency injection principle.'); - - self::createClient()->request('GET', '/serializable_resources/1'); - - $this->assertResponseStatusCodeSame(200); - $this->assertJsonEquals([ - '@context' => '/contexts/SerializableResource', - '@id' => '/serializable_resources/1', - '@type' => 'SerializableResource', - 'id' => 1, - 'foo' => 'Lorem', - 'bar' => 'Ipsum', - ]); - } -} diff --git a/tests/Functional/MappingTest.php b/tests/Functional/MappingTest.php index 0b3571a0e9c..35922ff4cff 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/MappingTest.php @@ -108,7 +108,7 @@ public function testShouldMapBetweenResourceAndEntity(): void /** * When an API resource has multiple #[Map] targets (e.g. MappedEntity + AnotherMappedObject), - * the ObjectMapperProcessor must resolve the correct target using stateOptions during POST. + * the ObjectMapperInputProcessor must resolve the correct target using stateOptions during POST. */ public function testPostWithMultipleMapTargetsResolvesCorrectEntity(): void { diff --git a/tests/Functional/NullOnNonNullablePropertyTest.php b/tests/Functional/NullOnNonNullablePropertyTest.php index eba8ce3f10c..d6aa24c078f 100644 --- a/tests/Functional/NullOnNonNullablePropertyTest.php +++ b/tests/Functional/NullOnNonNullablePropertyTest.php @@ -16,9 +16,6 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullOnNonNullableProperty\NullOnNonNullableResource; use ApiPlatform\Tests\SetupClassResourcesTrait; -use Composer\InstalledVersions; -use Composer\Semver\VersionParser; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** @see https://github.com/symfony/symfony/issues/64159 */ final class NullOnNonNullablePropertyTest extends ApiTestCase @@ -48,13 +45,8 @@ public function testNullOnNonNullablePropertyReturns400(): void $this->assertStringContainsString('Expected argument of type "string", "null" given at property path "name"', $body['hydra:description'] ?? $body['detail'] ?? ''); } - #[IgnoreDeprecations] public function testNullOnNonNullablePropertyReturns422WhenCollectingErrors(): void { - if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { - $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); - } - $response = self::createClient()->request('POST', '/null_on_non_nullable_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => null], diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index 51d1400d775..fb98e6e12a3 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -356,7 +356,7 @@ public function testRetrieveTheOpenApiDocumentation(): void $json = $response->toArray(); // Context - $this->assertSame('3.1.0', $json['openapi']); + $this->assertSame('3.2.0', $json['openapi']); // Root properties $this->assertSame('My Dummy API', $json['info']['title']); $this->assertStringContainsString('This is a test API.', $json['info']['description']); @@ -448,8 +448,10 @@ public function testRetrieveTheOpenApiDocumentation(): void $this->assertFalse($json['paths']['/dummies']['get']['parameters'][4]['required']); $this->assertSame('boolean', $json['paths']['/dummies']['get']['parameters'][4]['schema']['type']); - $this->assertSame('foobar[]', $json['paths']['/dummy_cars']['get']['parameters'][9]['name']); - $this->assertSame('Allows you to reduce the response to contain only the properties you need. If your desired property is nested, you can address it using nested arrays. Example: foobar[]={propertyName}&foobar[]={anotherPropertyName}&foobar[{nestedPropertyParent}][]={nestedProperty}', $json['paths']['/dummy_cars']['get']['parameters'][9]['description']); + $dummyCarParameters = $json['paths']['/dummy_cars']['get']['parameters']; + $foobarParameter = array_values(array_filter($dummyCarParameters, static fn (array $parameter): bool => 'foobar[]' === $parameter['name'])); + $this->assertCount(1, $foobarParameter); + $this->assertSame('Allows you to reduce the response to contain only the properties you need. If your desired property is nested, you can address it using nested arrays. Example: foobar[]={propertyName}&foobar[]={anotherPropertyName}&foobar[{nestedPropertyParent}][]={nestedProperty}', $foobarParameter[0]['description']); // Webhook $this->assertSame('Something else here for example', $json['webhooks']['a/{id}']['get']['description']); @@ -488,7 +490,7 @@ public function testRetrieveTheOpenApiDocumentation(): void $this->assertCount(7, $json['paths']['/related_dummies/{id}/related_to_dummy_friends']['get']['parameters']); // Subcollection - check schema - $this->assertSame('#/components/schemas/RelatedToDummyFriend.jsonld-fakemanytomany', $json['paths']['/related_dummies/{id}/related_to_dummy_friends']['get']['responses']['200']['content']['application/ld+json']['schema']['allOf'][1]['properties']['hydra:member']['items']['$ref']); + $this->assertSame('#/components/schemas/RelatedToDummyFriend4.jsonld-fakemanytomany', $json['paths']['/related_dummies/{id}/related_to_dummy_friends']['get']['responses']['200']['content']['application/ld+json']['schema']['allOf'][1]['properties']['hydra:member']['items']['$ref']); // Deprecations $this->assertTrue($json['paths']['/deprecated_resources']['get']['deprecated']); @@ -589,7 +591,7 @@ public function testRetrieveTheJsonOpenApiDocumentation(): void $json = $response->toArray(); // Context - $this->assertSame('3.1.0', $json['openapi']); + $this->assertSame('3.2.0', $json['openapi']); // Root properties $this->assertSame('My Dummy API', $json['info']['title']); $this->assertStringContainsString('This is a test API.', $json['info']['description']); diff --git a/tests/Functional/Parameters/BooleanFilterTest.php b/tests/Functional/Parameters/BooleanFilterTest.php index a856ea33790..a5dd382ac53 100644 --- a/tests/Functional/Parameters/BooleanFilterTest.php +++ b/tests/Functional/Parameters/BooleanFilterTest.php @@ -76,24 +76,21 @@ public static function booleanFilterScenariosProvider(): \Generator yield 'enabled_alias_numeric_0' => ['/filtered_boolean_parameters?enabled=0', 1, false]; } - #[DataProvider('booleanFilterNullAndEmptyScenariosProvider')] - public function testBooleanFilterWithNullAndEmptyValues(string $url): void + /** + * An empty value cannot be cast to the boolean native type, so the caster rejects it with a + * Bad Request before the filter runs. + */ + #[DataProvider('booleanFilterEmptyScenariosProvider')] + public function testBooleanFilterWithEmptyValues(string $url): void { - $response = self::createClient()->request('GET', $url); - $this->assertResponseIsSuccessful(); - - $responseData = $response->toArray(); - $filteredItems = $responseData['hydra:member']; + self::createClient()->request('GET', $url); - $expectedItemCount = 3; - $this->assertCount($expectedItemCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedItemCount, $url)); + $this->assertResponseStatusCodeSame(400); } - public static function booleanFilterNullAndEmptyScenariosProvider(): \Generator + public static function booleanFilterEmptyScenariosProvider(): \Generator { - yield 'active_null_value' => ['/filtered_boolean_parameters?active=null']; yield 'active_empty_value' => ['/filtered_boolean_parameters?active=']; - yield 'enabled_alias_null_value' => ['/filtered_boolean_parameters?enabled=null']; yield 'enabled_alias_empty_value' => ['/filtered_boolean_parameters?enabled=']; } diff --git a/tests/Functional/Parameters/DateFilterTest.php b/tests/Functional/Parameters/DateFilterTest.php index 72c90d214ef..dfe9a56be38 100644 --- a/tests/Functional/Parameters/DateFilterTest.php +++ b/tests/Functional/Parameters/DateFilterTest.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Tests\Functional\Parameters; +use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilteredDateParameter as FilteredDateParameterDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredDateParameter; @@ -36,6 +38,15 @@ public static function getResources(): array return [FilteredDateParameter::class]; } + public function testDateFilterIsStandalone(): void + { + self::assertNotContains( + AbstractFilter::class, + class_parents(DateFilter::class) ?: [], + 'DateFilter must not extend the deprecated AbstractFilter (5.0 standalone rewrite).' + ); + } + /** * @throws \Throwable */ diff --git a/tests/Functional/Parameters/EndSearchFilterTest.php b/tests/Functional/Parameters/EndSearchFilterTest.php new file mode 100644 index 00000000000..a6e45d1e576 --- /dev/null +++ b/tests/Functional/Parameters/EndSearchFilterTest.php @@ -0,0 +1,215 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Owner as DocumentOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Owner; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; + +final class EndSearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class, Owner::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class, DocumentOwner::class] + : [Chicken::class, ChickenCoop::class, Owner::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + #[DataProvider('endSearchFilterProvider')] + public function testEndSearchFilter(string $url, int $expectedCount, array $expectedNames): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $names = array_map(static fn ($chicken) => $chicken['name'], $filteredItems); + sort($names); + sort($expectedNames); + + $this->assertSame($expectedNames, $names, 'The returned names do not match the expected values.'); + } + + public static function endSearchFilterProvider(): \Generator + { + yield 'filter by ending "rude"' => [ + '/chickens?nameEnd=rude', + 1, + ['Gertrude'], + ]; + + yield 'filter by ending "tte"' => [ + '/chickens?nameEnd=tte', + 1, + ['Henriette'], + ]; + + yield 'filter by ending "e" (should match both)' => [ + '/chickens?nameEnd=e', + 2, + ['Gertrude', 'Henriette'], + ]; + + yield 'filter by ending "rud" (must not match — not a suffix)' => [ + '/chickens?nameEnd=rud', + 0, + [], + ]; + + yield 'filter by ending with no matching entities' => [ + '/chickens?nameEnd=Zebra', + 0, + [], + ]; + + yield 'filter by ending "xx"' => [ + '/chickens?nameEnd=xx', + 1, + ['xx_%_\\_%_xx'], + ]; + + yield 'filter with multiple endings "rude" OR "tte"' => [ + '/chickens?nameEnd[]=rude&nameEnd[]=tte', + 2, + ['Gertrude', 'Henriette'], + ]; + + yield 'filter with multiple endings, one matching "rude", the other not matching "Zebra"' => [ + '/chickens?nameEnd[]=rude&nameEnd[]=Zebra', + 1, + ['Gertrude'], + ]; + } + + public function testEndSearchFilterThrowsExceptionWhenPropertyIsMissing(): void + { + $response = self::createClient()->request('GET', '/chickens?nameEndNoProperty=rude'); + $this->assertResponseStatusCodeSame(400); + + $responseData = $response->toArray(false); + + $this->assertStringContainsString( + 'The filter parameter with key "nameEndNoProperty" must specify a property', + $responseData['detail'] + ); + } + + #[DataProvider('endSearchFilterCaseSensitiveProvider')] + public function testEndSearchCaseSensitiveFilter(string $url, int $expectedCount, array $expectedNames): void + { + if ($this->isMysql() || $this->isSqlite()) { + $this->markTestSkipped('Mysql and sqlite use case insensitive LIKE.'); + } + + $this->testEndSearchFilter($url, $expectedCount, $expectedNames); + } + + public static function endSearchFilterCaseSensitiveProvider(): \Generator + { + yield 'case insensitive ending "rude"' => [ + '/chickens?nameEnd=RUDE', + 1, + ['Gertrude'], + ]; + + yield 'case sensitive ending "rude"' => [ + '/chickens?nameEndSensitive=rude', + 1, + ['Gertrude'], + ]; + + yield 'case sensitive ending "RUDE"' => [ + '/chickens?nameEndSensitive=RUDE', + 0, + [], + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + $ownerClass = $this->isMongoDB() ? DocumentOwner::class : Owner::class; + + $owner1 = new $ownerClass(); + $owner1->setName('Alice'); + + $manager->persist($owner1); + $manager->flush(); + + $chickenCoop1 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); + $chicken1->setOwner($owner1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop1); + $chicken2->setOwner($owner1); + + $chicken3 = new $chickenClass(); + $chicken3->setName('xx_%_\\_%_xx'); + $chicken3->setChickenCoop($chickenCoop1); + $chicken3->setOwner($owner1); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop1->addChicken($chicken2); + $chickenCoop1->addChicken($chicken3); + + $manager->persist($chickenCoop1); + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chicken3); + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/ExistsFilterTest.php b/tests/Functional/Parameters/ExistsFilterTest.php index 122854154bf..18b20d4fbfa 100644 --- a/tests/Functional/Parameters/ExistsFilterTest.php +++ b/tests/Functional/Parameters/ExistsFilterTest.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Tests\Functional\Parameters; +use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilteredExistsParameter as FilteredExistsParameterDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredExistsParameter; @@ -50,6 +52,15 @@ protected function setUp(): void $this->loadFixtures($entityClass); } + public function testExistsFilterIsStandalone(): void + { + self::assertNotContains( + AbstractFilter::class, + class_parents(ExistsFilter::class) ?: [], + 'ExistsFilter must not extend the deprecated AbstractFilter (5.0 standalone rewrite).' + ); + } + #[DataProvider('existsFilterScenariosProvider')] public function testExistsFilterResponses(string $url, int $expectedCount): void { diff --git a/tests/Functional/Parameters/FreeTextQueryFilterTest.php b/tests/Functional/Parameters/FreeTextQueryFilterTest.php index 31e051b51f6..b09f8c6ef47 100644 --- a/tests/Functional/Parameters/FreeTextQueryFilterTest.php +++ b/tests/Functional/Parameters/FreeTextQueryFilterTest.php @@ -100,6 +100,23 @@ public function testFreeTextQueryFilterWithTwoLevelTraversalPartial(): void $this->assertCount(2, $response['member']); } + public function testFreeTextQueryFilterWithPerPropertyFilterMap(): void + { + $client = $this->createClient(); + + $response = $client->request('GET', '/chickens?qmixed=Henri')->toArray(); + $this->assertJsonContains(['totalItems' => 1]); + $this->assertSame('Henriette', $response['member'][0]['name']); + + $response = $client->request('GET', '/chickens?qmixed=978020137963')->toArray(); + $this->assertJsonContains(['totalItems' => 1]); + $this->assertSame('978020137963', $response['member'][0]['ean']); + + $response = $client->request('GET', '/chickens?qmixed=97802')->toArray(); + $this->assertJsonContains(['totalItems' => 1]); + $this->assertSame('978020137962', $response['member'][0]['name']); + } + public function testFreeTextQueryFilterWithTwoLevelTraversalPartialWithPropertyPlaceholder(): void { $client = $this->createClient(); diff --git a/tests/Functional/Parameters/Legacy/AttributeFilterLegacyTest.php b/tests/Functional/Parameters/Legacy/AttributeFilterLegacyTest.php new file mode 100644 index 00000000000..57c105e6a0e --- /dev/null +++ b/tests/Functional/Parameters/Legacy/AttributeFilterLegacyTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters\Legacy; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy\FilteredAttributeParameter as FilteredAttributeParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\FilteredAttributeParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Regression coverage for the deprecated #[ApiFilter] attribute declaration of the surviving + * Date/Range/Exists filters. The canonical QueryParameter form is covered by the + * Date/Range/ExistsFilterTest classes. Remove together with the #[ApiFilter] attribute in 6.0. + */ +#[Group('legacy')] +final class AttributeFilterLegacyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredAttributeParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredAttributeParameterDocument::class : FilteredAttributeParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + #[DataProvider('attributeFilterScenariosProvider')] + public function testAttributeFilterResponses(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + + $this->assertCount($expectedCount, $responseData['hydra:member'], \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function attributeFilterScenariosProvider(): \Generator + { + // DateFilter via #[ApiFilter] + yield 'date_after' => ['/legacy_filtered_attribute_parameters?createdAt[after]=2024-06-01', 2]; + yield 'date_before' => ['/legacy_filtered_attribute_parameters?createdAt[before]=2024-06-01', 1]; + // RangeFilter via #[ApiFilter] + yield 'range_gt' => ['/legacy_filtered_attribute_parameters?quantity[gt]=15', 2]; + yield 'range_lt' => ['/legacy_filtered_attribute_parameters?quantity[lt]=15', 1]; + // ExistsFilter via #[ApiFilter] + yield 'exists_true' => ['/legacy_filtered_attribute_parameters?exists[description]=true', 2]; + yield 'exists_false' => ['/legacy_filtered_attribute_parameters?exists[description]=false', 1]; + } + + /** + * @throws \Throwable + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + $rows = [ + [new \DateTimeImmutable('2024-01-01'), 10, 'a'], + [new \DateTimeImmutable('2024-06-15'), 20, null], + [new \DateTimeImmutable('2024-12-25'), 30, 'c'], + ]; + + foreach ($rows as [$createdAt, $quantity, $description]) { + $manager->persist(new $entityClass(createdAt: $createdAt, quantity: $quantity, description: $description)); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/Legacy/BackedEnumFilterLegacyTest.php b/tests/Functional/Parameters/Legacy/BackedEnumFilterLegacyTest.php new file mode 100644 index 00000000000..9111ff689ee --- /dev/null +++ b/tests/Functional/Parameters/Legacy/BackedEnumFilterLegacyTest.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters\Legacy; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7126\IntegerBackedEnum; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7126\StringBackedEnum; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\DummyForBackedEnumFilter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * Regression coverage for the deprecated #[ApiFilter(BackedEnumFilter)] attribute path. + * The canonical equivalent is covered by ApiPlatform\Tests\Functional\BackedEnumFilterTest. + * Remove together with the deprecated filters in 6.0. + */ +#[Group('legacy')] +final class BackedEnumFilterLegacyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyForBackedEnumFilter::class]; + } + + public function testFilterStringBackedEnum(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema($this->getResources()); + $this->loadFixtures(); + $response = self::createClient()->request('GET', 'legacy_backed_enum_filter?stringBackedEnum='.StringBackedEnum::One->value); + $a = $response->toArray(); + $this->assertCount(1, $a['hydra:member']); + $this->assertEquals(StringBackedEnum::One->value, $a['hydra:member'][0]['stringBackedEnum']); + } + + public function testFilterIntegerBackedEnum(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema($this->getResources()); + $this->loadFixtures(); + $response = self::createClient()->request('GET', 'legacy_backed_enum_filter?integerBackedEnum='.IntegerBackedEnum::Two->value); + $a = $response->toArray(); + $this->assertCount(1, $a['hydra:member']); + $this->assertEquals(IntegerBackedEnum::Two->value, $a['hydra:member'][0]['integerBackedEnum']); + } + + public function loadFixtures(): void + { + $container = static::$kernel->getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + + $dummyOne = new DummyForBackedEnumFilter(); + $dummyOne->setStringBackedEnum(StringBackedEnum::One); + $dummyOne->setIntegerBackedEnum(IntegerBackedEnum::One); + $manager->persist($dummyOne); + + $dummyTwo = new DummyForBackedEnumFilter(); + $dummyTwo->setStringBackedEnum(StringBackedEnum::Two); + $dummyTwo->setIntegerBackedEnum(IntegerBackedEnum::Two); + $manager->persist($dummyTwo); + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/Legacy/BooleanFilterLegacyTest.php b/tests/Functional/Parameters/Legacy/BooleanFilterLegacyTest.php new file mode 100644 index 00000000000..27b6e818eb0 --- /dev/null +++ b/tests/Functional/Parameters/Legacy/BooleanFilterLegacyTest.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters\Legacy; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy\FilteredBooleanParameter as FilteredBooleanParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\FilteredBooleanParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Regression coverage for the deprecated BooleanFilter. The canonical equivalent + * (ExactFilter + boolean nativeType) is covered by + * ApiPlatform\Tests\Functional\Parameters\BooleanFilterTest. + * Remove together with the deprecated filter in 6.0. + */ +#[Group('legacy')] +final class BooleanFilterLegacyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredBooleanParameter::class]; + } + + /** + * @throws MongoDBException + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredBooleanParameterDocument::class : FilteredBooleanParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + #[DataProvider('booleanFilterScenariosProvider')] + public function testBooleanFilterResponses(string $url, int $expectedActiveItemCount, bool $expectedActiveStatus): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedActiveItemCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedActiveItemCount, $url)); + + foreach ($filteredItems as $item) { + $this->assertSame($expectedActiveStatus, $item['active'], \sprintf("Expected 'active' to be %s", $expectedActiveStatus)); + } + } + + public static function booleanFilterScenariosProvider(): \Generator + { + yield 'active_true' => ['/legacy_filtered_boolean_parameters?active=true', 2, true]; + yield 'active_false' => ['/legacy_filtered_boolean_parameters?active=false', 1, false]; + yield 'active_numeric_1' => ['/legacy_filtered_boolean_parameters?active=1', 2, true]; + yield 'active_numeric_0' => ['/legacy_filtered_boolean_parameters?active=0', 1, false]; + yield 'enabled_alias_true' => ['/legacy_filtered_boolean_parameters?enabled=true', 2, true]; + yield 'enabled_alias_false' => ['/legacy_filtered_boolean_parameters?enabled=false', 1, false]; + yield 'enabled_alias_numeric_1' => ['/legacy_filtered_boolean_parameters?enabled=1', 2, true]; + yield 'enabled_alias_numeric_0' => ['/legacy_filtered_boolean_parameters?enabled=0', 1, false]; + } + + #[DataProvider('booleanFilterNullAndEmptyScenariosProvider')] + public function testBooleanFilterWithNullAndEmptyValues(string $url): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $expectedItemCount = 3; + $this->assertCount($expectedItemCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedItemCount, $url)); + } + + public static function booleanFilterNullAndEmptyScenariosProvider(): \Generator + { + yield 'active_null_value' => ['/legacy_filtered_boolean_parameters?active=null']; + yield 'active_empty_value' => ['/legacy_filtered_boolean_parameters?active=']; + yield 'enabled_alias_null_value' => ['/legacy_filtered_boolean_parameters?enabled=null']; + yield 'enabled_alias_empty_value' => ['/legacy_filtered_boolean_parameters?enabled=']; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + $booleanStates = [true, true, false, null]; + foreach ($booleanStates as $activeValue) { + $entity = new $entityClass(active: $activeValue); + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/Legacy/NumericFilterLegacyTest.php b/tests/Functional/Parameters/Legacy/NumericFilterLegacyTest.php new file mode 100644 index 00000000000..2c17aa56537 --- /dev/null +++ b/tests/Functional/Parameters/Legacy/NumericFilterLegacyTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters\Legacy; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy\FilteredNumericParameter as FilteredNumericParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\FilteredNumericParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Regression coverage for the deprecated NumericFilter. The canonical equivalent + * (ExactFilter + numeric nativeType) is covered by + * ApiPlatform\Tests\Functional\Parameters\NumericFilterTest. + * Remove together with the deprecated filter in 6.0. + */ +#[Group('legacy')] +final class NumericFilterLegacyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredNumericParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredNumericParameterDocument::class : FilteredNumericParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + #[DataProvider('rangeFilterScenariosProvider')] + public function testRangeFilterResponses(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function rangeFilterScenariosProvider(): \Generator + { + yield 'quantity_int_equal' => ['/legacy_filtered_numeric_parameters?quantity=10', 1]; + yield 'ratio_float_equal' => ['/legacy_filtered_numeric_parameters?ratio=1.0', 2]; + yield 'amount_alias_int_equal' => ['/legacy_filtered_numeric_parameters?amount=20', 2]; + } + + #[DataProvider('nullAndEmptyScenariosProvider')] + public function testRangeFilterWithNullAndEmptyValues(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function nullAndEmptyScenariosProvider(): \Generator + { + yield 'quantity_int_null_value' => ['/legacy_filtered_numeric_parameters?quantity=null', 4]; + yield 'quantity_int_empty_value' => ['/legacy_filtered_numeric_parameters?quantity=', 4]; + yield 'ratio_float_null_value' => ['/legacy_filtered_numeric_parameters?ratio=null', 4]; + yield 'ratio_float_empty_value' => ['/legacy_filtered_numeric_parameters?ratio=', 4]; + yield 'amount_alias_int_null_value' => ['/legacy_filtered_numeric_parameters?amount=null', 4]; + yield 'amount_alias_int_empty_value' => ['/legacy_filtered_numeric_parameters?amount=', 4]; + } + + /** + * @throws \Throwable + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + foreach ([[10, 1.0], [20, 2.0], [30, 3.0], [20, 1.0]] as [$quantity, $ratio]) { + $entity = new $entityClass(quantity: $quantity, ratio: $ratio); + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/Legacy/OrderFilterLegacyTest.php b/tests/Functional/Parameters/Legacy/OrderFilterLegacyTest.php new file mode 100644 index 00000000000..69a0ede780e --- /dev/null +++ b/tests/Functional/Parameters/Legacy/OrderFilterLegacyTest.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters\Legacy; + +use ApiPlatform\Doctrine\Odm\Filter\OrderFilter; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy\FilteredOrderParameter as FilteredOrderParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\FilteredOrderParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Regression coverage for the deprecated OrderFilter, including its per-property `properties` + * nulls_comparison config form. The canonical equivalent (SortFilter) is covered by + * ApiPlatform\Tests\Functional\Parameters\OrderFilterTest. + * Remove together with the deprecated filter in 6.0. + */ +#[Group('legacy')] +final class OrderFilterLegacyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredOrderParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredOrderParameterDocument::class : FilteredOrderParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + #[DataProvider('orderFilterScenariosProvider')] + public function testOrderFilterResponses(string $url, array $expectedOrder): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $orderedItems = $responseData['hydra:member']; + + $actualOrder = array_map(static fn ($item) => $item['createdAt'] ?? null, $orderedItems); + + // Default NULL order is different in PostgreSQL. + if ($this->isPostgres()) { + $actualOrder = array_values(array_filter($actualOrder)); + $expectedOrder = array_values(array_filter($expectedOrder)); + } + + $this->assertSame($expectedOrder, $actualOrder, \sprintf('Expected order does not match for URL %s', $url)); + } + + public static function orderFilterScenariosProvider(): \Generator + { + yield 'created_at_ordered_asc' => [ + '/legacy_filtered_order_parameters?createdAt=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'created_at_ordered_desc' => [ + '/legacy_filtered_order_parameters?createdAt=desc', + ['2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00', null], + ]; + yield 'date_alias_ordered_asc' => [ + '/legacy_filtered_order_parameters?date=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'date_alias_ordered_desc' => [ + '/legacy_filtered_order_parameters?date=desc', + ['2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00', null], + ]; + } + + #[DataProvider('orderFilterNullsComparisonScenariosProvider')] + public function testOrderFilterNullsComparisonResponses(string $url, array $expectedOrder): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(\sprintf('Not implemented in %s', OrderFilter::class)); + } + + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $orderedItems = $responseData['hydra:member']; + + $actualOrder = array_map(static fn ($item) => $item['createdAt'] ?? null, $orderedItems); + + $this->assertSame($expectedOrder, $actualOrder, \sprintf('Expected order does not match for URL %s', $url)); + } + + public static function orderFilterNullsComparisonScenariosProvider(): \Generator + { + yield 'date_null_always_first_alias_asc' => [ + '/legacy_filtered_order_parameters?date_null_always_first=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'date_null_always_first_alias_desc' => [ + '/legacy_filtered_order_parameters?date_null_always_first=desc', + [null, '2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00'], + ]; + yield 'date_null_always_first_old_way_alias_asc' => [ + '/legacy_filtered_order_parameters?date_null_always_first_old_way=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'date_null_always_first_old_way_alias_desc' => [ + '/legacy_filtered_order_parameters?date_null_always_first_old_way=desc', + [null, '2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00'], + ]; + yield 'order_property_created_at_null_first_asc' => [ + '/legacy_filtered_order_parameters?order[createdAt]=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'order_property_created_at_null_first_desc' => [ + '/legacy_filtered_order_parameters?order[createdAt]=desc', + [null, '2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00'], + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + $dates = [ + new \DateTimeImmutable('2024-01-01'), + new \DateTimeImmutable('2024-12-25'), + null, + new \DateTimeImmutable('2024-06-15'), + ]; + + foreach ($dates as $createdAtValue) { + $entity = new $entityClass(createdAt: $createdAtValue); + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/Legacy/SearchFilterParameterLegacyTest.php b/tests/Functional/Parameters/Legacy/SearchFilterParameterLegacyTest.php index 7d4344b84e0..f41abf0128d 100644 --- a/tests/Functional/Parameters/Legacy/SearchFilterParameterLegacyTest.php +++ b/tests/Functional/Parameters/Legacy/SearchFilterParameterLegacyTest.php @@ -14,8 +14,8 @@ namespace ApiPlatform\Tests\Functional\Parameters\Legacy; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SearchFilterParameter as SearchFilterParameterDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SearchFilterParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy\SearchFilterParameter as SearchFilterParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\SearchFilterParameter; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; use PHPUnit\Framework\Attributes\DataProvider; @@ -48,7 +48,7 @@ public function testDoctrineEntitySearchFilter(): void $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; $this->recreateSchema([$resource]); $this->loadFixtures($resource); - $route = 'search_filter_parameter'; + $route = 'legacy_search_filter_parameter'; $response = self::createClient()->request('GET', $route.'?foo=bar'); $a = $response->toArray(); $this->assertCount(2, $a['hydra:member']); @@ -119,7 +119,7 @@ public function testPropertyPlaceholderFilter(): void $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; $this->recreateSchema([$resource]); $this->loadFixtures($resource); - $route = 'search_filter_parameter'; + $route = 'legacy_search_filter_parameter'; $response = self::createClient()->request('GET', $route.'?foo=baz'); $a = $response->toArray(); $this->assertEquals($a['hydra:member'][0]['foo'], 'baz'); @@ -156,19 +156,19 @@ public static function partialFilterParameterProviderForSearchFilterParameter(): // 1x foo = 'baz' yield 'partial match on foo (fo -> 3x foo)' => [ - '/search_filter_parameter?searchPartial[foo]=fo', + '/legacy_search_filter_parameter?searchPartial[foo]=fo', 3, ['foo', 'foo', 'foo'], ]; yield 'partial match on foo (ba -> 2x bar, 1x baz)' => [ - '/search_filter_parameter?searchPartial[foo]=ba', + '/legacy_search_filter_parameter?searchPartial[foo]=ba', 3, ['bar', 'bar', 'baz'], ]; yield 'partial match on foo (az -> 1x baz)' => [ - '/search_filter_parameter?searchPartial[foo]=az', + '/legacy_search_filter_parameter?searchPartial[foo]=az', 1, ['baz'], ]; diff --git a/tests/Functional/Parameters/LinkProviderParameterTest.php b/tests/Functional/Parameters/LinkProviderParameterTest.php index 97025821195..cd7ef7d5a4e 100644 --- a/tests/Functional/Parameters/LinkProviderParameterTest.php +++ b/tests/Functional/Parameters/LinkProviderParameterTest.php @@ -15,6 +15,9 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7469TestResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7939BarResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7939BazResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7939FooResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkParameterProviderResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithParameter; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Company; @@ -40,7 +43,7 @@ final class LinkProviderParameterTest extends ApiTestCase */ public static function getResources(): array { - return [WithParameter::class, Dummy::class, Employee::class, Company::class, LinkParameterProviderResource::class, Issue7469TestResource::class, Issue7469Dummy::class, Pairing::class, Plan::class]; + return [WithParameter::class, Dummy::class, Employee::class, Company::class, LinkParameterProviderResource::class, Issue7469TestResource::class, Issue7469Dummy::class, Pairing::class, Plan::class, Issue7939FooResource::class, Issue7939BarResource::class, Issue7939BazResource::class]; } /** @@ -236,6 +239,47 @@ public function testSecurityLinkWithDifferentFromClassDoesNotBreakDoctrine(): vo ]); } + /** + * @see https://github.com/api-platform/core/issues/7939 + */ + public function testReadLinkParameterProviderResolvesNestedUriVariables(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('GET', '/issue7939_foos/F/bars/B/baz'); + self::assertResponseStatusCodeSame(200); + self::assertJsonContains([ + 'fooId' => 'F', + 'barId' => 'B', + ]); + } + + /** + * @see https://github.com/api-platform/core/issues/7939 + */ + public function testParentLinkProviderEnforcesParentScope(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $client = self::createClient(); + + $client->request('GET', '/issue7939_foos/F2/bars/B/baz_strict'); + self::assertResponseStatusCodeSame(200); + self::assertJsonContains([ + 'fooId' => 'F2', + 'barId' => 'B', + ]); + + $client->request('GET', '/issue7939_foos/F1/bars/B/baz_strict'); + self::assertResponseStatusCodeSame(404); + } + public function testIssue7469IriGenerationFailsForLinkedResource(): void { $container = static::getContainer(); diff --git a/tests/Functional/Parameters/NumericFilterTest.php b/tests/Functional/Parameters/NumericFilterTest.php index 03512ac1462..bcae5ab1930 100644 --- a/tests/Functional/Parameters/NumericFilterTest.php +++ b/tests/Functional/Parameters/NumericFilterTest.php @@ -66,26 +66,23 @@ public static function rangeFilterScenariosProvider(): \Generator yield 'amount_alias_int_equal' => ['/filtered_numeric_parameters?amount=20', 2]; } - #[DataProvider('nullAndEmptyScenariosProvider')] - public function testRangeFilterWithNullAndEmptyValues(string $url, int $expectedCount): void + /** + * An empty value cannot be cast to the int/float native type, so the caster rejects it with a + * Bad Request before the filter runs. + */ + #[DataProvider('emptyScenariosProvider')] + public function testRangeFilterWithEmptyValues(string $url): void { - $response = self::createClient()->request('GET', $url); - $this->assertResponseIsSuccessful(); + self::createClient()->request('GET', $url); - $responseData = $response->toArray(); - $filteredItems = $responseData['hydra:member']; - - $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + $this->assertResponseStatusCodeSame(400); } - public static function nullAndEmptyScenariosProvider(): \Generator + public static function emptyScenariosProvider(): \Generator { - yield 'quantity_int_null_value' => ['/filtered_numeric_parameters?quantity=null', 4]; - yield 'quantity_int_empty_value' => ['/filtered_numeric_parameters?quantity=', 4]; - yield 'ratio_float_null_value' => ['/filtered_numeric_parameters?ratio=null', 4]; - yield 'ratio_float_empty_value' => ['/filtered_numeric_parameters?ratio=', 4]; - yield 'amount_alias_int_null_value' => ['/filtered_numeric_parameters?amount=null', 4]; - yield 'amount_alias_int_empty_value' => ['/filtered_numeric_parameters?amount=', 4]; + yield 'quantity_int_empty_value' => ['/filtered_numeric_parameters?quantity=']; + yield 'ratio_float_empty_value' => ['/filtered_numeric_parameters?ratio=']; + yield 'amount_alias_int_empty_value' => ['/filtered_numeric_parameters?amount=']; } /** diff --git a/tests/Functional/Parameters/OrderFilterTest.php b/tests/Functional/Parameters/OrderFilterTest.php index 368ca970289..e53942f5dfa 100644 --- a/tests/Functional/Parameters/OrderFilterTest.php +++ b/tests/Functional/Parameters/OrderFilterTest.php @@ -124,14 +124,6 @@ public static function orderFilterNullsComparisonScenariosProvider(): \Generator '/filtered_order_parameters?date_null_always_first=desc', [null, '2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00'], ]; - yield 'date_null_always_first_old_way_alias_asc' => [ - '/filtered_order_parameters?date_null_always_first_old_way=asc', - [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], - ]; - yield 'date_null_always_first_old_way_alias_desc' => [ - '/filtered_order_parameters?date_null_always_first_old_way=desc', - [null, '2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00'], - ]; yield 'order_property_created_at_null_first_asc' => [ '/filtered_order_parameters?order[createdAt]=asc', [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], diff --git a/tests/Functional/Parameters/StartSearchFilterTest.php b/tests/Functional/Parameters/StartSearchFilterTest.php new file mode 100644 index 00000000000..fda860ec295 --- /dev/null +++ b/tests/Functional/Parameters/StartSearchFilterTest.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 ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Owner as DocumentOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Owner; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; + +final class StartSearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class, Owner::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class, DocumentOwner::class] + : [Chicken::class, ChickenCoop::class, Owner::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + #[DataProvider('startSearchFilterProvider')] + public function testStartSearchFilter(string $url, int $expectedCount, array $expectedNames): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $names = array_map(static fn ($chicken) => $chicken['name'], $filteredItems); + sort($names); + sort($expectedNames); + + $this->assertSame($expectedNames, $names, 'The returned names do not match the expected values.'); + } + + public static function startSearchFilterProvider(): \Generator + { + yield 'filter by prefix "Gert"' => [ + '/chickens?nameStart=Gert', + 1, + ['Gertrude'], + ]; + + yield 'filter by prefix "Hen"' => [ + '/chickens?nameStart=Hen', + 1, + ['Henriette'], + ]; + + yield 'prefix in the middle does not match (start anchored)' => [ + '/chickens?nameStart=rude', + 0, + [], + ]; + + yield 'filter by prefix with no matching entities' => [ + '/chickens?nameStart=Zebra', + 0, + [], + ]; + + yield 'filter with multiple prefixes "Gert" OR "Hen"' => [ + '/chickens?nameStart[]=Gert&nameStart[]=Hen', + 2, + ['Gertrude', 'Henriette'], + ]; + + yield 'filter by prefix "xx_"' => [ + '/chickens?nameStart=xx_', + 1, + ['xx_%_\\_%_xx'], + ]; + } + + public function testStartSearchFilterThrowsExceptionWhenPropertyIsMissing(): void + { + $response = self::createClient()->request('GET', '/chickens?nameStartNoProperty=Gert'); + $this->assertResponseStatusCodeSame(400); + + $responseData = $response->toArray(false); + + $this->assertStringContainsString( + 'The filter parameter with key "nameStartNoProperty" must specify a property', + $responseData['detail'] + ); + } + + #[DataProvider('startSearchFilterCaseSensitiveProvider')] + public function testStartSearchCaseSensitiveFilter(string $url, int $expectedCount, array $expectedNames): void + { + if ($this->isMysql() || $this->isSqlite()) { + $this->markTestSkipped('Mysql and sqlite use case insensitive LIKE.'); + } + + $this->testStartSearchFilter($url, $expectedCount, $expectedNames); + } + + public static function startSearchFilterCaseSensitiveProvider(): \Generator + { + yield 'filter by prefix "gert"' => [ + '/chickens?nameStart=gert', + 1, + ['Gertrude'], + ]; + + yield 'filter by case sensitive prefix "Gert"' => [ + '/chickens?nameStartSensitive=Gert', + 1, + ['Gertrude'], + ]; + + yield 'filter by case sensitive prefix "gert"' => [ + '/chickens?nameStartSensitive=gert', + 0, + [], + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + $ownerClass = $this->isMongoDB() ? DocumentOwner::class : Owner::class; + + $owner1 = new $ownerClass(); + $owner1->setName('Alice'); + + $manager->persist($owner1); + $manager->flush(); + + $chickenCoop1 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); + $chicken1->setOwner($owner1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop1); + $chicken2->setOwner($owner1); + + $chicken3 = new $chickenClass(); + $chicken3->setName('xx_%_\\_%_xx'); + $chicken3->setChickenCoop($chickenCoop1); + $chicken3->setOwner($owner1); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop1->addChicken($chicken2); + $chickenCoop1->addChicken($chicken3); + + $manager->persist($chickenCoop1); + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chicken3); + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/WordStartSearchFilterTest.php b/tests/Functional/Parameters/WordStartSearchFilterTest.php new file mode 100644 index 00000000000..00edaf0ecad --- /dev/null +++ b/tests/Functional/Parameters/WordStartSearchFilterTest.php @@ -0,0 +1,185 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Owner as DocumentOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Owner; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; + +final class WordStartSearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class, Owner::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class, DocumentOwner::class] + : [Chicken::class, ChickenCoop::class, Owner::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + #[DataProvider('wordStartSearchFilterProvider')] + public function testWordStartSearchFilter(string $url, int $expectedCount, array $expectedNames): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $names = array_map(static fn ($chicken) => $chicken['name'], $filteredItems); + sort($names); + sort($expectedNames); + + $this->assertSame($expectedNames, $names, 'The returned names do not match the expected values.'); + } + + public static function wordStartSearchFilterProvider(): \Generator + { + // Fixtures: "Gertrude the Hen", "Henriette", "Red Rooster" + yield 'matches word at the very start' => [ + '/chickens?nameWordStart=Gert', + 1, + ['Gertrude the Hen'], + ]; + + yield 'matches a word starting in the middle of the string' => [ + '/chickens?nameWordStart=Hen', + 2, + ['Gertrude the Hen', 'Henriette'], + ]; + + yield 'does not match a substring inside a word' => [ + '/chickens?nameWordStart=ette', + 0, + [], + ]; + + yield 'does not match a substring inside a non-leading word' => [ + '/chickens?nameWordStart=ooster', + 0, + [], + ]; + + yield 'matches the leading word of a multi-word value' => [ + '/chickens?nameWordStart=Red', + 1, + ['Red Rooster'], + ]; + + yield 'matches a trailing word' => [ + '/chickens?nameWordStart=Roo', + 1, + ['Red Rooster'], + ]; + + yield 'no match' => [ + '/chickens?nameWordStart=Zebra', + 0, + [], + ]; + + yield 'multiple values "Gert" OR "Red"' => [ + '/chickens?nameWordStart[]=Gert&nameWordStart[]=Red', + 2, + ['Gertrude the Hen', 'Red Rooster'], + ]; + } + + public function testWordStartSearchFilterThrowsExceptionWhenPropertyIsMissing(): void + { + $response = self::createClient()->request('GET', '/chickens?nameWordStartNoProperty=Gert'); + $this->assertResponseStatusCodeSame(400); + + $responseData = $response->toArray(false); + + $this->assertStringContainsString( + 'The filter parameter with key "nameWordStartNoProperty" must specify a property', + $responseData['detail'] + ); + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + $ownerClass = $this->isMongoDB() ? DocumentOwner::class : Owner::class; + + $owner1 = new $ownerClass(); + $owner1->setName('Alice'); + + $manager->persist($owner1); + $manager->flush(); + + $chickenCoop1 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude the Hen'); + $chicken1->setChickenCoop($chickenCoop1); + $chicken1->setOwner($owner1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop1); + $chicken2->setOwner($owner1); + + $chicken3 = new $chickenClass(); + $chicken3->setName('Red Rooster'); + $chicken3->setChickenCoop($chickenCoop1); + $chicken3->setOwner($owner1); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop1->addChicken($chicken2); + $chickenCoop1->addChicken($chicken3); + + $manager->persist($chickenCoop1); + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chicken3); + + $manager->flush(); + } +} diff --git a/tests/Functional/Security/SecurityHeadersTest.php b/tests/Functional/Security/SecurityHeadersTest.php index cacbf430537..30793614cd3 100644 --- a/tests/Functional/Security/SecurityHeadersTest.php +++ b/tests/Functional/Security/SecurityHeadersTest.php @@ -55,7 +55,7 @@ public function testDeserializationErrorResponseIncludesSecurityHeaders(): void ], ); - $this->assertResponseStatusCodeSame(400); + $this->assertResponseStatusCodeSame(422); $this->assertResponseHeaderSame('x-content-type-options', 'nosniff'); $this->assertResponseHeaderSame('x-frame-options', 'deny'); } diff --git a/tests/Functional/Security/StrongTypingTest.php b/tests/Functional/Security/StrongTypingTest.php index d42b6a8aab9..78a906b94dc 100644 --- a/tests/Functional/Security/StrongTypingTest.php +++ b/tests/Functional/Security/StrongTypingTest.php @@ -86,12 +86,12 @@ public function testNullValueForRequiredStringTriggersTypeError(): void ], ); - $this->assertResponseStatusCodeSame(400); + $this->assertResponseStatusCodeSame(422); $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); $this->assertJsonContains([ - '@context' => '/contexts/Error', - '@type' => 'hydra:Error', - 'detail' => 'The type of the "name" attribute must be "string", "NULL" given.', + '@context' => '/contexts/ConstraintViolation', + '@type' => 'ConstraintViolation', + 'detail' => 'name: This value should not be blank.', ]); } @@ -198,12 +198,12 @@ public function testIntegerInsteadOfStringScalarTriggersTypeError(): void ], ); - $this->assertResponseStatusCodeSame(400); + $this->assertResponseStatusCodeSame(422); $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); $this->assertJsonContains([ - '@context' => '/contexts/Error', - '@type' => 'hydra:Error', - 'detail' => 'The type of the "name" attribute must be "string", "integer" given.', + '@context' => '/contexts/ConstraintViolation', + '@type' => 'ConstraintViolation', + 'detail' => 'name: This value should be of type string.', ]); } diff --git a/tests/Functional/SubResource/SubResourceTest.php b/tests/Functional/SubResource/SubResourceTest.php index cfc7c2328cf..f9bc7d0363c 100644 --- a/tests/Functional/SubResource/SubResourceTest.php +++ b/tests/Functional/SubResource/SubResourceTest.php @@ -164,9 +164,9 @@ public function testGetOneToOneSubResource(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/Answer', + '@context' => '/contexts/Answer3', '@id' => '/questions/1/answer', - '@type' => 'Answer', + '@type' => 'Answer3', 'id' => 1, 'content' => '42', 'relatedQuestions' => ['/questions/1'], @@ -188,9 +188,9 @@ public function testOneToOneSubresourceExposesInverseSideBackIri(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/OneToOneSubresourceAnswer', + '@context' => '/contexts/OneToOneSubresourceAnswer2', '@id' => '/one_to_one_subresource_questions/1/answer', - '@type' => 'OneToOneSubresourceAnswer', + '@type' => 'OneToOneSubresourceAnswer2', 'id' => 1, 'content' => '42', 'question' => '/one_to_one_subresource_questions/1', @@ -216,7 +216,7 @@ public function testGetRecursiveSubResource(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonEquals([ - '@context' => '/contexts/Question', + '@context' => '/contexts/Question3', '@id' => '/questions/1/answer/related_questions', '@type' => 'hydra:Collection', 'hydra:member' => [[ @@ -268,7 +268,7 @@ public function testGetSubResourceItem(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonContains([ - '@context' => '/contexts/RelatedDummy', + '@context' => '/contexts/RelatedDummy3', '@id' => '/dummies/1/related_dummies/2', '@type' => 'https://schema.org/Product', 'id' => 2, @@ -297,9 +297,9 @@ public function testGetEmbeddedRelationAtThirdLevel(): void $this->assertResponseStatusCodeSame(200); $data = $response->toArray(); - $this->assertSame('/contexts/ThirdLevel', $data['@context']); + $this->assertSame('/contexts/ThirdLevel2', $data['@context']); $this->assertSame('/dummies/1/related_dummies/1/third_level', $data['@id']); - $this->assertSame('ThirdLevel', $data['@type']); + $this->assertSame('ThirdLevel2', $data['@type']); $this->assertSame('/fourth_levels/1', $data['fourthLevel']); $this->assertSame(1, $data['id']); $this->assertSame(3, $data['level']); @@ -317,9 +317,9 @@ public function testGetEmbeddedRelationAtFourthLevel(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/FourthLevel', + '@context' => '/contexts/FourthLevel2', '@id' => '/dummies/1/related_dummies/1/third_level/fourth_level', - '@type' => 'FourthLevel', + '@type' => 'FourthLevel2', 'badThirdLevel' => [], 'id' => 1, 'level' => 4, @@ -485,9 +485,9 @@ public function testOneToOneFromOwnedSide(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonContains([ - '@context' => '/contexts/Dummy', + '@context' => '/contexts/Dummy2', '@id' => '/related_owned_dummies/1/owning_dummy', - '@type' => 'Dummy', + '@type' => 'Dummy2', 'name' => 'plop', 'relatedOwnedDummy' => '/related_owned_dummies/1', 'relatedOwningDummy' => null, @@ -517,9 +517,9 @@ public function testOneToOneFromOwningSide(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonContains([ - '@context' => '/contexts/Dummy', + '@context' => '/contexts/Dummy3', '@id' => '/related_owning_dummies/1/owned_dummy', - '@type' => 'Dummy', + '@type' => 'Dummy3', 'name' => 'plop', 'relatedOwningDummy' => '/related_owning_dummies/1', 'relatedOwnedDummy' => null, diff --git a/tests/Functional/ThrowOnNotFoundTest.php b/tests/Functional/ThrowOnNotFoundTest.php new file mode 100644 index 00000000000..060f1cedf46 --- /dev/null +++ b/tests/Functional/ThrowOnNotFoundTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ThrowOnNotFound\Feeder; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ThrowOnNotFoundTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Feeder::class]; + } + + public function testPostWithThrowOnNotFoundReturns404WhenProviderReturnsNull(): void + { + self::createClient()->request('POST', '/throw_on_not_found_feeders/42/feed', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => '{}', + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testPostDefaultDoesNotReturn404WhenProviderReturnsNull(): void + { + self::createClient()->request('POST', '/throw_on_not_found_feeders/42/feed_default', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => '{}', + ]); + + $this->assertNotSame(404, self::getClient()->getResponse()->getStatusCode()); + } +} diff --git a/tests/JsonLd/Action/ContextActionTest.php b/tests/JsonLd/Action/ContextActionTest.php index 52683ad81b2..62117051935 100644 --- a/tests/JsonLd/Action/ContextActionTest.php +++ b/tests/JsonLd/Action/ContextActionTest.php @@ -52,17 +52,6 @@ public function testContextActionWithEntrypoint(): void $this->assertEquals(['@context' => ['/entrypoints']], $contextAction('Entrypoint')); } - public function testContextActionWithContexts(): void - { - $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); - $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); - $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $contextBuilderProphecy->getBaseContext()->willReturn(['/contexts']); - $contextAction = new ContextAction($contextBuilderProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal()); - - $this->assertEquals(['@context' => ['/contexts']], $contextAction('ConstraintViolationList')); - } - public function testContextActionWithResourceClass(): void { $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); diff --git a/tests/JsonLd/ContextBuilderTest.php b/tests/JsonLd/ContextBuilderTest.php index f0f3f87a030..b9fb0727b54 100644 --- a/tests/JsonLd/ContextBuilderTest.php +++ b/tests/JsonLd/ContextBuilderTest.php @@ -226,57 +226,6 @@ public function testAnonymousResourceContextWithIri(): void $this->assertEquals($expected, $contextBuilder->getAnonymousResourceContext($output, ['iri' => '/dummies', 'name' => 'Dummy'])); } - public function testAnonymousResourceContextWithApiResource(): void - { - $output = new OutputDto(); - $this->propertyNameCollectionFactoryProphecy->create(OutputDto::class)->willReturn(new PropertyNameCollection(['dummyPropertyA'])); - $this->propertyMetadataFactoryProphecy->create(OutputDto::class, 'dummyPropertyA', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('Dummy property A')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)); - $this->urlGeneratorProphecy->generate('api_doc', ['_format' => 'jsonld'], UrlGeneratorInterface::ABS_URL)->willReturn(''); - - $this->resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ - (new ApiResource()) - ->withShortName('Dummy') - ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), - ])); - - $contextBuilder = new ContextBuilder($this->resourceNameCollectionFactoryProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->urlGeneratorProphecy->reveal()); - - $expected = [ - '@context' => [ - '@vocab' => '#', - 'hydra' => 'http://www.w3.org/ns/hydra/core#', - 'dummyPropertyA' => 'OutputDto/dummyPropertyA', - ], - '@id' => '/dummies', - '@type' => 'Dummy', - ]; - - $this->assertEquals($expected, $contextBuilder->getAnonymousResourceContext($output, ['iri' => '/dummies', 'name' => 'Dummy', 'api_resource' => new Dummy()])); - } - - public function testAnonymousResourceContextWithApiResourceHavingContext(): void - { - $output = new OutputDto(); - $this->propertyNameCollectionFactoryProphecy->create(OutputDto::class)->willReturn(new PropertyNameCollection(['dummyPropertyA'])); - $this->propertyMetadataFactoryProphecy->create(OutputDto::class, 'dummyPropertyA', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('Dummy property A')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)); - $this->urlGeneratorProphecy->generate('api_doc', ['_format' => 'jsonld'], UrlGeneratorInterface::ABS_URL)->willReturn(''); - - $this->resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ - (new ApiResource()) - ->withShortName('Dummy') - ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), - ])); - - $contextBuilder = new ContextBuilder($this->resourceNameCollectionFactoryProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->urlGeneratorProphecy->reveal()); - - $expected = [ - '@id' => '/dummies', - '@type' => 'Dummy', - ]; - - $this->assertEquals($expected, $contextBuilder->getAnonymousResourceContext($output, ['iri' => '/dummies', 'name' => 'Dummy', 'api_resource' => new Dummy(), 'has_context' => true])); - } - public function testResourceContextWithoutHydraPrefix(): void { $this->resourceMetadataCollectionFactoryProphecy->create($this->entityClass)->willReturn(new ResourceMetadataCollection('DummyEntity', [ diff --git a/tests/JsonLd/Serializer/ObjectNormalizerTest.php b/tests/JsonLd/Serializer/ObjectNormalizerTest.php index 65dcfe619b4..fa9006911b4 100644 --- a/tests/JsonLd/Serializer/ObjectNormalizerTest.php +++ b/tests/JsonLd/Serializer/ObjectNormalizerTest.php @@ -86,50 +86,19 @@ public function testNormalizeEmptyArray(): void $this->assertEquals([], $normalizer->normalize($dummy)); } - public function testNormalizeWithOutput(): void + public function testNormalizeWithJsonLdContextSet(): void { $dummy = new Dummy(); $dummy->setName('hello'); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource($dummy)->willReturn('/dummy/1234'); $serializerProphecy = $this->prophesize(SerializerInterface::class); $serializerProphecy->willImplement(NormalizerInterface::class); $serializerProphecy->normalize($dummy, null, Argument::type('array'))->willReturn(['name' => 'hello']); $contextBuilderProphecy = $this->prophesize(AnonymousContextBuilderInterface::class); - $contextBuilderProphecy->getAnonymousResourceContext($dummy, ['api_resource' => $dummy, 'iri' => '/dummy/1234'])->shouldBeCalled()->willReturn(['@id' => '/dummy/1234', '@type' => 'Dummy', '@context' => []]); - - $normalizer = new ObjectNormalizer( - $serializerProphecy->reveal(), - $iriConverterProphecy->reveal(), - $contextBuilderProphecy->reveal() - ); - - $expected = [ - '@context' => [], - '@id' => '/dummy/1234', - '@type' => 'Dummy', - 'name' => 'hello', - ]; - $this->assertEquals($expected, $normalizer->normalize($dummy, null, ['api_resource' => $dummy])); - } - - public function testNormalizeWithContext(): void - { - $dummy = new Dummy(); - $dummy->setName('hello'); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource($dummy)->willReturn('/dummy/1234'); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(NormalizerInterface::class); - $serializerProphecy->normalize($dummy, null, Argument::type('array'))->willReturn(['name' => 'hello']); - - $contextBuilderProphecy = $this->prophesize(AnonymousContextBuilderInterface::class); - $contextBuilderProphecy->getAnonymousResourceContext($dummy, ['api_resource' => $dummy, 'has_context' => true, 'iri' => '/dummy/1234'])->shouldBeCalled()->willReturn(['@id' => '/dummy/1234', '@type' => 'Dummy']); + $contextBuilderProphecy->getAnonymousResourceContext($dummy, ['has_context' => true])->shouldBeCalled()->willReturn(['@id' => '/dummy/1234', '@type' => 'Dummy']); $normalizer = new ObjectNormalizer( $serializerProphecy->reveal(), @@ -142,6 +111,6 @@ public function testNormalizeWithContext(): void '@type' => 'Dummy', 'name' => 'hello', ]; - $this->assertEquals($expected, $normalizer->normalize($dummy, null, ['api_resource' => $dummy, 'jsonld_has_context' => true])); + $this->assertEquals($expected, $normalizer->normalize($dummy, null, ['jsonld_has_context' => true])); } } diff --git a/tests/State/RespondProcessorTest.php b/tests/State/RespondProcessorTest.php index 92d29c31167..c9ea58073af 100644 --- a/tests/State/RespondProcessorTest.php +++ b/tests/State/RespondProcessorTest.php @@ -163,6 +163,63 @@ public function testAddsLinkedDataPlatformHeaders(): void $this->assertSame('application/ld+json', $response->headers->get('Accept-Post')); } + public function testDoesNotAdvertiseHeadWithoutGetOperation(): void + { + $postOperation = new Post(uriTemplate: '/employees', class: Employee::class); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Employee::class)->willReturn(true); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Employee::class)->willReturn(new ResourceMetadataCollection(Employee::class, [ + new ApiResource(operations: [ + 'post' => $postOperation, + ]), + ])); + + $respondProcessor = new RespondProcessor( + null, + $resourceClassResolver->reveal(), + null, + $resourceMetadataCollectionFactory->reveal() + ); + + $response = $respondProcessor->process('content', $postOperation, context: [ + 'request' => new Request(), + ]); + + $this->assertNotNull($response->headers->get('Allow')); + $this->assertStringNotContainsString('HEAD', $response->headers->get('Allow')); + } + + public function testDynamicResponseStatusFromRequestAttribute(): void + { + $operation = new Post(class: Employee::class); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Employee::class)->willReturn(true); + + $respondProcessor = new RespondProcessor(null, $resourceClassResolver->reveal()); + + $req = new Request([], [], ['_api_response_status' => 200]); + $req->setMethod('POST'); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => $req, + 'original_data' => new Employee(), + ]); + + $this->assertSame(200, $response->getStatusCode()); + + $req = new Request(); + $req->setMethod('POST'); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => $req, + 'original_data' => new Employee(), + ]); + + $this->assertSame(201, $response->getStatusCode()); + } + public function testDoesNotSetContentTypeWhenOutputIsFalse(): void { $operation = new Post(class: Employee::class, output: ['class' => null], status: 204); diff --git a/tests/Symfony/Bundle/ApiPlatformBundleTest.php b/tests/Symfony/Bundle/ApiPlatformBundleTest.php index a8b85a8e4c9..45af8e628d8 100644 --- a/tests/Symfony/Bundle/ApiPlatformBundleTest.php +++ b/tests/Symfony/Bundle/ApiPlatformBundleTest.php @@ -17,7 +17,6 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AttributeFilterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AttributeResourcePass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AuthenticatorManagerPass; -use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\DataProviderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ErrorResourceAttributeLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; @@ -47,8 +46,6 @@ public function testBuild(): void $passes = $container->getCompilerPassConfig()->getBeforeOptimizationPasses(); $passClasses = array_map(static fn (object $p): string => $p::class, $passes); - // TODO: remove in 5.x - $this->assertContains(DataProviderPass::class, $passClasses); $this->assertContains(AttributeFilterPass::class, $passClasses); $this->assertContains(AttributeResourcePass::class, $passClasses); $this->assertContains(FilterPass::class, $passClasses); diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPassTest.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPassTest.php index e2a31ebad61..6f35b793b75 100644 --- a/tests/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPassTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPassTest.php @@ -15,6 +15,8 @@ use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AttributeFilterPass; +use ApiPlatform\Tests\Symfony\Bundle\DependencyInjection\Compiler\Resource\LegacyFilteredResource; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -37,6 +39,7 @@ public function testConstruct(): void $this->assertInstanceOf(CompilerPassInterface::class, $attributeFilterPass); } + #[IgnoreDeprecations] public function testProcess(): void { $containerBuilderProphecy = $this->prophesize(ContainerBuilder::class); @@ -57,6 +60,37 @@ public function testProcess(): void $attributeFilterPass->process($containerBuilderProphecy->reveal()); } + public function testProcessTriggersApiFilterDeprecation(): void + { + $containerBuilderProphecy = $this->prophesize(ContainerBuilder::class); + $containerBuilderProphecy->getParameter('api_platform.resource_class_directories')->willReturn([ + __DIR__.'/Resource/', + ]); + $containerBuilderProphecy->has(Argument::type('string'))->willReturn(false, true); + $containerBuilderProphecy->getReflectionClass(BooleanFilter::class, false)->willReturn(new \ReflectionClass(BooleanFilter::class)); + $containerBuilderProphecy->has(BooleanFilter::class)->willReturn(true); + $containerBuilderProphecy->findDefinition(BooleanFilter::class)->willReturn(new Definition(BooleanFilter::class)); + $containerBuilderProphecy->setDefinition(Argument::type('string'), Argument::type(Definition::class))->willReturn(new Definition(BooleanFilter::class)); + + $deprecations = []; + set_error_handler(static function (int $type, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, \E_USER_DEPRECATED); + + try { + (new AttributeFilterPass())->process($containerBuilderProphecy->reveal()); + } finally { + restore_error_handler(); + } + + $apiFilterDeprecations = array_values(array_filter($deprecations, static fn (string $m): bool => str_contains($m, '#[ApiFilter]'))); + $this->assertCount(1, $apiFilterDeprecations, 'Processing a resource declaring #[ApiFilter] must trigger one deprecation.'); + $this->assertStringContainsString(LegacyFilteredResource::class, $apiFilterDeprecations[0]); + } + + #[IgnoreDeprecations] public function testProcessInvalidFilterClass(): void { $this->expectException(DependencyInjectionInvalidArgumentException::class); diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/Resource/LegacyFilteredResource.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/Resource/LegacyFilteredResource.php new file mode 100644 index 00000000000..61a71a89361 --- /dev/null +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/Resource/LegacyFilteredResource.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Symfony\Bundle\DependencyInjection\Compiler\Resource; + +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiFilter; + +#[ApiFilter(BooleanFilter::class, properties: ['active'])] +class LegacyFilteredResource +{ + public bool $active = false; +} diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 9d1f82645bb..b1d0a51c448 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Symfony\Bundle\DependencyInjection\Configuration; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\OptimisticLockException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -101,12 +102,12 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm ExceptionInterface::class => Response::HTTP_BAD_REQUEST, InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, OptimisticLockException::class => Response::HTTP_CONFLICT, + UniqueConstraintViolationException::class => Response::HTTP_UNPROCESSABLE_ENTITY, ], 'path_segment_name_generator' => 'api_platform.metadata.path_segment_name_generator.underscore', 'inflector' => 'api_platform.metadata.inflector', 'validator' => [ 'serialize_payload_fields' => [], - 'query_parameter_validation' => true, ], 'name_converter' => null, 'enable_swagger' => true, @@ -132,9 +133,6 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'enabled' => true, ], ], - 'graphql_playground' => [ - 'enabled' => false, - ], ], 'elasticsearch' => [ 'enabled' => false, @@ -161,6 +159,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'http_auth' => [], 'swagger_ui_extra_configuration' => [], 'persist_authorization' => false, + 'with_credentials' => false, ], 'eager_loading' => [ 'enabled' => true, @@ -188,11 +187,9 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'http_cache' => [ 'invalidation' => [ 'enabled' => false, - 'varnish_urls' => [], 'request_options' => [], 'max_header_length' => 7500, 'purger' => 'api_platform.http_cache.purger.varnish', - 'xkey' => ['glue' => ' '], 'urls' => [], 'scoped_clients' => [], ], @@ -212,7 +209,6 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'hub_url' => null, 'include_type' => false, ], - 'resource_class_directories' => [], 'asset_package' => null, 'openapi' => [ 'contact' => [ @@ -239,8 +235,6 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm ], 'use_symfony_listeners' => false, 'handle_symfony_errors' => false, - // TODO: remove in 5.0 - 'enable_link_security' => true, 'serializer' => [ 'hydra_prefix' => null, ], @@ -250,10 +244,11 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'format' => 'jsonld', ], 'jsonapi' => [ - 'use_iri_as_id' => true, + 'use_iri_as_id' => null, 'allow_client_generated_id' => false, ], 'enable_scalar' => true, + 'enable_head_request_optimization' => true, ], $config); } diff --git a/generate-changelog.sh b/tools/generate-changelog.sh similarity index 100% rename from generate-changelog.sh rename to tools/generate-changelog.sh diff --git a/subtree.sh b/tools/subtree.sh similarity index 100% rename from subtree.sh rename to tools/subtree.sh diff --git a/update-js.sh b/tools/update-js.sh similarity index 100% rename from update-js.sh rename to tools/update-js.sh