diff --git a/.claude/agents/filter-provider-reviewer.md b/.claude/agents/filter-provider-reviewer.md new file mode 100644 index 0000000..2ca516a --- /dev/null +++ b/.claude/agents/filter-provider-reviewer.md @@ -0,0 +1,38 @@ +--- +name: filter-provider-reviewer +description: Review changes to Elasticsearch filters and state providers for query-DSL correctness and index-contract alignment. Use after editing anything under src/Api/Filter/ElasticSearch/ or src/Api/State/. +--- + +You review the riskiest code in this repo: the custom Elasticsearch filters (`src/Api/Filter/ElasticSearch/`) and +the state providers (`src/Api/State/`) that assemble them. Unlike stock API Platform filters, these emit **raw +Elasticsearch query DSL** rather than mutating a Doctrine query builder, and the document mappings they target are +owned by `event-database-imports` — so a generic code reviewer misses the failure modes that matter here. + +Focus your review on: + +1. **Query-DSL correctness.** `apply()` returns ES query fragments, not query-builder mutations. Check the emitted + DSL is well-formed for its intent (`term`/`terms` vs `match`, `range` bounds, `bool` `must`/`should`/`filter` + nesting) and that clauses compose rather than overwrite when multiple filters apply to one request. + +2. **Filter vs sort separation.** `AbstractProvider::getFilters()` splits filter clauses from sort clauses via the + `SortFilterInterface` marker. A new sorting filter must implement that marker or it will be treated as a query + clause; a query filter must not. + +3. **Index-contract alignment — check the MAPPED field type, not assumed behaviour.** Field names/types referenced + in the DSL must match what `event-database-imports` writes. The production-parity mappings are copied in + `tests/resources/mappings/*.json` — consult them. Matching semantics depend on the mapped type: a `keyword` field + (e.g. `tags`, `slug`, `vocabulary`, `postalCode`) matches **exact, case-sensitive, whole-value** (no tokenisation); + a `text` field (e.g. `title`, `name`, `description`) is **tokenised** (word match). Do not assume dynamic-mapping + behaviour. A typo'd/renamed field, or a filter that assumes token matching on a `keyword` field, yields + empty/incorrect results silently — not an error. Confirm against `tests/resources/mappings/.json` or a live + mapping (`docker compose exec -T phpfpm curl -s http://elasticsearch:9200//_mapping`). + +4. **Pagination & limits.** `paginationMaximumItemsPerPage` on the DTO and `AbstractProvider::MAX_PAGE_SIZE_FALLBACK` + (20) bound page size — check a new provider respects them and returns `SearchResults` for collections / a single + array for items. + +5. **Filter registration.** Filters are attached via `#[ApiFilter(...)]` on the DTO and picked up through + `api_platform.filter_locator`. Confirm a new filter is actually reachable that way, not just defined. + +Report concrete, file:line findings ranked by severity. Prefer verifying a claim against the code or a live ES +query over speculating. Do not restate what is correct at length — surface what is wrong or unverifiable. diff --git a/.claude/agents/pr-readiness.md b/.claude/agents/pr-readiness.md new file mode 100644 index 0000000..c5c7205 --- /dev/null +++ b/.claude/agents/pr-readiness.md @@ -0,0 +1,31 @@ +--- +name: pr-readiness +description: Run all CI-equivalent checks locally before creating a PR +model: haiku +--- + +Mirror what `.github/workflows/*.yaml` runs on a PR. Every step executes inside Docker (the project +ships only its `phpfpm` / `prettier` / `markdownlint` services — there is no `node` service and no +JS/CSS to lint). Stop early if a critical check fails. + +## Checks + +1. **Composer validate**: `docker compose exec -T phpfpm composer validate --strict` +2. **Composer normalize (dry-run)**: `docker compose exec -T phpfpm composer normalize --dry-run` +3. **PHP coding standards**: `task coding-standards:php:check` +4. **Twig coding standards**: `task coding-standards:twig:check` +5. **YAML coding standards**: `task coding-standards:yaml:check` +6. **Markdown coding standards**: `task coding-standards:markdown:check` +7. **PHPStan (level 8)**: `task code-analysis:phpstan` +8. **Rector (dry-run)**: `task code-analysis:rector` — the `Rector` gate fails on any suggested change; run `task code-analysis:rector:apply` to fix. +9. **Test fixtures + API tests**: `task fixtures:load:test --yes && task api:test`. Tests hit a real Elasticsearch, so fixtures must be loaded first. If the load fails with "No alive nodes", run `docker compose up --detach --wait` and retry — see the `reload-fixtures` agent for the full recovery dance. +10. **API spec up to date** (mirrors `.github/workflows/api-spec.yml`): + +- `task api:spec:export` +- `git diff --exit-code public/spec.yaml` — must be clean. + +1. **CHANGELOG updated**: `git diff develop -- CHANGELOG.md` should show at least one entry under `## [Unreleased]`. + +## Output + +Report a summary table with columns: Check Name, Status (pass/fail), and error output for failures. diff --git a/.claude/agents/reload-fixtures.md b/.claude/agents/reload-fixtures.md new file mode 100644 index 0000000..1008ce2 --- /dev/null +++ b/.claude/agents/reload-fixtures.md @@ -0,0 +1,25 @@ +--- +name: reload-fixtures +description: Reload Elasticsearch fixtures and recover from a not-ready cluster +model: haiku +--- + +This project's "data layer" is Elasticsearch — there is no Doctrine database to migrate. After changing +API resources, filters, or anything that affects search behavior you generally want to reload fixtures +so subsequent manual / test runs see a consistent dataset. + +Two flavours of fixtures exist (see `src/Model/IndexName.php` for the seven index names): + +- **Dev fixtures** — pulled from the `event-database-imports` repo on GitHub: `task fixtures:load` +- **Test fixtures** — read from `tests/resources/*.json` and used by the PHPUnit suite: `task fixtures:load:test --yes` + +## Steps + +1. Confirm with the user (or take it from their prompt) whether to load **dev** or **test** fixtures. +2. Make sure the stack is up: `docker compose up --detach --wait`. The `--wait` is important — Elasticsearch is slow to become ready and the fixture loader fails fast with "No alive nodes" otherwise. +3. Run the load command (`task fixtures:load` or `task fixtures:load:test --yes`). The Taskfile prompts for confirmation unless `--yes` is passed. +4. If the command fails with "No alive nodes" or any Elasticsearch connection error: + - Poll the cluster health endpoint until it returns HTTP 200: + `docker compose exec elasticsearch curl 'http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=5s' --verbose` + - Re-run the fixture load command. +5. Report which indices were loaded and any non-fatal warnings. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e552f70 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,197 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "env": { + "COMPOSE_USER": "deploy" + }, + "permissions": { + "allow": [ + "Bash(cat:*)", + "Bash(diff:*)", + "Bash(echo:*)", + "Bash(find:*)", + "Bash(gh:*)", + "Bash(git:*)", + "Bash(grep:*)", + "Bash(head:*)", + "Bash(ls:*)", + "Bash(pwd)", + "Bash(tail:*)", + "Bash(task:*)", + "Bash(tree:*)", + "Bash(wc:*)", + "Bash(which:*)", + "Bash(docker compose exec:*)", + "Bash(docker compose run:*)", + "Bash(docker compose up:*)", + "Bash(docker compose ps:*)", + "Bash(docker compose logs:*)", + "Bash(docker compose top:*)", + "Bash(docker compose config:*)", + "Bash(docker compose pull:*)", + "Bash(docker compose images:*)", + "Bash(docker network:*)" + ], + "deny": [ + "Bash(rm -rf:*)", + "Bash(gh issue delete:*)", + "Bash(gh release delete:*)", + "Bash(gh repo delete:*)", + "Bash(gh label delete:*)", + "Read(./.env.local)", + "Read(./.env.local.*)", + "Read(./config/secrets/*)" + ], + "ask": [ + "Bash(docker compose down:*)", + "Bash(docker compose stop:*)", + "Bash(docker compose rm:*)", + "Bash(docker compose restart:*)", + "Bash(gh issue create:*)", + "Bash(gh issue close:*)", + "Bash(gh issue edit:*)", + "Bash(gh issue comment:*)", + "Bash(gh pr create:*)", + "Bash(gh pr close:*)", + "Bash(gh pr merge:*)", + "Bash(gh pr edit:*)", + "Bash(gh pr comment:*)", + "Bash(gh pr review:*)", + "Bash(gh release create:*)", + "Bash(gh release edit:*)", + "Bash(gh repo create:*)", + "Bash(gh label create:*)", + "Bash(gh label edit:*)", + "Bash(git push:*)", + "Bash(git branch -d:*)", + "Bash(git branch -D:*)", + "Bash(git tag -d:*)", + "Bash(git tag -a:*)", + "Bash(git tag :*)", + "Bash(git reset:*)", + "Bash(git rebase:*)", + "Bash(git merge:*)", + "Bash(git stash drop:*)", + "Bash(git clean:*)", + "Bash(git checkout -- :*)", + "Bash(git restore:*)", + "Bash(git commit:*)" + ] + }, + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "docker compose up --detach --quiet-pull 2>/dev/null || true", + "timeout": 60, + "statusMessage": "Starting Docker services..." + } + ] + }, + { + "matcher": "startup|resume|clear", + "hooks": [ + { + "type": "command", + "command": "command -v jq >/dev/null 2>&1 || { echo '⚠️ jq is not installed on the host — the Edit/Write formatting hooks read the edited file path from the tool payload via jq and will silently no-op until you install it (brew install jq).' >&2; exit 2; }", + "timeout": 10, + "statusMessage": "Checking host prerequisites..." + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in */composer.lock|*/symfony.lock|*/.env.local|*/.env.*.local|*/config/reference.php|*/vendor/*|*/node_modules/*|*/var/cache/*) echo 'BLOCKED: this path is generated, locked, or operator-only — edit the source instead' >&2; exit 2 ;; esac" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "f=$(jq -r '.tool_input.file_path // empty'); REL_PATH=\"${f#\"$CLAUDE_PROJECT_DIR\"/}\"; case \"$REL_PATH\" in src/*.php|tests/*.php) docker compose exec -T phpfpm vendor/bin/rector process --no-progress-bar \"$REL_PATH\" >/dev/null 2>&1 || true ;; esac", + "timeout": 60 + }, + { + "type": "command", + "command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.php) REL_PATH=\"${f#\"$CLAUDE_PROJECT_DIR\"/}\"; docker compose exec -T phpfpm vendor/bin/php-cs-fixer fix --quiet \"$REL_PATH\" 2>/dev/null || true ;; esac", + "timeout": 30 + }, + { + "type": "command", + "command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.php) REL_PATH=\"${f#\"$CLAUDE_PROJECT_DIR\"/}\"; docker compose exec -T phpfpm vendor/bin/phpstan analyse --no-progress --error-format=raw \"$REL_PATH\" 2>/dev/null || true ;; esac", + "timeout": 45 + }, + { + "type": "command", + "command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.twig) REL_PATH=\"${f#\"$CLAUDE_PROJECT_DIR\"/}\"; docker compose exec -T phpfpm vendor/bin/twig-cs-fixer lint --fix \"$REL_PATH\" 2>/dev/null || true ;; esac", + "timeout": 15 + }, + { + "type": "command", + "command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in */composer.json) docker compose exec -T phpfpm composer normalize --quiet 2>/dev/null || true ;; esac", + "timeout": 30 + }, + { + "type": "command", + "command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.yaml|*.yml|*.js|*.css) REL_PATH=\"${f#\"$CLAUDE_PROJECT_DIR\"/}\"; docker compose run --rm -T prettier \"$REL_PATH\" --write 2>/dev/null || true ;; esac", + "timeout": 30 + }, + { + "type": "command", + "command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.md) REL_PATH=\"${f#\"$CLAUDE_PROJECT_DIR\"/}\"; docker compose run --rm -T markdownlint markdownlint --fix \"$REL_PATH\" 2>/dev/null || true ;; esac", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "docker compose exec -T phpfpm bin/console lint:container 2>/dev/null || true", + "timeout": 30, + "statusMessage": "Validating Symfony DI container..." + }, + { + "type": "command", + "command": "sh \"$CLAUDE_PROJECT_DIR/scripts/claude-hook-check-index-contract.sh\" || true", + "timeout": 10, + "statusMessage": "Checking Elasticsearch index contract..." + }, + { + "type": "command", + "command": "sh \"$CLAUDE_PROJECT_DIR/scripts/claude-hook-check-spec-drift.sh\" || true", + "timeout": 10, + "statusMessage": "Checking OpenAPI spec is up to date..." + } + ] + } + ] + }, + "enabledPlugins": { + "php-lsp@claude-plugins-official": true, + "code-simplifier@claude-plugins-official": true, + "context7@claude-plugins-official": true, + "code-review@claude-plugins-official": true, + "security-guidance@claude-plugins-official": true, + "playwright@claude-plugins-official": true, + "feature-dev@claude-plugins-official": true, + "itkdev-skills@itkdev-marketplace": true + }, + "enabledMcpjsonServers": [ + "context7" + ], + "enableAllProjectMcpServers": true +} diff --git a/.claude/skills/changelog-entry/SKILL.md b/.claude/skills/changelog-entry/SKILL.md new file mode 100644 index 0000000..d86b04d --- /dev/null +++ b/.claude/skills/changelog-entry/SKILL.md @@ -0,0 +1,45 @@ +--- +name: changelog-entry +description: Add or fix this repo's CHANGELOG.md entry for the current PR, in the exact house format. Use before opening a PR or when resolving a CHANGELOG merge conflict. +disable-model-invocation: true +--- + +Add a changelog line for the current work to `CHANGELOG.md`, following this repo's convention exactly (getting it wrong causes the `changelog` CI check in `.github/workflows/changelog.yaml` to fail and creates repeated merge conflicts). + +## Format + +Under the `## [Unreleased]` heading, entries are a **two-line wrapped** list item: + +``` +- [PR-N](https://github.com/itk-dev/event-database-api/pull/N) + Short imperative description of the change +``` + +Rules: + +- One entry per PR. Ordered by **descending PR number** (newest on top). +- The URL is always the `event-database-api` repo (not `event-database-imports`). +- **Keep it short and terse — match the surrounding entries.** The description is a single concise noun or + imperative phrase naming *what* changed, not a sentence (or paragraph) explaining *how*. It fits on one wrapped + line and almost always well under the 120-char limit (markdownlint MD013). If you're tempted to list mechanisms, + reasons, or multiple clauses, cut them — that detail belongs in the PR description, not the changelog. + - Good (real entries): `Symfony 7.3 and PHP 8.4` · `Re-lint YAML files` · + `Add Claude Code project setup (CLAUDE.md, agents, skills)` + - Too long: `Adapt Claude Code setup from event-database-imports: fix the Edit/Write hook mechanism (read the + file path from the tool payload via jq instead of the unset CLAUDE_FILE_PATH), guard the …` +- Some historical entries are plain bullets without a PR link (e.g. dependency bumps) — that's fine for changes + without a PR, but PR-based work always gets the `[PR-N]` form. + +## Two-step PR number + +The PR number isn't known until the PR exists. So: + +1. Add the entry now with a `PR-XX` placeholder and `pull/XX` URL. +2. After the PR is opened (`gh pr view --json number`), replace `XX` with the real number in a follow-up commit (`docs: set PR number in CHANGELOG entry`). + +## Steps + +1. `git fetch origin develop` and read the current `[Unreleased]` block so you insert in the right order and don't duplicate. +2. Insert the entry at the correct descending-number position. +3. Lint: `docker compose run --rm markdownlint markdownlint CHANGELOG.md`. +4. If a PR already exists, set the real number; otherwise leave `XX` and remind the user of step 2. diff --git a/.claude/skills/new-resource/SKILL.md b/.claude/skills/new-resource/SKILL.md new file mode 100644 index 0000000..7a579d0 --- /dev/null +++ b/.claude/skills/new-resource/SKILL.md @@ -0,0 +1,39 @@ +--- +name: new-resource +description: Scaffold a new read-only API resource backed by an Elasticsearch index, following this repo's DTO + provider + filter pattern. Use when exposing a new index (or a new shape of an existing one) under /api/v2. +disable-model-invocation: true +--- + +Add a new API Platform resource. This repo's resources are **DTOs backed by Elasticsearch**, never Doctrine +entities — every resource follows the same three-part shape, with `Event` as the canonical template. Read +`src/Api/Dto/Event.php`, `src/Api/State/EventRepresentationProvider.php`, and `tests/ApiPlatform/EventsTest.php` +before starting, and mirror them. + +## Steps + +1. **Confirm the index.** The seven indices live in `src/Model/IndexName.php`. If the resource maps to an existing + index, reuse that enum case. If it's a genuinely new index, add a case — and remember the mapping is owned by + `event-database-imports` (see CLAUDE.md → "Works with event-database-imports"); coordinate the contract there. + +2. **DTO** — `src/Api/Dto/.php`: an `#[ApiResource]` class declaring operations, pagination + (`paginationMaximumItemsPerPage`), and `#[ApiFilter(...)]` attributes. The `$id` property is identifier-only + (PHPStan "unused" errors on it are intentionally ignored); real fields are filled by the provider. Attach + filters from `src/Api/Filter/ElasticSearch/` (`MatchFilter`, `IdFilter`, `BooleanFilter`, `DateRangeFilter`, + `DateFilter`, `TagFilter`) — do not invent a Doctrine filter. + +3. **Provider** — `src/Api/State/RepresentationProvider.php`: implement `ProviderInterface` by extending + `AbstractProvider`. Use `getFilters()` to compile the declared `#[ApiFilter]` attributes into ES query + fragments, call `IndexInterface::search()`, and return a `SearchResults` (collection op) or a single array + (item op). Wire the DTO's `provider:` to this class. Providers are autoconfigured — only touch + `config/services.yaml` if you need explicit arguments. + +4. **Test** — `tests/ApiPlatform/Test.php`: extend `AbstractApiTestCase`, `use` `GetEntitiesTestTrait` + and `GetItemTestTrait`, and set the static props (`$requestPath`, `$resourceClass`, `$itemId`, + `$unknownItemId`). Add resource-specific filter assertions in a `FilterTest.php`. Add fixture rows to + `tests/resources/.json` so `$itemId` resolves (see `tests/resources/README.md`). + +5. **Regenerate the OpenAPI spec** — run the `/update-api-spec` skill (or `task api:spec:export`). CI + (`.github/workflows/api-spec.yml`) fails if `public/spec.yaml` is stale. + +6. **Verify** — `task fixtures:load:test --yes` then `task api:test -- --filter `. Run + `task coding-standards:check` and `task code-analysis` before opening the PR. diff --git a/.claude/skills/update-api-spec/SKILL.md b/.claude/skills/update-api-spec/SKILL.md new file mode 100644 index 0000000..ccef50a --- /dev/null +++ b/.claude/skills/update-api-spec/SKILL.md @@ -0,0 +1,18 @@ +--- +name: update-api-spec +description: Regenerate and stage the OpenAPI spec after API resource changes +user-invocable: true +--- + +The committed OpenAPI spec lives at `public/spec.yaml` (single YAML file — there is no separate JSON +export). `.github/workflows/api-spec.yml` fails the PR if it drifts from the resources defined under +`src/Api/`, and additionally diffs the spec against the base branch to flag breaking changes. + +After touching anything under `src/Api/Dto/`, `src/Api/State/`, or `src/Api/Filter/`: + +1. Regenerate the spec: `task api:spec:export` + (This is shorthand for `bin/console api:openapi:export --yaml --output=public/spec.yaml --no-interaction`.) +2. Inspect changes: `git diff public/spec.yaml` +3. If the diff is non-empty, stage the file: `git add public/spec.yaml` +4. Summarise what changed — new/removed operations, modified parameters, schema diffs — so the human + reviewer can sanity-check for unintended breaking changes before pushing. diff --git a/.github/workflows/api-spec.yml b/.github/workflows/api-spec.yml index 4b31e7d..7dd51eb 100644 --- a/.github/workflows/api-spec.yml +++ b/.github/workflows/api-spec.yml @@ -1,5 +1,13 @@ on: pull_request: + paths: + - "src/**/*.php" + - "config/**" + - "composer.json" + - "composer.lock" + - "public/spec.yaml" + - "docker-compose.yml" + - "docker-compose.override.yml" name: API Spec review @@ -10,37 +18,86 @@ jobs: api-spec: runs-on: ubuntu-latest name: Ensure committed API specification is up to date + permissions: + contents: read + pull-requests: write steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 2 - # https://taskfile.dev/installation/#github-actions - - uses: go-task/setup-task@v1 + - name: Cache vendor + uses: actions/cache@v5 + with: + path: vendor + key: vendor-php8.4-${{ hashFiles('composer.lock') }} + restore-keys: vendor-php8.4- - - run: | + - name: Install dependencies + run: | docker network create frontend - task --yes site:update + # The spec export only needs PHP — pull phpfpm alone and run it as a + # one-off (--no-deps skips mariadb/rabbit), not the whole stack. + docker compose pull --quiet phpfpm + docker compose run --rm --no-deps phpfpm composer install --no-interaction - name: Export API specification run: | - task --yes api:spec:export + docker compose run --rm --no-deps phpfpm bin/console api:openapi:export --yaml --output=public/spec.yaml --no-interaction - name: Check for changes in specification id: git-diff-spec continue-on-error: true run: git diff --diff-filter=ACMRT --exit-code public/spec.yaml - - name: Comment PR + - name: Find previous export comment + id: prev-export-comment + env: + GH_TOKEN: ${{ github.token }} + run: | + id=$(gh api \ + "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \ + --paginate \ + --jq '.[] | select(.body | startswith("")) | .id' \ + | tail -n1) + echo "id=${id:-}" >> "$GITHUB_OUTPUT" + + - name: Comment PR - spec out of date if: steps.git-diff-spec.outcome == 'failure' env: GH_TOKEN: ${{ github.token }} + PREV_ID: ${{ steps.prev-export-comment.outputs.id }} run: | - echo '## 🛑 Exported API specification file not up to date' > var/comment.md - echo '' >> var/comment.md - echo 'Please run `task api:spec:export` to export the API specification. Then commit and push the changes.' >> var/comment.md - gh pr comment ${{ github.event.pull_request.number }} --body-file var/comment.md --create-if-none --edit-last + { + echo "" + echo "## 🛑 Exported API specification file not up to date" + echo "" + echo "Run \`task api:spec:export\` to export the API specification, then commit and push the changes." + } > comment.md + if [ -n "$PREV_ID" ]; then + jq -Rs '{body: .}' < comment.md \ + | gh api "repos/${{ github.repository }}/issues/comments/$PREV_ID" --method PATCH --input - + else + gh pr comment ${{ github.event.pull_request.number }} --body-file comment.md + fi + + # Self-heal: once the committed spec matches the export again, edit the + # previous 🛑 comment to ✅ instead of leaving a stale failure comment. + - name: Mark export comment resolved (when up to date) + if: steps.git-diff-spec.outcome == 'success' && steps.prev-export-comment.outputs.id != '' + env: + GH_TOKEN: ${{ github.token }} + PREV_ID: ${{ steps.prev-export-comment.outputs.id }} + run: | + { + echo "" + echo "## ✅ Exported API specification is up to date" + echo "" + echo "_Resolved — the committed \`public/spec.yaml\` matches the export._" + } > comment.md + jq -Rs '{body: .}' < comment.md \ + | gh api "repos/${{ github.repository }}/issues/comments/$PREV_ID" --method PATCH --input - - name: Fail job api spec is not up to date if: steps.git-diff-spec.outcome == 'failure' @@ -51,64 +108,155 @@ jobs: name: Detect breaking changes in API specification runs-on: ubuntu-latest needs: [api-spec] + permissions: + contents: read + pull-requests: write steps: - - name: Check out BASE rev - uses: actions/checkout@v5 - with: - ref: ${{ github.base_ref }} - path: base + - name: Checkout + uses: actions/checkout@v7 - - name: Check out HEAD rev - uses: actions/checkout@v5 - with: - ref: ${{ github.head_ref }} - path: head + - name: Fetch base branch for comparison + env: + BASE_REF: ${{ github.base_ref }} + run: git fetch --depth=1 origin "$BASE_REF" + + # oasdiff cannot parse `headers: []` (it expects a map); normalise both specs. + - name: Sanitise specs (headers -> map) + env: + BASE_REF: ${{ github.base_ref }} + run: | + mkdir -p .api-spec-tmp + git show "origin/$BASE_REF:public/spec.yaml" | sed 's/headers: \[\]/headers: {}/g' > .api-spec-tmp/base.yaml + sed 's/headers: \[\]/headers: {}/g' public/spec.yaml > .api-spec-tmp/revision.yaml - - name: Run OpenAPI Changed (from HEAD rev) - id: api-changed + - name: Detect breaking changes + id: breaking continue-on-error: true - uses: docker://openapitools/openapi-diff:latest + uses: oasdiff/oasdiff-action/breaking@v0.1.5 with: - args: --fail-on-changed base/public/spec.yaml head/public/spec.yaml --markdown api-spec-changed.md + base: .api-spec-tmp/base.yaml + revision: .api-spec-tmp/revision.yaml + fail-on: ERR + # Compare effective (flattened) schemas so an allOf/$ref restructure is + # not reported as removed properties. Never masks real diffs. + flatten-allof: true + # Upload to oasdiff.com to get a side-by-side review link (exposed as + # the review_url output), but suppress the action's own comment with an + # empty token — we post a single combined comment below. + review: true + github-token: "" - - name: Run OpenAPI Incompatible (from HEAD rev) - id: api-incompatible + - name: Generate changelog + id: changelog continue-on-error: true - uses: docker://openapitools/openapi-diff:latest + uses: oasdiff/oasdiff-action/changelog@v0.1.5 with: - args: --fail-on-incompatible base/public/spec.yaml head/public/spec.yaml --markdown api-spec-incompatible.md + base: .api-spec-tmp/base.yaml + revision: .api-spec-tmp/revision.yaml + format: markdown + output-to-file: changelog.md + flatten-allof: true + # As above: keep the upload/review_url output, suppress the auto-comment. + review: true + github-token: "" + + - name: Determine whether the spec changed + id: changes + run: | + # oasdiff writes "No changelog changes" (v0.1.x) / "No changes" (older) + # when the spec is unchanged; treat either as no change. + if [ -s changelog.md ] && ! grep -qiE 'no (changelog )?changes' changelog.md; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Find previous workflow comment + id: prev-comment + env: + GH_TOKEN: ${{ github.token }} + run: | + id=$(gh api \ + "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \ + --paginate \ + --jq '.[] | select(.body | startswith("")) | .id' \ + | tail -n1) + echo "id=${id:-}" >> "$GITHUB_OUTPUT" - - name: Comment PR with no changes - if: steps.api-changed.outcome == 'success' && steps.api-incompatible.outcome == 'success' - working-directory: head + - name: Comment PR - non-breaking changes + if: steps.breaking.outcome == 'success' && steps.changes.outputs.has_changes == 'true' env: GH_TOKEN: ${{ github.token }} + PREV_ID: ${{ steps.prev-comment.outputs.id }} + REVIEW_URL: ${{ steps.changelog.outputs.review_url }} run: | - gh pr comment ${{ github.event.pull_request.number }} --body "✅ **No changes detected in API specification**" --create-if-none --edit-last + { + echo "" + echo "## ⚠️ API specification — non-breaking changes" + echo "" + echo "
API changes" + echo "" + cat changelog.md + echo "" + echo "
" + if [ -n "$REVIEW_URL" ]; then + printf '\n🔍 [View the full side-by-side review](%s)\n' "$REVIEW_URL" + fi + } > comment.md + if [ -n "$PREV_ID" ]; then + jq -Rs '{body: .}' < comment.md \ + | gh api "repos/${{ github.repository }}/issues/comments/$PREV_ID" --method PATCH --input - + else + gh pr comment ${{ github.event.pull_request.number }} --body-file comment.md + fi - - name: Comment PR with non-breaking changes - if: steps.api-changed.outcome == 'failure' && steps.api-incompatible.outcome == 'success' - working-directory: head + - name: Comment PR - breaking changes + if: steps.breaking.outcome == 'failure' env: GH_TOKEN: ${{ github.token }} + PREV_ID: ${{ steps.prev-comment.outputs.id }} + REVIEW_URL: ${{ steps.breaking.outputs.review_url }} run: | - echo "## ⚠️ Non-Breaking changes detected in API specification" > ../comment.md - echo "" >> ../comment.md - cat ../api-spec-changed.md >> ../comment.md - gh pr comment ${{ github.event.pull_request.number }} --body-file ../comment.md --create-if-none --edit-last - - - name: Comment PR with breaking changes - if: steps.api-incompatible.outcome == 'failure' - working-directory: head + { + echo "" + echo "## 🛑 API specification — breaking changes detected" + echo "" + if [ -s changelog.md ]; then + echo "
API changes" + echo "" + cat changelog.md + echo "" + echo "
" + else + echo "The breaking-changes check flagged incompatible changes. Review the workflow logs for details." + fi + if [ -n "$REVIEW_URL" ]; then + printf '\n🔍 [View the full side-by-side review](%s)\n' "$REVIEW_URL" + fi + } > comment.md + if [ -n "$PREV_ID" ]; then + jq -Rs '{body: .}' < comment.md \ + | gh api "repos/${{ github.repository }}/issues/comments/$PREV_ID" --method PATCH --input - + else + gh pr comment ${{ github.event.pull_request.number }} --body-file comment.md + fi + + - name: Mark previous comment resolved (when no changes) + if: steps.breaking.outcome == 'success' && steps.changes.outputs.has_changes == 'false' && steps.prev-comment.outputs.id != '' env: GH_TOKEN: ${{ github.token }} + PREV_ID: ${{ steps.prev-comment.outputs.id }} run: | - echo "## 🛑 Breaking changes detected in API specification" > ../comment.md - echo "" >> ../comment.md - cat ../api-spec-incompatible.md >> ../comment.md - gh pr comment ${{ github.event.pull_request.number }} --body-file ../comment.md --create-if-none --edit-last + { + echo "" + echo "## ✅ API specification" + echo "" + echo "_No changes detected on this run — previous diff resolved._" + } > comment.md + jq -Rs '{body: .}' < comment.md \ + | gh api "repos/${{ github.repository }}/issues/comments/$PREV_ID" --method PATCH --input - - name: Fail if breaking changes detected - if: steps.api-incompatible.outcome == 'failure' + if: steps.breaking.outcome == 'failure' run: | exit 1 diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index bb436c0..547097a 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -8,21 +8,21 @@ name: Create Github Release permissions: contents: write +env: + COMPOSE_USER: runner + jobs: create-release: runs-on: ubuntu-latest - env: - COMPOSER_ALLOW_SUPERUSER: 1 - APP_ENV: prod steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Composer install run: | docker network create frontend - docker compose run --rm --user=root -e APP_ENV=prod phpfpm composer install --no-dev --optimize-autoloader - docker compose run --rm --user=root -e APP_ENV=prod phpfpm composer clear-cache + docker compose run --rm -e APP_ENV=prod phpfpm composer install --no-dev --optimize-autoloader + docker compose run --rm -e APP_ENV=prod phpfpm composer clear-cache - name: Make assets dir run: | diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index fead572..71da908 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -18,7 +18,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 2 diff --git a/.github/workflows/composer.yaml b/.github/workflows/composer.yaml index a0e5a94..def97d2 100644 --- a/.github/workflows/composer.yaml +++ b/.github/workflows/composer.yaml @@ -41,7 +41,7 @@ jobs: strategy: fail-fast: false steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Create docker network run: | @@ -55,7 +55,7 @@ jobs: strategy: fail-fast: false steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Create docker network run: | @@ -70,11 +70,11 @@ jobs: strategy: fail-fast: false steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Create docker network run: | docker network create frontend - run: | - docker compose run --rm phpfpm composer audit + docker compose run --rm phpfpm composer audit --locked diff --git a/.github/workflows/index-mapping-drift.yaml b/.github/workflows/index-mapping-drift.yaml new file mode 100644 index 0000000..c5de90f --- /dev/null +++ b/.github/workflows/index-mapping-drift.yaml @@ -0,0 +1,102 @@ +on: + pull_request: + +name: Index mapping drift + +permissions: + contents: read + pull-requests: write + +jobs: + mapping-drift: + runs-on: ubuntu-latest + name: Check index mappings against event-database-imports + steps: + - name: Check out this repository + uses: actions/checkout@v7 + + - name: Check out event-database-imports (develop) + uses: actions/checkout@v7 + with: + repository: itk-dev/event-database-imports + ref: develop + path: upstream + + - name: Compare committed mappings against upstream + id: drift + run: | + set -uo pipefail + ours="tests/resources/mappings" + theirs="upstream/resources/mappings" + report="drift-report.md" + : > "$report" + drift=0 + + if [ ! -d "$theirs" ]; then + echo "Upstream mapping directory '$theirs' not found — has the importers export moved?" + echo "drift=true" >> "$GITHUB_OUTPUT" + exit 1 + fi + + # Compare with keys canonicalised (jq -S) so only real schema changes count. + for f in "$ours"/*.json; do + idx="$(basename "$f")" + up="$theirs/$idx" + if [ ! -f "$up" ]; then + printf -- '- `%s`: present here but **missing upstream**\n' "$idx" >> "$report" + drift=1 + continue + fi + if ! diff -u <(jq -S . "$up") <(jq -S . "$f") > "/tmp/$idx.diff"; then + { + printf -- '- `%s` **differs** (`-` upstream, `+` here):\n\n' "$idx" + printf '```diff\n' + cat "/tmp/$idx.diff" + printf '\n```\n\n' + } >> "$report" + drift=1 + fi + done + + # Flag indices that exist upstream but not here. + for f in "$theirs"/*.json; do + idx="$(basename "$f")" + [ -f "$ours/$idx" ] || { + printf -- '- `%s`: present **upstream but missing here**\n' "$idx" >> "$report" + drift=1 + } + done + + if [ "$drift" -eq 0 ]; then + echo "drift=false" >> "$GITHUB_OUTPUT" + echo "✅ Index mappings match event-database-imports@develop." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "drift=true" >> "$GITHUB_OUTPUT" + { + echo "## 🛑 Index mappings drifted from event-database-imports@develop" + echo "" + echo "\`tests/resources/mappings/*.json\` no longer matches the committed export in" + echo "[event-database-imports](https://github.com/itk-dev/event-database-imports/tree/develop/resources/mappings)." + echo "The importers project is the source of truth for these Elasticsearch mappings." + echo "" + echo "Re-sync each drifted index by copying the upstream file, e.g.:" + echo "" + echo '```shell' + echo "curl -sSL https://raw.githubusercontent.com/itk-dev/event-database-imports/develop/resources/mappings/.json \\" + echo " -o tests/resources/mappings/.json" + echo '```' + echo "" + cat "$report" + } | tee -a "$GITHUB_STEP_SUMMARY" > full-report.md + + - name: Comment on PR + if: github.event_name == 'pull_request' && steps.drift.outputs.drift == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: gh pr comment "${{ github.event.pull_request.number }}" --body-file full-report.md --create-if-none --edit-last + + - name: Fail if mappings drifted + if: steps.drift.outputs.drift == 'true' + run: exit 1 diff --git a/.github/workflows/markdown.yaml b/.github/workflows/markdown.yaml index ae83163..b0f74c5 100644 --- a/.github/workflows/markdown.yaml +++ b/.github/workflows/markdown.yaml @@ -34,7 +34,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Create docker network run: | diff --git a/.github/workflows/php.yaml b/.github/workflows/php.yaml index b7a68dd..5188018 100644 --- a/.github/workflows/php.yaml +++ b/.github/workflows/php.yaml @@ -48,7 +48,7 @@ jobs: name: PHP - Check Coding Standards runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Create docker network run: | diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 1c27f6e..b83d147 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -11,37 +11,86 @@ jobs: name: API test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - # https://taskfile.dev/installation/#github-actions - - uses: go-task/setup-task@v1 + - name: Cache vendor + uses: actions/cache@v5 + with: + path: vendor + key: vendor-php8.4-${{ hashFiles('composer.lock') }} + restore-keys: vendor-php8.4- - name: Start docker compose setup and install site run: | docker network create frontend - task --yes site:update + # API tests need PHP + Elasticsearch only (not mariadb/rabbit/nginx/mail); + # --no-deps keeps phpfpm's mariadb/rabbit dependencies from starting. + docker compose pull --quiet phpfpm elasticsearch + docker compose up --detach --wait --no-deps phpfpm elasticsearch + docker compose exec -T phpfpm composer install --no-interaction - name: Load test fixtures run: | - task --yes fixtures:load:test + for index in events organizations occurrences daily_occurrences tags vocabularies locations; do + docker compose exec -T phpfpm bin/console app:fixtures:load "$index" --url="file:///app/tests/resources/$index.json" + done - - name: Run API tests + - name: Run API tests with coverage run: | - task --yes api:test + docker compose exec -T -e XDEBUG_MODE=coverage phpfpm bin/phpunit --coverage-clover=coverage/unit.xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v7 + with: + files: ./coverage/unit.xml + flags: unittests + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} code-analysis-phpstan: runs-on: ubuntu-latest name: PHPStan static analysis steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - # https://taskfile.dev/installation/#github-actions - - uses: go-task/setup-task@v1 + - name: Cache vendor + uses: actions/cache@v5 + with: + path: vendor + key: vendor-php8.4-${{ hashFiles('composer.lock') }} + restore-keys: vendor-php8.4- - - run: | + - name: Install dependencies + run: | docker network create frontend - task --yes site:update + # PHPStan only needs PHP — pull phpfpm and run it as a one-off (--no-deps). + docker compose pull --quiet phpfpm + docker compose run --rm --no-deps phpfpm composer install --no-interaction - name: Run code analysis run: | - task --yes code-analysis:phpstan + docker compose run --rm --no-deps phpfpm vendor/bin/phpstan analyse --no-progress + + code-analysis-rector: + runs-on: ubuntu-latest + name: Rector + steps: + - uses: actions/checkout@v7 + + - name: Cache vendor + uses: actions/cache@v5 + with: + path: vendor + key: vendor-php8.4-${{ hashFiles('composer.lock') }} + restore-keys: vendor-php8.4- + + - name: Install dependencies + run: | + docker network create frontend + # Rector only needs PHP — pull phpfpm and run it as a one-off (--no-deps). + docker compose pull --quiet phpfpm + docker compose run --rm --no-deps phpfpm composer install --no-interaction + + - name: Check for Rector suggestions + run: | + docker compose run --rm --no-deps phpfpm vendor/bin/rector process --dry-run --no-progress-bar diff --git a/.github/workflows/twig.yaml b/.github/workflows/twig.yaml index 5909841..047d881 100644 --- a/.github/workflows/twig.yaml +++ b/.github/workflows/twig.yaml @@ -40,7 +40,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Create docker network run: | diff --git a/.github/workflows/yaml.yaml b/.github/workflows/yaml.yaml index 631e525..80d0a23 100644 --- a/.github/workflows/yaml.yaml +++ b/.github/workflows/yaml.yaml @@ -31,7 +31,7 @@ jobs: yaml-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Create docker network run: | diff --git a/.gitignore b/.gitignore index 0f3aac4..6b0cf92 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,10 @@ ###> phpstan/phpstan ### phpstan.neon ###< phpstan/phpstan ### + +###> claude-code ### +/.claude/settings.local.json +###< claude-code ### + +# Coverage reports (generated in CI, uploaded to Codecov). +/coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e3ab8..21f3433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,79 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +## [1.3.0] - 2026-07-10 + +- [PR-61](https://github.com/itk-dev/event-database-api/pull/61) + Fix the API-spec workflow's no-change detection for oasdiff v0.1.x ("No changelog changes" wording) +- [PR-60](https://github.com/itk-dev/event-database-api/pull/60) + Upgrade Elasticsearch to 8.19.18 (dev image) and the `elasticsearch/elasticsearch` client constraint to `^8.19` +- [PR-59](https://github.com/itk-dev/event-database-api/pull/59) + Refresh CLAUDE.md/agent docs for current tooling (API Platform 4.3, php8.4 image, PHPStan level 8, test layout) +- [PR-58](https://github.com/itk-dev/event-database-api/pull/58) + Update dependencies in-constraint (Symfony 7.4.14 patch train, phpunit/php-cs-fixer/phpstan-symfony/flex) +- [PR-57](https://github.com/itk-dev/event-database-api/pull/57) + Run Rector from the Claude Code setup (PostToolUse hook + pr-readiness step); drop the redundant + `--user=root`/`COMPOSER_ALLOW_SUPERUSER` from the release workflow (`COMPOSE_USER: runner`) +- [PR-56](https://github.com/itk-dev/event-database-api/pull/56) + Scope CI image pulls to each job's real dependencies (phpfpm, or phpfpm + elasticsearch) instead of the whole stack +- [PR-55](https://github.com/itk-dev/event-database-api/pull/55) + Self-heal the API-spec "up to date" PR comment (resolve to ✅ once the spec matches, no stale 🛑) +- [PR-54](https://github.com/itk-dev/event-database-api/pull/54) + Add Rector (relevant sets aligned with PHPStan), apply it across the codebase, and run it in CI +- [PR-53](https://github.com/itk-dev/event-database-api/pull/53) + Compare effective schemas in the oasdiff gate (flatten-allof) so allOf/$ref restructures are not miscounted +- [PR-52](https://github.com/itk-dev/event-database-api/pull/52) + Pin the problem+json error contract: 401 served as RFC 7807, resource reads in problem+json yield 406 +- [PR-51](https://github.com/itk-dev/event-database-api/pull/51) + Show the API-spec diff summary inline in the PR comment (collapsed) alongside the oasdiff review link +- [PR-49](https://github.com/itk-dev/event-database-api/pull/49) + Bump oasdiff-action to v0.1.5 in the API-spec workflow +- [PR-48](https://github.com/itk-dev/event-database-api/pull/48) + Add an os2display consumer contract test suite exercising the endpoints and filters its feed helper depends on +- [PR-47](https://github.com/itk-dev/event-database-api/pull/47) + Add an aarhusguiden consumer contract test suite exercising the endpoints and filters the site depends on +- [PR-46](https://github.com/itk-dev/event-database-api/pull/46) + Upgrade api-platform/core 4.1 to 4.3. Runtime responses are unchanged except that 401/404 error bodies now use + `hydra:title`/`hydra:description` instead of the unprefixed `title` (`status`, `type` and `detail` unchanged) +- [PR-45](https://github.com/itk-dev/event-database-api/pull/45) + Check committed index mappings against event-database-imports@develop in CI +- [PR-44](https://github.com/itk-dev/event-database-api/pull/44) + Validate deep payload schemas (nested objects, field types) for every resource +- [PR-43](https://github.com/itk-dev/event-database-api/pull/43) + Assert filter identities (not counts), sort order, and pagination edge cases +- [PR-42](https://github.com/itk-dev/event-database-api/pull/42) + Return HTTP 400 (not 500) for malformed date-range filter input; unskip FilterErrorTest +- [PR-41](https://github.com/itk-dev/event-database-api/pull/41) + Run the Code Review workflow via docker compose directly (drop Task) with vendor caching and image pre-pull +- [PR-40](https://github.com/itk-dev/event-database-api/pull/40) + Improve the API-spec workflow (path filter, permissions, vendor cache, oasdiff) +- [PR-39](https://github.com/itk-dev/event-database-api/pull/39) + Update GitHub Actions to latest: actions/checkout v7 and go-task/setup-task v2 +- [PR-38](https://github.com/itk-dev/event-database-api/pull/38) + Extract SearchParamsBuilder from ElasticSearchIndex and unit-test the query DSL +- [PR-37](https://github.com/itk-dev/event-database-api/pull/37) + Upload test coverage to Codecov in CI +- [PR-36](https://github.com/itk-dev/event-database-api/pull/36) + Add unit tests pinning the Elasticsearch filters' query DSL and parameter descriptors +- [PR-35](https://github.com/itk-dev/event-database-api/pull/35) + Test against production-parity Elasticsearch mappings (dynamic: strict) so filter tests exercise real field semantics +- [PR-34](https://github.com/itk-dev/event-database-api/pull/34) + Document the item-as-collection quirk as a versioning TODO and fix the README fixtures wording (no Doctrine) +- [PR-33](https://github.com/itk-dev/event-database-api/pull/33) + Mature the API test suite ahead of the API Platform upgrade (contract, filter, pagination and error tests) +- [PR-32](https://github.com/itk-dev/event-database-api/pull/32) + Pay down the PHPStan baseline: fix the mechanical strict-rule findings (34 → 10) +- [PR-31](https://github.com/itk-dev/event-database-api/pull/31) + Align dev tooling with event-database-imports: PHPStan level 8 + strict rules, PHP 8.4, twig-cs-fixer v4, PHPUnit 13 +- [PR-30](https://github.com/itk-dev/event-database-api/pull/30) + Update vulnerable dependencies (twig, symfony, guzzle) and audit the lock file in CI +- [PR-29](https://github.com/itk-dev/event-database-api/pull/29) + Add Claude Code skills, filter/provider reviewer subagent, and spec-drift hook +- [PR-28](https://github.com/itk-dev/event-database-api/pull/28) + Adapt Claude Code hooks from event-database-imports and guard the ES index contract +- [PR-27](https://github.com/itk-dev/event-database-api/pull/27) + Add Claude Code project setup (CLAUDE.md, agents, skills) + ## [1.2.2] - 2026-05-22 - [PR-26](https://github.com/itk-dev/event-database-api/pull/26) @@ -84,7 +157,10 @@ See [keep a changelog] for information about writing changes to this log. - Added multi-value filtering for Lactions and Organizations [keep a changelog]: https://keepachangelog.com/en/1.1.0/ -[Unreleased]: https://github.com/itk-dev/event-database-api/compare/1.2.0...HEAD +[Unreleased]: https://github.com/itk-dev/event-database-api/compare/1.3.0...HEAD +[1.3.0]: https://github.com/itk-dev/event-database-api/compare/1.2.2...1.3.0 +[1.2.2]: https://github.com/itk-dev/event-database-api/compare/1.2.1...1.2.2 +[1.2.1]: https://github.com/itk-dev/event-database-api/compare/1.2.0...1.2.1 [1.2.0]: https://github.com/itk-dev/event-database-api/compare/1.1.0...1.2.0 [1.1.0]: https://github.com/itk-dev/event-database-api/releases/tag/1.1.0 [1.0.1]: https://github.com/itk-dev/event-database-api/releases/tag/1.0.1 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d94f409 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,182 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this project is + +API platform front-end for the Danish event database used by the municipality of Aarhus. This repo serves a +**read-only** REST/Hydra API (`/api/v2/...`) backed by **Elasticsearch** — data is *indexed* by a separate project, +[`itk-dev/event-database-imports`](https://github.com/itk-dev/event-database-imports). The MariaDB service in +`docker-compose.yml` is part of the standard ITK Dev Symfony image but is **not used for domain data** (the +`migrations/` and `src/Entity/` directories are empty). + +Stack: PHP 8.3+, Symfony 7.4, API Platform 4.3, Elasticsearch 8.x. Runs entirely in Docker via +`itkdev/php8.4-fpm` + nginx. + +## Common commands + +All commands are wrapped in `Taskfile.yml` (run via [Task](https://taskfile.dev)). Most are just +`docker compose exec phpfpm …` underneath — useful to know when `task --dry ` to see the actual command. + +### Setup / running + +```shell +docker compose pull +docker compose up --detach --wait # --wait is important: Elasticsearch is slow to become ready +docker compose exec phpfpm composer install +task fixtures:load # loads demo data from event-database-imports into Elasticsearch +``` + +The site is reachable at `http://$(docker compose port nginx 8080)`. Every API call needs an `X-Api-Key` header +matching one of the entries in `APP_API_KEYS` (JSON array in `.env.local`). + +### Tests + +```shell +task fixtures:load:test --yes # loads tests/resources/*.json into Elasticsearch — REQUIRED before api:test +task api:test # phpunit +task api:test -- --filter EventsFilter # single test class / pattern +``` + +Tests hit a real Elasticsearch (no mocking) — see `tests/ApiPlatform/AbstractApiTestCase.php`. The hardcoded test +API key is `test_api_key`. If a test run dies with "No alive nodes", run `docker compose up --detach --wait` and +reload fixtures. + +The test harness creates each index with a **production-parity mapping** (`dynamic: strict`) checked in at +`tests/resources/mappings/.json` — a hand-kept copy of the importer's `src/Model/Indexing/Mappings/`. +`FixtureLoader::createIndex()` fails loudly if a mapping is missing, so the filter tests exercise real field +semantics (`keyword` = exact/case-sensitive; `text` = tokenised) rather than Elasticsearch dynamic-mapping +artefacts. When the importer changes a mapping, update the matching file here — the `Stop` hook warns locally, +and the `index-mapping-drift` CI job fails if these diverge from the importer's committed `resources/mappings` +export (`.github/workflows/index-mapping-drift.yaml`). + +The suite is split into `tests/ApiPlatform/Contract/` (JSON-LD envelope + deep payload schemas via +`ContractSchemaTest`, `tests/schemas/`), `tests/ApiPlatform/Consumer/` (per-consumer contracts — +`AarhusguidenContractTest`, `Os2displayContractTest`), the per-resource `*Test`/`*FilterTest` (behavioural), and +`tests/Unit/` (filter DSL + `SearchParamsBuilder`, runnable with Elasticsearch stopped). + +Per-resource tests extend `AbstractApiTestCase`, `use` the `GetEntitiesTestTrait` / `GetItemTestTrait` traits, and +configure behaviour purely through static props (`$requestPath`, `$resourceClass`, `$itemId`, `$unknownItemId`) — +`EventsTest.php` is the template. The traits assert the standard contract (401 without a key, 200 + `hydra:*` shape + +JSON-schema match with one, 404 for unknown ids), so a new resource's happy-path coverage is mostly declarative. The +`*FilterTest.php` classes carry the resource-specific filter assertions. Fixture ids referenced by `$itemId` must +exist in `tests/resources/.json`. + +### Lint / static analysis + +```shell +task coding-standards:check # markdown + php-cs-fixer + twig-cs-fixer + prettier (yaml) +task coding-standards:apply # auto-fix all of the above +task code-analysis # PHPStan (level 8) + Rector +``` + +CI (GitHub Actions `pr.yaml`) runs all of these — run them locally before opening a PR. + +### API spec + +`public/spec.yaml` is the committed OpenAPI export and is checked in CI. Regenerate after changing any API resource: + +```shell +task api:spec:export +``` + +## Architecture + +### Request flow + +API Platform resources are **DTOs**, not Doctrine entities. Each resource follows the same pattern — `Event` is the +canonical example: + +1. `src/Api/Dto/.php` — `#[ApiResource]` class declaring operations, pagination, and `#[ApiFilter]` + attributes. The `$id` property is identifier-only; real data is filled in by the provider. +2. `src/Api/State/RepresentationProvider.php` — implements `ProviderInterface`. Receives the operation + + context, asks `AbstractProvider::getFilters()` to compile the declared `#[ApiFilter]` attributes into + Elasticsearch query fragments, then calls `IndexInterface::search()` and returns a `SearchResults` (collection) or + array (single item). +3. `src/Service/ElasticSearch/ElasticSearchIndex.php` — the only `IndexInterface` implementation. Talks to the + Elasticsearch cluster (`INDEX_URL` env var). Paginated results come back via `ElasticSearchPaginator`. + +The seven indices are enumerated in `src/Model/IndexName.php` (`events`, `organizations`, `occurrences`, +`daily_occurrences`, `tags`, `vocabularies`, `locations`). Each index has a matching DTO and provider. + +### Filters + +Custom Elasticsearch filters live in `src/Api/Filter/ElasticSearch/` (`MatchFilter`, `IdFilter`, `BooleanFilter`, +`DateRangeFilter`, `DateFilter`, `TagFilter`). They implement API Platform's `FilterInterface` but `apply()` returns +ES query DSL rather than mutating a Doctrine queryBuilder. `AbstractProvider::getFilters()` separates filter clauses +from sort clauses via the `SortFilterInterface` marker. + +When adding a filter to a resource, attach it with `#[ApiFilter(SomeFilter::class, properties: […])]` on the DTO — +the provider picks them up automatically through `api_platform.filter_locator` (injected via +`config/services.yaml`). + +### Auth + +Stateless. `src/Security/ApiKeyAuthenticator.php` reads `X-Api-Key`; `ApiUserProvider` validates against the +JSON-encoded `APP_API_KEYS` env var. `/api/v2/docs` is public; everything else under `/api` requires a valid key +(see `config/packages/security.yaml`). There is no role model — any valid key gets full read access. + +### What lives where + +- `src/Api/Dto/` + `src/Api/State/` + `src/Api/Filter/ElasticSearch/` — the API layer (see above). +- `src/Service/ElasticSearch/` — the only data access. If you find yourself adding a new persistence concern, this + is where it goes. +- `src/Command/FixturesLoadCommand.php` — loads JSON dumps into Elasticsearch (used by `task fixtures:load*`). +- `src/Model/` — value objects / enums (`IndexName`, `FilterType`, `DateLimit`, `SearchResults`, `DateFilterConfig`). +- `config/reference.php` — auto-generated, excluded from PHP-CS-Fixer (see `.php-cs-fixer.dist.php`). +- `tests/resources/*.json` — fixtures used by the test suite; loaded via + `--url=file:///app/tests/resources/.json`. + +## Conventions worth knowing + +- Don't add Doctrine entities or migrations — domain data is owned by `event-database-imports`. New resources mean + new DTOs + providers, not entities. +- `paginationMaximumItemsPerPage` on a resource caps client-requested page size. + `AbstractProvider::MAX_PAGE_SIZE_FALLBACK = 20` is the safety net when the resource doesn't declare one. +- `failOnDeprecation`, `failOnNotice`, `failOnWarning` are all `true` in `phpunit.dist.xml` — a deprecation warning + will fail the suite. +- PHPStan errors about `App\Api\Dto\*::$id` being unused are intentionally ignored (API Platform writes the property + reflectively). + +## Works with event-database-imports + +This repo is the public **read-only** API; [`event-database-imports`](https://github.com/itk-dev/event-database-imports) +is the **write/admin** side. They are decoupled at runtime and communicate only through a **shared Elasticsearch +cluster** — no shared database, no HTTP call between them. + +- **The importer writes; this repo reads.** The importer runs feed import → normalize → persist (MariaDB) → index + into ES. This repo serves `/api/v2/…` by reading those same ES indices (`INDEX_URL`); it has **no domain + database** and must never write to ES. +- **The importer owns the index lifecycle.** It builds a versioned index `_`, then atomically + repoints the alias. This repo always queries the **alias** — so it never sees a half-built index, but a resource + returns empty if the alias was never populated. +- **The contract is hand-duplicated, with no compile-time link:** + - Index names — `src/Model/IndexName.php` here ↔ `src/Model/Indexing/IndexNames.php` in the importer + (`events`, `organizations`, `occurrences`, `daily_occurrences`, `tags`, `vocabularies`, `locations`). + - Document shape — mappings live **only in the importer** (`src/Model/Indexing/Mappings/`); this repo has none and + trusts the fields/types the importer writes. A renamed or retyped field silently breaks the filters/providers + here (they reference ES field names directly). + - Keep both in sync when changing either. The `Stop` hook (below) warns when `src/Model/IndexName.php` changes. +- **Co-hosted by path prefix** in production: `/api/v2/` → this app, `/admin/` → the importer, via Traefik on the + shared `frontend` network. + +## Claude Code automation + +`.claude/settings.json`, `.claude/agents/`, and `.claude/skills/` configure this repo's Claude Code setup. All hooks +run tooling **inside the `phpfpm` container**. + +- **Hooks** — `SessionStart` boots the Docker stack and checks host prerequisites; `PostToolUse` auto-runs + Rector (on `src`/`tests` PHP), php-cs-fixer, phpstan, twig-cs-fixer, `composer normalize`, prettier, and + markdownlint on the file you just edited (so single-file changes don't need manual formatting); `PreToolUse` + blocks edits to generated/locked/secret files + (`config/reference.php`, lock files, `.env.local`, …); `Stop` validates the DI container (`lint:container`), warns + on ES index-contract changes (`scripts/claude-hook-check-index-contract.sh`), and warns when a resource changed + but `public/spec.yaml` was not regenerated (`scripts/claude-hook-check-spec-drift.sh`). +- **Prerequisite:** `jq` must be installed on the **host** — the Edit/Write hooks read the edited file path from the + tool payload via `jq` and silently no-op without it (`brew install jq`; a `SessionStart` hook warns if missing). +- **Subagents** (`.claude/agents/`): `pr-readiness` (run all CI-equivalent checks), `reload-fixtures` (reload ES + fixtures / recover a not-ready cluster), and `filter-provider-reviewer` (review ES filter/provider changes for + query-DSL correctness and index-contract alignment). +- **Skills** (`.claude/skills/`, user-invocable): `/update-api-spec` (regenerate `public/spec.yaml` after changing + an API resource), `/changelog-entry` (add the CHANGELOG entry in the CI-enforced format), and `/new-resource` + (scaffold a new index-backed resource following the DTO + provider + filter pattern). diff --git a/README.md b/README.md index 409440e..61e4291 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ docker compose exec phpfpm composer install ### Fixtures -The project comes with doctrine fixtures to help development on local machines. They can be loaded with the standard -doctrine fixture load command: +The project comes with Elasticsearch index fixtures to help development on local machines (this API has no Doctrine +database — see [`CLAUDE.md`](CLAUDE.md)). They can be loaded with the `app:fixtures:load` command: ```shell docker compose exec phpfpm bin/console app:fixtures:load @@ -116,6 +116,18 @@ curl --silent --header "X-Api-Key: api_key_1" "http://$(docker compose port ngin curl --silent --header "X-Api-Key: api_key_1" "http://$(docker compose port nginx 8080)/api/v2/events?publicAccess=false" | docker run --rm --interactive ghcr.io/jqlang/jq:latest '.["hydra:member"]|length' ``` +## Known limitations + +The following contract quirk is kept as-is for backwards compatibility and should be revisited when the API is next +versioned (fixing it is a breaking change for API consumers): + +- **Item endpoints return a collection wrapper.** `GET /api/v2/{resource}/{id}` for `events`, `occurrences`, + `daily_occurrences`, `locations` and `organizations` returns a `hydra:Collection` with a single member rather than a + single item, and that member has no `@id`/`@type`. This is because the representation providers return the raw + Elasticsearch `_source` wrapped in an array (see e.g. + [`EventRepresentationProvider`](src/Api/State/EventRepresentationProvider.php)). `tags` and `vocabularies` already + return proper single items. When versioning the API, return a single mapped item with `@id`/`@type` for all resources. + ## Test ``` shell name=run-tests diff --git a/Taskfile.yml b/Taskfile.yml index db67805..b63cd08 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -38,8 +38,6 @@ tasks: task: compose vars: COMPOSE_ARGS: exec phpfpm bin/console app:fixtures:load {{.ITEM}} - # Loading some fixtures generates an 'Warning: Undefined array key "entityId"' error. - ignore_error: true silent: true fixtures:load:test: @@ -57,8 +55,6 @@ tasks: task: compose vars: COMPOSE_ARGS: exec phpfpm bin/console app:fixtures:load {{.ITEM}} --url=file:///app/tests/resources/{{.ITEM}}.json - # Loading some fixtures generates an 'Warning: Undefined array key "entityId"' error. - ignore_error: true composer: desc: "Run `composer` command. Example: task composer -- normalize" @@ -169,6 +165,7 @@ tasks: desc: "Run code analysis" cmds: - task: code-analysis:phpstan + - task: code-analysis:rector silent: true code-analysis:phpstan: @@ -179,6 +176,22 @@ tasks: COMPOSE_ARGS: exec phpfpm vendor/bin/phpstan silent: true + code-analysis:rector: + desc: "Check for Rector suggestions (dry-run; no changes applied)" + cmds: + - task: compose + vars: + COMPOSE_ARGS: exec phpfpm vendor/bin/rector process --dry-run + silent: true + + code-analysis:rector:apply: + desc: "Apply Rector changes" + cmds: + - task: compose + vars: + COMPOSE_ARGS: exec phpfpm vendor/bin/rector process + silent: true + api:test: desc: Run API tests cmds: @@ -186,6 +199,13 @@ tasks: vars: COMPOSE_ARGS: exec phpfpm bin/phpunit + api:test:coverage: + desc: Run API tests with a Clover coverage report (coverage/unit.xml) + cmds: + - task: compose + vars: + COMPOSE_ARGS: exec --env XDEBUG_MODE=coverage phpfpm bin/phpunit --coverage-clover=coverage/unit.xml + api:spec:export: desc: Export API spec cmds: diff --git a/composer.json b/composer.json index c8e856f..ffeefda 100644 --- a/composer.json +++ b/composer.json @@ -7,8 +7,8 @@ "php": ">=8.3", "ext-ctype": "*", "ext-iconv": "*", - "api-platform/core": "~4.1.0", - "elasticsearch/elasticsearch": "^8.13", + "api-platform/core": "~4.3.0", + "elasticsearch/elasticsearch": "^8.19", "nelmio/cors-bundle": "^2.4", "phpdocumentor/reflection-docblock": "^5.6", "phpstan/phpdoc-parser": "^2.2", @@ -31,15 +31,19 @@ "require-dev": { "ergebnis/composer-normalize": "^2.47", "friendsofphp/php-cs-fixer": "^3.86", + "justinrainbow/json-schema": "^6.10", "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-strict-rules": "^2.0", "phpstan/phpstan-symfony": "^2.0", - "phpunit/phpunit": "^12.3", + "phpunit/phpunit": "^13", + "rector/rector": "^2.5", "symfony/browser-kit": "~7.4.0", "symfony/css-selector": "~7.4.0", "symfony/maker-bundle": "^1.52", "symfony/stopwatch": "~7.4.0", "symfony/web-profiler-bundle": "~7.4.0", - "vincentlanglet/twig-cs-fixer": "^3.7" + "vincentlanglet/twig-cs-fixer": "^4.0" }, "replace": { "symfony/polyfill-ctype": "*", diff --git a/composer.lock b/composer.lock index c162754..014ecc7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,48 +4,50 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3f5f887593d98f2480b8292d2099e09a", + "content-hash": "747cb423309e517227c3648b1003fc5a", "packages": [ { "name": "api-platform/core", - "version": "v4.1.28", + "version": "v4.3.16", "source": { "type": "git", "url": "https://github.com/api-platform/core.git", - "reference": "654f8e626ed1e3e0c084bf3a695fba551308ac95" + "reference": "95dd0232dd50e982d37f0590cb35ca3cefd13efa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/core/zipball/654f8e626ed1e3e0c084bf3a695fba551308ac95", - "reference": "654f8e626ed1e3e0c084bf3a695fba551308ac95", + "url": "https://api.github.com/repos/api-platform/core/zipball/95dd0232dd50e982d37f0590cb35ca3cefd13efa", + "reference": "95dd0232dd50e982d37f0590cb35ca3cefd13efa", "shasum": "" }, "require": { + "composer/semver": "^3.4", "doctrine/inflector": "^2.0", "php": ">=8.2", "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", - "symfony/http-kernel": "^6.4 || ^7.0", - "symfony/property-access": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.1", - "symfony/serializer": "^6.4 || ^7.0", + "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/translation-contracts": "^3.3", - "symfony/type-info": "^7.2", - "symfony/validator": "^6.4.11 || ^7.1", - "symfony/web-link": "^6.4 || ^7.1", + "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", "willdurand/negotiation": "^3.1" }, "conflict": { "doctrine/common": "<3.2.2", "doctrine/dbal": "<2.10", "doctrine/mongodb-odm": "<2.4", - "doctrine/orm": "<2.14.0", + "doctrine/orm": "<2.14.0 || 3.0.0", "doctrine/persistence": "<1.3", "phpspec/prophecy": "<1.15", "phpunit/phpunit": "<9.5", "symfony/framework-bundle": "6.4.6 || 7.0.6", + "symfony/object-mapper": "<7.3.4", "symfony/var-exporter": "<6.1.1" }, "replace": { @@ -55,13 +57,14 @@ "api-platform/documentation": "self.version", "api-platform/elasticsearch": "self.version", "api-platform/graphql": "self.version", + "api-platform/hal": "self.version", "api-platform/http-cache": "self.version", "api-platform/hydra": "self.version", "api-platform/json-api": "self.version", - "api-platform/json-hal": "self.version", "api-platform/json-schema": "self.version", "api-platform/jsonld": "self.version", "api-platform/laravel": "self.version", + "api-platform/mcp": "self.version", "api-platform/metadata": "self.version", "api-platform/openapi": "self.version", "api-platform/parameter-validator": "self.version", @@ -72,81 +75,78 @@ "api-platform/validator": "self.version" }, "require-dev": { - "behat/behat": "^3.11", - "behat/mink": "^1.9", - "doctrine/cache": "^1.11 || ^2.1", "doctrine/common": "^3.2.2", "doctrine/dbal": "^4.0", - "doctrine/doctrine-bundle": "^2.11", - "doctrine/mongodb-odm": "^2.10", - "doctrine/mongodb-odm-bundle": "^5.0", + "doctrine/doctrine-bundle": "^2.11 || ^3.1", "doctrine/orm": "^2.17 || ^3.0", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", - "friends-of-behat/mink-browserkit-driver": "^1.3.1", - "friends-of-behat/mink-extension": "^2.2", - "friends-of-behat/symfony-extension": "^2.1", + "friendsofphp/php-cs-fixer": "^3.93", "guzzlehttp/guzzle": "^6.0 || ^7.0", - "illuminate/config": "^11.0 || ^12.0", - "illuminate/contracts": "^11.0 || ^12.0", - "illuminate/database": "^11.0 || ^12.0", - "illuminate/http": "^11.0 || ^12.0", - "illuminate/pagination": "^11.0 || ^12.0", - "illuminate/routing": "^11.0 || ^12.0", - "illuminate/support": "^11.0 || ^12.0", - "jangregor/phpstan-prophecy": "^1.0", - "justinrainbow/json-schema": "^5.2.11", - "laravel/framework": "^11.0 || ^12.0", - "orchestra/testbench": "^9.1", + "illuminate/config": "^11.0 || ^12.0 || ^13.0", + "illuminate/contracts": "^11.0 || ^12.0 || ^13.0", + "illuminate/database": "^11.0 || ^12.0 || ^13.0", + "illuminate/http": "^11.0 || ^12.0 || ^13.0", + "illuminate/pagination": "^11.0 || ^12.0 || ^13.0", + "illuminate/routing": "^11.0 || ^12.0 || ^13.0", + "illuminate/support": "^11.0 || ^12.0 || ^13.0", + "jangregor/phpstan-prophecy": "^2.1.11", + "justinrainbow/json-schema": "^6.5.2", + "laravel/framework": "^11.0 || ^12.0 || ^13.0", + "mcp/sdk": "^0.6", + "orchestra/testbench": "^10.9 || ^11.0", "phpspec/prophecy-phpunit": "^2.2", "phpstan/extension-installer": "^1.1", "phpstan/phpdoc-parser": "^1.29 || ^2.0", - "phpstan/phpstan": "^1.10", - "phpstan/phpstan-doctrine": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "phpstan/phpstan-symfony": "^1.0", - "phpunit/phpunit": "11.5.x-dev", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-doctrine": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpunit/phpunit": "^11.5 || ^12.2", "psr/log": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", - "soyuka/contexts": "^3.3.10", "soyuka/pmu": "^0.2.0", "soyuka/stubs-mongodb": "^1.0", - "symfony/asset": "^6.4 || ^7.0", - "symfony/browser-kit": "^6.4 || ^7.0", - "symfony/cache": "^6.4 || ^7.0", - "symfony/config": "^6.4 || ^7.0", - "symfony/console": "^6.4 || ^7.0", - "symfony/css-selector": "^6.4 || ^7.0", - "symfony/dependency-injection": "^6.4 || ^7.0", - "symfony/doctrine-bridge": "^6.4.2 || ^7.0.2", - "symfony/dom-crawler": "^6.4 || ^7.0", - "symfony/error-handler": "^6.4 || ^7.0", - "symfony/event-dispatcher": "^6.4 || ^7.0", - "symfony/expression-language": "^6.4 || ^7.0", - "symfony/finder": "^6.4 || ^7.0", - "symfony/form": "^6.4 || ^7.0", - "symfony/framework-bundle": "^6.4 || ^7.0", - "symfony/http-client": "^6.4 || ^7.0", - "symfony/intl": "^6.4 || ^7.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/json-streamer": "^7.4 || ^8.0", "symfony/maker-bundle": "^1.24", + "symfony/mcp-bundle": "dev-main", "symfony/mercure-bundle": "*", - "symfony/messenger": "^6.4 || ^7.0", - "symfony/routing": "^6.4 || ^7.0", - "symfony/security-bundle": "^6.4 || ^7.0", - "symfony/security-core": "^6.4 || ^7.0", - "symfony/stopwatch": "^6.4 || ^7.0", - "symfony/string": "^6.4 || ^7.0", - "symfony/twig-bundle": "^6.4 || ^7.0", - "symfony/uid": "^6.4 || ^7.0", - "symfony/web-profiler-bundle": "^6.4 || ^7.0", - "symfony/yaml": "^6.4 || ^7.0", + "symfony/messenger": "^6.4 || ^7.0 || ^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/var-exporter": "^7.4 || ^8.0", + "symfony/web-profiler-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0", "twig/twig": "^1.42.3 || ^2.12 || ^3.0", "webonyx/graphql-php": "^15.0" }, "suggest": { "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", "elasticsearch/elasticsearch": "To support Elasticsearch.", - "ocramius/package-versions": "To display the API Platform's version in the debug bar.", + "opensearch-project/opensearch-php": "To support OpenSearch (^2.5).", "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", "psr/cache-implementation": "To use metadata caching.", "ramsey/uuid": "To support Ramsey's UUID identifiers.", @@ -154,6 +154,7 @@ "symfony/config": "To load XML configuration files.", "symfony/expression-language": "To use authorization features.", "symfony/http-client": "To use the HTTP cache invalidation system.", + "symfony/json-streamer": "To use the JSON Streamer component.", "symfony/messenger": "To support messenger integration.", "symfony/security": "To use authorization features.", "symfony/twig-bundle": "To use the Swagger UI integration.", @@ -174,12 +175,13 @@ "name": "api-platform/api-platform" }, "symfony": { - "require": "^6.4 || ^7.1" + "require": "^6.4 || ^7.1 || ^8.0" }, "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-main": "4.2.x-dev" + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -218,9 +220,86 @@ ], "support": { "issues": "https://github.com/api-platform/core/issues", - "source": "https://github.com/api-platform/core/tree/v4.1.28" + "source": "https://github.com/api-platform/core/tree/v4.3.16" + }, + "time": "2026-07-03T06:59:33+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" }, - "time": "2025-12-05T14:51:01+00:00" + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" }, { "name": "doctrine/deprecations", @@ -477,25 +556,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.3", + "version": "7.14.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86" + "reference": "aef242412e13128b5049864867bb49fc37dd39de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", - "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aef242412e13128b5049864867bb49fc37dd39de", + "reference": "aef242412e13128b5049864867bb49fc37dd39de", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.12.4", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -503,8 +583,8 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.3.2", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.6", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -584,7 +664,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.3" + "source": "https://github.com/guzzle/guzzle/tree/7.14.0" }, "funding": [ { @@ -600,24 +680,25 @@ "type": "tidelift" } ], - "time": "2026-05-20T22:59:19+00:00" + "time": "2026-07-08T22:54:09+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.1", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e", - "reference": "d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -667,7 +748,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.1" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -683,27 +764,29 @@ "type": "tidelift" } ], - "time": "2026-05-19T18:30:48+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.8.1", + "version": "2.12.4", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "718f1ee6a878be5290af3557aeda0c91278361d9" + "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/718f1ee6a878be5290af3557aeda0c91278361d9", - "reference": "718f1ee6a878be5290af3557aeda0c91278361d9", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", + "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -711,8 +794,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -783,7 +867,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.1" + "source": "https://github.com/guzzle/psr7/tree/2.12.4" }, "funding": [ { @@ -799,7 +883,7 @@ "type": "tidelift" } ], - "time": "2026-03-10T09:55:26+00:00" + "time": "2026-07-08T15:56:20+00:00" }, { "name": "nelmio/cors-bundle", @@ -868,16 +952,16 @@ }, { "name": "open-telemetry/api", - "version": "1.7.1", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4" + "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4", - "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be", + "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be", "shasum": "" }, "require": { @@ -887,7 +971,7 @@ "symfony/polyfill-php82": "^1.26" }, "conflict": { - "open-telemetry/sdk": "<=1.0.8" + "open-telemetry/sdk": "<=1.11" }, "type": "library", "extra": { @@ -934,20 +1018,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-10-19T10:49:48+00:00" + "time": "2026-02-25T13:24:05+00:00" }, { "name": "open-telemetry/context", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/context.git", - "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf" + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/d4c4470b541ce72000d18c339cfee633e4c8e0cf", - "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07", "shasum": "" }, "require": { @@ -989,11 +1073,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-09-19T00:05:49+00:00" + "time": "2025-10-19T06:44:33+00:00" }, { "name": "php-http/discovery", @@ -1302,16 +1386,16 @@ }, { "name": "phpdocumentor/type-resolver", - "version": "1.10.1", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "431c02da15e566adb0ad9c5030fa6f6204d9de9e" + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/431c02da15e566adb0ad9c5030fa6f6204d9de9e", - "reference": "431c02da15e566adb0ad9c5030fa6f6204d9de9e", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", "shasum": "" }, "require": { @@ -1354,22 +1438,22 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.1" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" }, - "time": "2025-11-18T07:51:16+00:00" + "time": "2025-11-21T15:09:14+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "version": "2.3.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "shasum": "" }, "require": { @@ -1401,9 +1485,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" }, - "time": "2026-01-25T14:56:51+00:00" + "time": "2026-07-08T07:01:06+00:00" }, { "name": "psr/cache", @@ -1990,16 +2074,16 @@ }, { "name": "symfony/cache", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "902d621e0b6ef0ebeaa133770b5c339a19328589" + "reference": "9adfcb2a7fc3924473b09f5a0b058dcc2cc7be9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/902d621e0b6ef0ebeaa133770b5c339a19328589", - "reference": "902d621e0b6ef0ebeaa133770b5c339a19328589", + "url": "https://api.github.com/repos/symfony/cache/zipball/9adfcb2a7fc3924473b09f5a0b058dcc2cc7be9a", + "reference": "9adfcb2a7fc3924473b09f5a0b058dcc2cc7be9a", "shasum": "" }, "require": { @@ -2070,7 +2154,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.12" + "source": "https://github.com/symfony/cache/tree/v7.4.14" }, "funding": [ { @@ -2090,20 +2174,20 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-17T14:44:48+00:00" }, { "name": "symfony/cache-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/cache-contracts.git", - "reference": "225e8a254166bd3442e370c6f50145465db63831" + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", - "reference": "225e8a254166bd3442e370c6f50145465db63831", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", "shasum": "" }, "require": { @@ -2150,7 +2234,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" }, "funding": [ { @@ -2170,7 +2254,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T15:33:14+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/clock", @@ -2252,16 +2336,16 @@ }, { "name": "symfony/config", - "version": "v7.4.10", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57" + "reference": "7b665e443381ea7c4db03eb03b4bf79ea2b020eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", - "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", + "url": "https://api.github.com/repos/symfony/config/zipball/7b665e443381ea7c4db03eb03b4bf79ea2b020eb", + "reference": "7b665e443381ea7c4db03eb03b4bf79ea2b020eb", "shasum": "" }, "require": { @@ -2307,7 +2391,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.4.10" + "source": "https://github.com/symfony/config/tree/v7.4.14" }, "funding": [ { @@ -2327,20 +2411,20 @@ "type": "tidelift" } ], - "time": "2026-05-03T14:20:49+00:00" + "time": "2026-06-09T07:51:57+00:00" }, { "name": "symfony/console", - "version": "v7.4.11", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075" + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075", - "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", "shasum": "" }, "require": { @@ -2405,7 +2489,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.11" + "source": "https://github.com/symfony/console/tree/v7.4.14" }, "funding": [ { @@ -2425,20 +2509,20 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:04:42+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.4.10", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "4eb0d9dfa9d4f7c59216baf49b3ed6b1fb72293d" + "reference": "2c8c64a33e2e6911579e1ff79a8e06c27d48d402" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/4eb0d9dfa9d4f7c59216baf49b3ed6b1fb72293d", - "reference": "4eb0d9dfa9d4f7c59216baf49b3ed6b1fb72293d", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/2c8c64a33e2e6911579e1ff79a8e06c27d48d402", + "reference": "2c8c64a33e2e6911579e1ff79a8e06c27d48d402", "shasum": "" }, "require": { @@ -2489,7 +2573,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.10" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.14" }, "funding": [ { @@ -2509,20 +2593,20 @@ "type": "tidelift" } ], - "time": "2026-05-06T11:55:30+00:00" + "time": "2026-06-24T07:41:05+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -2560,7 +2644,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -2580,20 +2664,20 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/dotenv", - "version": "v7.4.11", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "82e9b1355c68ef7b96397dbd34cc75a92eebae7c" + "reference": "9b9c7a00e565238857eea0040dc9745e3576402e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/82e9b1355c68ef7b96397dbd34cc75a92eebae7c", - "reference": "82e9b1355c68ef7b96397dbd34cc75a92eebae7c", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/9b9c7a00e565238857eea0040dc9745e3576402e", + "reference": "9b9c7a00e565238857eea0040dc9745e3576402e", "shasum": "" }, "require": { @@ -2638,7 +2722,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v7.4.11" + "source": "https://github.com/symfony/dotenv/tree/v7.4.14" }, "funding": [ { @@ -2658,20 +2742,20 @@ "type": "tidelift" } ], - "time": "2026-05-11T13:02:51+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + "reference": "4e1a093b481f323e6e326451f9760c3868430673" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/4e1a093b481f323e6e326451f9760c3868430673", + "reference": "4e1a093b481f323e6e326451f9760c3868430673", "shasum": "" }, "require": { @@ -2720,7 +2804,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + "source": "https://github.com/symfony/error-handler/tree/v7.4.14" }, "funding": [ { @@ -2740,20 +2824,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.9", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101" + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101", - "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", "shasum": "" }, "require": { @@ -2805,7 +2889,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.9" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" }, "funding": [ { @@ -2825,20 +2909,20 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:18:21+00:00" + "time": "2026-06-06T11:10:32+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -2885,7 +2969,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -2905,20 +2989,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/expression-language", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "87ff95687748f4af65e4d5a6e917d448ec52aa83" + "reference": "bd5763f92959201816ecc31defdf352d2ea473be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/87ff95687748f4af65e4d5a6e917d448ec52aa83", - "reference": "87ff95687748f4af65e4d5a6e917d448ec52aa83", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/bd5763f92959201816ecc31defdf352d2ea473be", + "reference": "bd5763f92959201816ecc31defdf352d2ea473be", "shasum": "" }, "require": { @@ -2953,7 +3037,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v7.4.8" + "source": "https://github.com/symfony/expression-language/tree/v7.4.14" }, "funding": [ { @@ -2973,7 +3057,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "symfony/filesystem", @@ -3047,16 +3131,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e0be088d22278583a82da281886e8c3592fbf149" + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", - "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { @@ -3091,7 +3175,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.8" + "source": "https://github.com/symfony/finder/tree/v7.4.14" }, "funding": [ { @@ -3111,20 +3195,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/flex", - "version": "v2.10.0", + "version": "v2.11.0", "source": { "type": "git", "url": "https://github.com/symfony/flex.git", - "reference": "9cd384775973eabbf6e8b05784dda279fc67c28d" + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/flex/zipball/9cd384775973eabbf6e8b05784dda279fc67c28d", - "reference": "9cd384775973eabbf6e8b05784dda279fc67c28d", + "url": "https://api.github.com/repos/symfony/flex/zipball/4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99", "shasum": "" }, "require": { @@ -3137,9 +3221,9 @@ }, "require-dev": { "composer/composer": "^2.1", - "symfony/dotenv": "^6.4|^7.4|^8.0", + "phpunit/phpunit": "^12.4", + "symfony/dotenv": "^6.4.41|^7.4.13|^8.0.13", "symfony/filesystem": "^6.4|^7.4|^8.0", - "symfony/phpunit-bridge": "^6.4|^7.4|^8.0", "symfony/process": "^6.4|^7.4|^8.0" }, "type": "composer-plugin", @@ -3164,7 +3248,7 @@ "description": "Composer plugin for Symfony", "support": { "issues": "https://github.com/symfony/flex/issues", - "source": "https://github.com/symfony/flex/tree/v2.10.0" + "source": "https://github.com/symfony/flex/tree/v2.11.0" }, "funding": [ { @@ -3184,20 +3268,20 @@ "type": "tidelift" } ], - "time": "2025-11-16T09:38:19+00:00" + "time": "2026-05-29T17:25:22+00:00" }, { "name": "symfony/framework-bundle", - "version": "v7.4.11", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "637f5cac1ac2698a012b41610215bf366004295f" + "reference": "4d11fd50d0a3d2c43b400154ad7ec35b84ea3e5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/637f5cac1ac2698a012b41610215bf366004295f", - "reference": "637f5cac1ac2698a012b41610215bf366004295f", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/4d11fd50d0a3d2c43b400154ad7ec35b84ea3e5b", + "reference": "4d11fd50d0a3d2c43b400154ad7ec35b84ea3e5b", "shasum": "" }, "require": { @@ -3247,7 +3331,7 @@ "symfony/twig-bundle": "<6.4", "symfony/validator": "<6.4", "symfony/web-profiler-bundle": "<6.4", - "symfony/webhook": "<7.2", + "symfony/webhook": "<7.4", "symfony/workflow": "<7.4" }, "require-dev": { @@ -3291,7 +3375,7 @@ "symfony/uid": "^6.4|^7.0|^8.0", "symfony/validator": "^7.4|^8.0", "symfony/web-link": "^6.4|^7.0|^8.0", - "symfony/webhook": "^7.2|^8.0", + "symfony/webhook": "^7.4|^8.0", "symfony/workflow": "^7.4|^8.0", "symfony/yaml": "^7.3|^8.0", "twig/twig": "^3.12" @@ -3322,7 +3406,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.4.11" + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.14" }, "funding": [ { @@ -3342,20 +3426,20 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:04:42+00:00" + "time": "2026-06-27T08:31:38+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.9", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6" + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", - "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", + "url": "https://api.github.com/repos/symfony/http-client/zipball/f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", "shasum": "" }, "require": { @@ -3423,7 +3507,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.9" + "source": "https://github.com/symfony/http-client/tree/v7.4.14" }, "funding": [ { @@ -3443,20 +3527,20 @@ "type": "tidelift" } ], - "time": "2026-04-29T13:25:15+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", "shasum": "" }, "require": { @@ -3505,7 +3589,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" }, "funding": [ { @@ -3525,20 +3609,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:50+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab" + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/06db5ae1552177bf8572f8908839f12e3c06aed3", + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3", "shasum": "" }, "require": { @@ -3587,7 +3671,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.14" }, "funding": [ { @@ -3607,20 +3691,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-11T07:31:44+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d" + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", - "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e99af79b1e776646eda0e1c23b7b45c184ff99be", + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be", "shasum": "" }, "require": { @@ -3706,7 +3790,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.12" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.14" }, "funding": [ { @@ -3726,7 +3810,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T09:27:11+00:00" + "time": "2026-06-27T09:14:35+00:00" }, { "name": "symfony/password-hasher", @@ -3806,16 +3890,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -3864,7 +3948,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -3884,20 +3968,20 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:13:48+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -3949,7 +4033,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -3969,20 +4053,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.37.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -4034,7 +4118,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -4054,20 +4138,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php82", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59" + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59", - "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", "shasum": "" }, "require": { @@ -4114,7 +4198,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" }, "funding": [ { @@ -4134,20 +4218,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-05-26T12:45:58+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.37.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "shasum": "" }, "require": { @@ -4194,7 +4278,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, "funding": [ { @@ -4214,20 +4298,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-27T06:51:48+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -4274,7 +4358,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -4294,20 +4378,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -4354,7 +4438,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -4374,7 +4458,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:10:57+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/property-access", @@ -4549,16 +4633,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204" + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", - "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", "shasum": "" }, "require": { @@ -4610,7 +4694,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.12" + "source": "https://github.com/symfony/routing/tree/v7.4.13" }, "funding": [ { @@ -4630,20 +4714,20 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { "name": "symfony/runtime", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "0b032fa77359745db793df5aff626779180c5f3b" + "reference": "15497f743dd714f3167cb6a56509b9d42e6417b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/0b032fa77359745db793df5aff626779180c5f3b", - "reference": "0b032fa77359745db793df5aff626779180c5f3b", + "url": "https://api.github.com/repos/symfony/runtime/zipball/15497f743dd714f3167cb6a56509b9d42e6417b3", + "reference": "15497f743dd714f3167cb6a56509b9d42e6417b3", "shasum": "" }, "require": { @@ -4651,7 +4735,8 @@ "php": ">=8.2" }, "conflict": { - "symfony/dotenv": "<6.4" + "symfony/dotenv": "<6.4", + "symfony/http-foundation": "<6.4" }, "require-dev": { "composer/composer": "^2.6", @@ -4694,7 +4779,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.4.12" + "source": "https://github.com/symfony/runtime/tree/v7.4.14" }, "funding": [ { @@ -4714,20 +4799,20 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "symfony/security-bundle", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "6f6f859b437fb95028addfa21b417d25daca86d5" + "reference": "6ec147e67262c6f5ac5dbb8b2ccbb34af14c4d79" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/6f6f859b437fb95028addfa21b417d25daca86d5", - "reference": "6f6f859b437fb95028addfa21b417d25daca86d5", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/6ec147e67262c6f5ac5dbb8b2ccbb34af14c4d79", + "reference": "6ec147e67262c6f5ac5dbb8b2ccbb34af14c4d79", "shasum": "" }, "require": { @@ -4806,7 +4891,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v7.4.12" + "source": "https://github.com/symfony/security-bundle/tree/v7.4.14" }, "funding": [ { @@ -4826,20 +4911,20 @@ "type": "tidelift" } ], - "time": "2026-05-15T07:14:02+00:00" + "time": "2026-06-16T15:54:05+00:00" }, { "name": "symfony/security-core", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "efff84605474ec682c7d9c6278088811e6f3caaa" + "reference": "880bb18eff6188d55115e795f06e4185373c35fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/efff84605474ec682c7d9c6278088811e6f3caaa", - "reference": "efff84605474ec682c7d9c6278088811e6f3caaa", + "url": "https://api.github.com/repos/symfony/security-core/zipball/880bb18eff6188d55115e795f06e4185373c35fd", + "reference": "880bb18eff6188d55115e795f06e4185373c35fd", "shasum": "" }, "require": { @@ -4897,7 +4982,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v7.4.12" + "source": "https://github.com/symfony/security-core/tree/v7.4.14" }, "funding": [ { @@ -4917,7 +5002,7 @@ "type": "tidelift" } ], - "time": "2026-05-15T06:48:59+00:00" + "time": "2026-06-09T07:51:57+00:00" }, { "name": "symfony/security-csrf", @@ -4995,16 +5080,16 @@ }, { "name": "symfony/security-http", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "1fc7ca636cbd2cad29b42cc13c9fd0c681c6efee" + "reference": "148e038b91c8cc3e42aee177f8b0117437077c9b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/1fc7ca636cbd2cad29b42cc13c9fd0c681c6efee", - "reference": "1fc7ca636cbd2cad29b42cc13c9fd0c681c6efee", + "url": "https://api.github.com/repos/symfony/security-http/zipball/148e038b91c8cc3e42aee177f8b0117437077c9b", + "reference": "148e038b91c8cc3e42aee177f8b0117437077c9b", "shasum": "" }, "require": { @@ -5063,7 +5148,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v7.4.12" + "source": "https://github.com/symfony/security-http/tree/v7.4.14" }, "funding": [ { @@ -5083,20 +5168,20 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-19T08:40:54+00:00" }, { "name": "symfony/serializer", - "version": "v7.4.10", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "268c5aa6c4bd675eddd89348e7ecac292a843ddd" + "reference": "55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/268c5aa6c4bd675eddd89348e7ecac292a843ddd", - "reference": "268c5aa6c4bd675eddd89348e7ecac292a843ddd", + "url": "https://api.github.com/repos/symfony/serializer/zipball/55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1", + "reference": "55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1", "shasum": "" }, "require": { @@ -5167,7 +5252,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.4.10" + "source": "https://github.com/symfony/serializer/tree/v7.4.14" }, "funding": [ { @@ -5187,20 +5272,20 @@ "type": "tidelift" } ], - "time": "2026-05-03T13:03:28+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -5254,7 +5339,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -5274,20 +5359,20 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", - "version": "v7.4.11", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15" + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/965f7306a43383d02c6aca1e3f3bd2f0ea5dee15", - "reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "shasum": "" }, "require": { @@ -5345,7 +5430,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.11" + "source": "https://github.com/symfony/string/tree/v7.4.13" }, "funding": [ { @@ -5365,20 +5450,20 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:04:42+00:00" + "time": "2026-05-23T15:23:29+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -5427,7 +5512,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -5447,20 +5532,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/twig-bridge", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "81663873d946531129c76c65e80b681ce99c0e89" + "reference": "e4574ab4d5411a7c495d4189b15a8ecfbc720332" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/81663873d946531129c76c65e80b681ce99c0e89", - "reference": "81663873d946531129c76c65e80b681ce99c0e89", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/e4574ab4d5411a7c495d4189b15a8ecfbc720332", + "reference": "e4574ab4d5411a7c495d4189b15a8ecfbc720332", "shasum": "" }, "require": { @@ -5542,7 +5627,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v7.4.12" + "source": "https://github.com/symfony/twig-bridge/tree/v7.4.14" }, "funding": [ { @@ -5562,20 +5647,20 @@ "type": "tidelift" } ], - "time": "2026-04-29T17:13:54+00:00" + "time": "2026-06-17T13:16:29+00:00" }, { "name": "symfony/twig-bundle", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95" + "reference": "11b69c64efdd0c3465403bb2747bf4585add1ec7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95", - "reference": "ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/11b69c64efdd0c3465403bb2747bf4585add1ec7", + "reference": "11b69c64efdd0c3465403bb2747bf4585add1ec7", "shasum": "" }, "require": { @@ -5632,7 +5717,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v7.4.8" + "source": "https://github.com/symfony/twig-bundle/tree/v7.4.14" }, "funding": [ { @@ -5652,7 +5737,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "symfony/type-info", @@ -5739,16 +5824,16 @@ }, { "name": "symfony/validator", - "version": "v7.4.10", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "c76458623af9a3fe3b2e5b09b36453f334c2a361" + "reference": "306d904336166d001751759351d40d5e82312596" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/c76458623af9a3fe3b2e5b09b36453f334c2a361", - "reference": "c76458623af9a3fe3b2e5b09b36453f334c2a361", + "url": "https://api.github.com/repos/symfony/validator/zipball/306d904336166d001751759351d40d5e82312596", + "reference": "306d904336166d001751759351d40d5e82312596", "shasum": "" }, "require": { @@ -5819,7 +5904,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v7.4.10" + "source": "https://github.com/symfony/validator/tree/v7.4.14" }, "funding": [ { @@ -5839,20 +5924,20 @@ "type": "tidelift" } ], - "time": "2026-05-05T15:30:56+00:00" + "time": "2026-06-27T06:16:12+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", "shasum": "" }, "require": { @@ -5906,7 +5991,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" }, "funding": [ { @@ -5926,20 +6011,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:44:50+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.4.9", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "22e03a49c95ef054a43601cd159b222bfab1c701" + "reference": "0118811b1d59f323bf131250b3fb919febfece28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/22e03a49c95ef054a43601cd159b222bfab1c701", - "reference": "22e03a49c95ef054a43601cd159b222bfab1c701", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0118811b1d59f323bf131250b3fb919febfece28", + "reference": "0118811b1d59f323bf131250b3fb919febfece28", "shasum": "" }, "require": { @@ -5987,7 +6072,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.4.9" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.14" }, "funding": [ { @@ -6007,7 +6092,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:18:21+00:00" + "time": "2026-06-27T08:41:53+00:00" }, { "name": "symfony/web-link", @@ -6098,16 +6183,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51" + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/8b6952b56ca6417f25f7a65758cadd0ce02edc51", - "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51", + "url": "https://api.github.com/repos/symfony/yaml/zipball/f8f328665ace2370d1e10645b807ba1646dc7dcc", + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc", "shasum": "" }, "require": { @@ -6150,7 +6235,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.12" + "source": "https://github.com/symfony/yaml/tree/v7.4.14" }, "funding": [ { @@ -6170,20 +6255,20 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "twig/twig", - "version": "v3.26.0", + "version": "v3.28.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc" + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/1fcae487b180d78e6351f4e0afa91f9eab96a2bc", - "reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "shasum": "" }, "require": { @@ -6238,7 +6323,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.26.0" + "source": "https://github.com/twigphp/Twig/tree/v3.28.0" }, "funding": [ { @@ -6250,27 +6335,27 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:31:59+00:00" + "time": "2026-07-03T20:44:34+00:00" }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -6279,8 +6364,12 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { - "dev-master": "1.10-dev" + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -6296,6 +6385,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -6306,9 +6399,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-06-15T15:31:57+00:00" }, { "name": "willdurand/negotiation", @@ -6434,28 +6527,29 @@ }, { "name": "composer/pcre", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.10" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { @@ -6493,7 +6587,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { @@ -6503,44 +6597,38 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { - "name": "composer/semver", - "version": "3.4.4", + "name": "composer/xdebug-handler", + "version": "3.0.5", "source": { "type": "git", - "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", "shasum": "" }, "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" }, "require-dev": { - "phpstan/phpstan": "^1.11", - "symfony/phpunit-bridge": "^3 || ^7" + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - } - }, "autoload": { "psr-4": { - "Composer\\Semver\\": "src" + "Composer\\XdebugHandler\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -6549,32 +6637,19 @@ ], "authors": [ { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "http://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - }, - { - "name": "Rob Bast", - "email": "rob.bast@gmail.com", - "homepage": "http://robbast.nl" + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" } ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", + "description": "Restarts a process without Xdebug.", "keywords": [ - "semantic", - "semver", - "validation", - "versioning" + "Xdebug", + "performance" ], "support": { "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" }, "funding": [ { @@ -6584,38 +6659,61 @@ { "url": "https://github.com/composer", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" } ], - "time": "2025-08-20T19:15:30+00:00" + "time": "2024-05-06T16:37:16+00:00" }, { - "name": "composer/xdebug-handler", - "version": "3.0.5", + "name": "ergebnis/agent-detector", + "version": "1.2.0", "source": { "type": "git", - "url": "https://github.com/composer/xdebug-handler.git", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", "shasum": "" }, "require": { - "composer/pcre": "^1 || ^2 || ^3", - "php": "^7.2.5 || ^8.0", - "psr/log": "^1 || ^2 || ^3" + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" }, "require-dev": { - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, "autoload": { "psr-4": { - "Composer\\XdebugHandler\\": "src" + "Ergebnis\\AgentDetector\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -6624,48 +6722,32 @@ ], "authors": [ { - "name": "John Stevenson", - "email": "john-stevenson@blueyonder.co.uk" + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" } ], - "description": "Restarts a process without Xdebug.", - "keywords": [ - "Xdebug", - "performance" - ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-05-06T16:37:16+00:00" + "time": "2026-05-07T08:19:07+00:00" }, { "name": "ergebnis/composer-normalize", - "version": "2.48.2", + "version": "2.52.0", "source": { "type": "git", "url": "https://github.com/ergebnis/composer-normalize.git", - "reference": "86dc9731b8320f49e9be9ad6d8e4de9b8b0e9b8b" + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/86dc9731b8320f49e9be9ad6d8e4de9b8b0e9b8b", - "reference": "86dc9731b8320f49e9be9ad6d8e4de9b8b0e9b8b", + "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/988f83f5e51a42cdd2337e5fcd935432f8dfa33c", + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c", "shasum": "" }, "require": { @@ -6679,27 +6761,27 @@ "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "composer/composer": "^2.8.3", + "composer/composer": "^2.9.8", "ergebnis/license": "^2.7.0", - "ergebnis/php-cs-fixer-config": "^6.53.0", - "ergebnis/phpstan-rules": "^2.11.0", - "ergebnis/phpunit-slow-test-detector": "^2.20.0", + "ergebnis/php-cs-fixer-config": "^6.62.1", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", "fakerphp/faker": "^1.24.1", - "infection/infection": "~0.26.6", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.17", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.7", - "phpstan/phpstan-strict-rules": "^2.0.6", - "phpunit/phpunit": "^9.6.20", - "rector/rector": "^2.1.4", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.11", + "phpunit/phpunit": "^9.6.33", + "rector/rector": "^2.4.3", "symfony/filesystem": "^5.4.41" }, "type": "composer-plugin", "extra": { "class": "Ergebnis\\Composer\\Normalize\\NormalizePlugin", "branch-alias": { - "dev-main": "2.49-dev" + "dev-main": "2.52-dev" }, "plugin-optional": true, "composer-normalize": { @@ -6736,7 +6818,7 @@ "security": "https://github.com/ergebnis/composer-normalize/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/composer-normalize" }, - "time": "2025-09-06T11:42:34+00:00" + "time": "2026-05-15T15:39:24+00:00" }, { "name": "ergebnis/json", @@ -6895,36 +6977,38 @@ }, { "name": "ergebnis/json-pointer", - "version": "3.7.1", + "version": "3.8.0", "source": { "type": "git", "url": "https://github.com/ergebnis/json-pointer.git", - "reference": "43bef355184e9542635e35dd2705910a3df4c236" + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/43bef355184e9542635e35dd2705910a3df4c236", - "reference": "43bef355184e9542635e35dd2705910a3df4c236", + "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/b58c3c468a7ff109fdf9a255f17de29ecbe5276c", + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c", "shasum": "" }, "require": { "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.43.0", - "ergebnis/data-provider": "^3.2.0", - "ergebnis/license": "^2.4.0", - "ergebnis/php-cs-fixer-config": "^6.32.0", - "ergebnis/phpunit-slow-test-detector": "^2.15.0", - "fakerphp/faker": "^1.23.1", + "ergebnis/composer-normalize": "^2.50.0", + "ergebnis/data-provider": "^3.6.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.16.0", + "fakerphp/faker": "^1.24.1", "infection/infection": "~0.26.6", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^1.12.10", - "phpstan/phpstan-deprecation-rules": "^1.2.1", - "phpstan/phpstan-phpunit": "^1.4.0", - "phpstan/phpstan-strict-rules": "^1.6.1", - "phpunit/phpunit": "^9.6.19", - "rector/rector": "^1.2.10" + "phpstan/phpstan": "^2.1.46", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.0" }, "type": "library", "extra": { @@ -6964,7 +7048,7 @@ "security": "https://github.com/ergebnis/json-pointer/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/json-pointer" }, - "time": "2025-09-06T09:28:19+00:00" + "time": "2026-04-07T14:52:13+00:00" }, { "name": "ergebnis/json-printer", @@ -7228,22 +7312,23 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.89.2", + "version": "v3.95.12", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "7569658f91e475ec93b99bd5964b059ad1336dcf" + "reference": "b1b9055997a98dce3c2338e884626e718a25a923" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/7569658f91e475ec93b99bd5964b059ad1336dcf", - "reference": "7569658f91e475ec93b99bd5964b059ad1336dcf", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b1b9055997a98dce3c2338e884626e718a25a923", + "reference": "b1b9055997a98dce3c2338e884626e718a25a923", "shasum": "" }, "require": { "clue/ndjson-react": "^1.3", "composer/semver": "^3.4", "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", "ext-filter": "*", "ext-hash": "*", "ext-json": "*", @@ -7254,31 +7339,32 @@ "react/event-loop": "^1.5", "react/socket": "^1.16", "react/stream": "^1.4", - "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0", - "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0", - "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0", - "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0", - "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0", - "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0", - "symfony/polyfill-mbstring": "^1.33", - "symfony/polyfill-php80": "^1.33", - "symfony/polyfill-php81": "^1.33", - "symfony/polyfill-php84": "^1.33", - "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2", - "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0" + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.7", - "infection/infection": "^0.31.0", - "justinrainbow/json-schema": "^6.5", - "keradus/cli-executor": "^2.2", + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", - "php-coveralls/php-coveralls": "^2.9", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", - "phpunit/phpunit": "^9.6.25 || ^10.5.53 || ^11.5.34", - "symfony/var-dumper": "^5.4.48 || ^6.4.24 || ^7.3.2", - "symfony/yaml": "^5.4.45 || ^6.4.24 || ^7.3.2" + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -7293,7 +7379,7 @@ "PhpCsFixer\\": "src/" }, "exclude-from-classmap": [ - "src/Fixer/Internal/*" + "src/**/Internal/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -7319,7 +7405,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.89.2" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.12" }, "funding": [ { @@ -7327,20 +7413,20 @@ "type": "github" } ], - "time": "2025-11-06T21:12:50+00:00" + "time": "2026-07-07T13:29:36+00:00" }, { "name": "justinrainbow/json-schema", - "version": "6.6.4", + "version": "6.10.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7" + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2eeb75d21cf73211335888e7f5e6fd7440723ec7", - "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", "shasum": "" }, "require": { @@ -7350,7 +7436,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "^23.2", + "json-schema/json-schema-test-suite": "dev-main", "marc-mabe/php-enum-phpstan": "^2.0", "phpspec/prophecy": "^1.19", "phpstan/phpstan": "^1.12", @@ -7400,9 +7486,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.4" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.10.0" }, - "time": "2025-12-19T15:01:32+00:00" + "time": "2026-06-16T20:50:26+00:00" }, { "name": "localheinz/diff", @@ -7534,16 +7620,16 @@ }, { "name": "masterminds/html5", - "version": "2.10.0", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fcf91eb64359852f00d921887b219479b4f21251" + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", - "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", "shasum": "" }, "require": { @@ -7551,7 +7637,7 @@ "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" }, "type": "library", "extra": { @@ -7595,9 +7681,9 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" }, - "time": "2025-07-25T09:04:22+00:00" + "time": "2026-06-23T18:43:15+00:00" }, { "name": "myclabs/deep-copy", @@ -7661,20 +7747,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -7713,9 +7798,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -7885,11 +7970,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.55", + "version": "2.2.5", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566", - "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", "shasum": "" }, "require": { @@ -7912,6 +7997,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -7934,20 +8030,71 @@ "type": "github" } ], - "time": "2026-05-18T11:57:34+00:00" + "time": "2026-07-05T06:31:06+00:00" + }, + { + "name": "phpstan/phpstan-strict-rules", + "version": "2.0.11", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-strict-rules.git", + "reference": "9b000a578b85b32945b358b172c7b20e91189024" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/9b000a578b85b32945b358b172c7b20e91189024", + "reference": "9b000a578b85b32945b358b172c7b20e91189024", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.39" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Extra strict and opinionated rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.11" + }, + "time": "2026-05-02T06:54:10+00:00" }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.18", + "version": "2.0.20", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d" + "reference": "53f1a6462dbe71fad36ce054caf5e1b725b740fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d", - "reference": "a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/53f1a6462dbe71fad36ce054caf5e1b725b740fd", + "reference": "53f1a6462dbe71fad36ce054caf5e1b725b740fd", "shasum": "" }, "require": { @@ -8006,39 +8153,41 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.18" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.20" }, - "time": "2026-05-18T14:51:49+00:00" + "time": "2026-06-16T09:17:35+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.6", + "version": "14.2.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "876099a072646c7745f673d7aeab5382c4439691" + "reference": "82f6e49ff224e2cde923d74425e583a883910783" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", - "reference": "876099a072646c7745f673d7aeab5382c4439691", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783", + "reference": "82f6e49ff224e2cde923d74425e583a883910783", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", + "ext-mbstring": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.7.0", - "php": ">=8.3", - "phpunit/php-text-template": "^5.0", - "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", - "sebastian/lines-of-code": "^4.0", - "sebastian/version": "^6.0", + "nikic/php-parser": "^5.8.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.1", + "sebastian/version": "^7.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^13.2.2" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -8047,7 +8196,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5.x-dev" + "dev-main": "14.2.x-dev" } }, "autoload": { @@ -8076,7 +8225,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.3" }, "funding": [ { @@ -8096,32 +8245,32 @@ "type": "tidelift" } ], - "time": "2026-04-15T08:23:17+00:00" + "time": "2026-07-06T15:04:02+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.1", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -8149,7 +8298,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" }, "funding": [ { @@ -8169,28 +8318,28 @@ "type": "tidelift" } ], - "time": "2026-02-02T14:04:18+00:00" + "time": "2026-02-06T04:33:26+00:00" }, { "name": "phpunit/php-invoker", - "version": "6.0.0", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", - "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "suggest": { "ext-pcntl": "*" @@ -8198,7 +8347,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -8225,40 +8374,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:58+00:00" + "time": "2026-02-06T04:34:47+00:00" }, { "name": "phpunit/php-text-template", - "version": "5.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", - "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -8285,40 +8446,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" } ], - "time": "2025-02-07T04:59:16+00:00" + "time": "2026-02-06T04:36:37+00:00" }, { "name": "phpunit/php-timer", - "version": "8.0.0", + "version": "9.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", - "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -8345,56 +8518,70 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" } ], - "time": "2025-02-07T04:59:38+00:00" + "time": "2026-02-06T04:37:53+00:00" }, { "name": "phpunit/phpunit", - "version": "12.5.26", + "version": "13.2.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "e78c9ad74f73fd3642a23e65ace83746dc8df26d" + "reference": "8f5180f4627fc1978be2f61d8d9979dbe37e0c10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e78c9ad74f73fd3642a23e65ace83746dc8df26d", - "reference": "e78c9ad74f73fd3642a23e65ace83746dc8df26d", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8f5180f4627fc1978be2f61d8d9979dbe37e0c10", + "reference": "8f5180f4627fc1978be2f61d8d9979dbe37e0c10", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.6", - "phpunit/php-file-iterator": "^6.0.1", - "phpunit/php-invoker": "^6.0.0", - "phpunit/php-text-template": "^5.0.0", - "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.1", - "sebastian/comparator": "^7.1.8", - "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.1.1", - "sebastian/exporter": "^7.0.3", - "sebastian/global-state": "^8.0.2", - "sebastian/object-enumerator": "^7.0.0", - "sebastian/recursion-context": "^7.0.1", - "sebastian/type": "^6.0.4", - "sebastian/version": "^6.0.0", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.2.3", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.3.0", + "sebastian/diff": "^9.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.1.0", + "sebastian/file-filter": "^1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.1", + "sebastian/version": "^7.0.0", "staabm/side-effects-detector": "^1.0.5" }, "bin": [ @@ -8403,7 +8590,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5-dev" + "dev-main": "13.2-dev" } }, "autoload": { @@ -8435,7 +8622,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.26" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.4" }, "funding": [ { @@ -8443,7 +8630,7 @@ "type": "other" } ], - "time": "2026-05-21T12:36:53+00:00" + "time": "2026-07-08T08:36:51+00:00" }, { "name": "react/cache", @@ -8594,16 +8781,16 @@ }, { "name": "react/dns", - "version": "v1.13.0", + "version": "v1.14.0", "source": { "type": "git", "url": "https://github.com/reactphp/dns.git", - "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5" + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", - "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", "shasum": "" }, "require": { @@ -8658,7 +8845,7 @@ ], "support": { "issues": "https://github.com/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.13.0" + "source": "https://github.com/reactphp/dns/tree/v1.14.0" }, "funding": [ { @@ -8666,20 +8853,20 @@ "type": "open_collective" } ], - "time": "2024-06-13T14:18:03+00:00" + "time": "2025-11-18T19:34:28+00:00" }, { "name": "react/event-loop", - "version": "v1.5.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/reactphp/event-loop.git", - "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", - "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", "shasum": "" }, "require": { @@ -8730,7 +8917,7 @@ ], "support": { "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" }, "funding": [ { @@ -8738,7 +8925,7 @@ "type": "open_collective" } ], - "time": "2023-11-13T13:48:05+00:00" + "time": "2025-11-17T20:46:25+00:00" }, { "name": "react/promise", @@ -8815,16 +9002,16 @@ }, { "name": "react/socket", - "version": "v1.16.0", + "version": "v1.17.0", "source": { "type": "git", "url": "https://github.com/reactphp/socket.git", - "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1" + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", - "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", "shasum": "" }, "require": { @@ -8883,7 +9070,7 @@ ], "support": { "issues": "https://github.com/reactphp/socket/issues", - "source": "https://github.com/reactphp/socket/tree/v1.16.0" + "source": "https://github.com/reactphp/socket/tree/v1.17.0" }, "funding": [ { @@ -8891,7 +9078,7 @@ "type": "open_collective" } ], - "time": "2024-07-26T10:38:09+00:00" + "time": "2025-11-19T20:47:34+00:00" }, { "name": "react/stream", @@ -8971,30 +9158,90 @@ ], "time": "2024-06-11T12:45:25+00:00" }, + { + "name": "rector/rector", + "version": "2.5.4", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/adaa18d7cd6b3c960967cfbc98c03efb3116ac0e", + "reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.2.2" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "suggest": { + "ext-dom": "To manipulate phpunit.xml via the custom-rule command" + }, + "bin": [ + "bin/rector" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.5.4" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2026-07-06T12:41:46+00:00" + }, { "name": "sebastian/cli-parser", - "version": "4.2.1", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", - "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.2-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -9018,7 +9265,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" }, "funding": [ { @@ -9038,31 +9285,31 @@ "type": "tidelift" } ], - "time": "2026-05-17T05:29:34+00:00" + "time": "2026-02-06T04:39:44+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.8", + "version": "8.3.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "7c65c1e79836812819705b473a90c12399542485" + "reference": "c025fc7604afab3f195fab7cdaf72327331af241" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", - "reference": "7c65c1e79836812819705b473a90c12399542485", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c025fc7604afab3f195fab7cdaf72327331af241", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.3", - "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0.3" + "php": ">=8.4", + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.1.0" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^13.2" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -9070,7 +9317,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.1-dev" + "dev-main": "8.3-dev" } }, "autoload": { @@ -9110,7 +9357,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + "source": "https://github.com/sebastianbergmann/comparator/tree/8.3.0" }, "funding": [ { @@ -9130,33 +9377,33 @@ "type": "tidelift" } ], - "time": "2026-05-21T04:45:25+00:00" + "time": "2026-06-05T03:06:45+00:00" }, { "name": "sebastian/complexity", - "version": "5.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", - "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -9180,41 +9427,53 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:25+00:00" + "time": "2026-02-06T04:41:32+00:00" }, { "name": "sebastian/diff", - "version": "7.0.0", + "version": "9.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0", - "symfony/process": "^7.2" + "phpunit/phpunit": "^13.2", + "symfony/process": "^7.4.13" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -9247,35 +9506,47 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:46+00:00" + "time": "2026-06-05T03:04:51+00:00" }, { "name": "sebastian/environment", - "version": "8.1.1", + "version": "9.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "334bc42a97ec6fc44c59001dc3467e0d739a20e9" + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/334bc42a97ec6fc44c59001dc3467e0d739a20e9", - "reference": "334bc42a97ec6fc44c59001dc3467e0d739a20e9", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^13.1.11" }, "suggest": { "ext-posix": "*" @@ -9283,7 +9554,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.1-dev" + "dev-main": "9.3-dev" } }, "autoload": { @@ -9311,7 +9582,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.1.1" + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" }, "funding": [ { @@ -9331,34 +9602,34 @@ "type": "tidelift" } ], - "time": "2026-05-21T08:45:32+00:00" + "time": "2026-05-25T13:41:38+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.3", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", - "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c0d29a945f8cf82f300a05e69874508e307ca4c6", + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.3", - "sebastian/recursion-context": "^7.0.1" + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^13.1.10" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -9401,7 +9672,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.0" }, "funding": [ { @@ -9421,35 +9692,173 @@ "type": "tidelift" } ], - "time": "2026-05-20T04:37:17+00:00" + "time": "2026-05-21T11:50:56+00:00" + }, + { + "name": "sebastian/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "9.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", "shasum": "" }, "require": { - "php": ">=8.3", - "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.1.13" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -9475,7 +9884,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" }, "funding": [ { @@ -9495,33 +9904,33 @@ "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2026-06-01T15:11:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "4.0.1", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", - "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7", "shasum": "" }, "require": { "nikic/php-parser": "^5.7.0", - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^13.1.10" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -9545,7 +9954,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1" }, "funding": [ { @@ -9565,34 +9974,34 @@ "type": "tidelift" } ], - "time": "2026-05-19T16:22:07+00:00" + "time": "2026-05-19T16:23:37+00:00" }, { "name": "sebastian/object-enumerator", - "version": "7.0.0", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", - "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", "shasum": "" }, "require": { - "php": ">=8.3", - "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -9615,40 +10024,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:48+00:00" + "time": "2026-02-06T04:46:36+00:00" }, { "name": "sebastian/object-reflector", - "version": "5.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", - "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -9671,40 +10092,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:17+00:00" + "time": "2026-02-06T04:47:13+00:00" }, { "name": "sebastian/recursion-context", - "version": "7.0.1", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", - "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -9735,7 +10168,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" }, "funding": [ { @@ -9755,32 +10188,32 @@ "type": "tidelift" } ], - "time": "2025-08-13T04:44:59+00:00" + "time": "2026-02-06T04:51:28+00:00" }, { "name": "sebastian/type", - "version": "6.0.4", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "82ff822c2edc46724be9f7411d3163021f602773" + "reference": "fee0309275847fefd7636167085e379c1dbf6990" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", - "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^13.1.10" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9804,7 +10237,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" }, "funding": [ { @@ -9824,29 +10257,29 @@ "type": "tidelift" } ], - "time": "2026-05-20T06:45:45+00:00" + "time": "2026-05-20T06:49:11+00:00" }, { "name": "sebastian/version", - "version": "6.0.0", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", - "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9870,15 +10303,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/version/issues", "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" } ], - "time": "2025-02-07T05:00:38+00:00" + "time": "2026-02-06T04:52:52+00:00" }, { "name": "staabm/side-effects-detector", @@ -9934,16 +10379,16 @@ }, { "name": "symfony/browser-kit", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521" + "reference": "bb28e8761a6c33975972948010f00d4a10f0a634" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/41850d8f8ddef9a9cd7314fa9f4902cf48885521", - "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/bb28e8761a6c33975972948010f00d4a10f0a634", + "reference": "bb28e8761a6c33975972948010f00d4a10f0a634", "shasum": "" }, "require": { @@ -9983,7 +10428,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v7.4.8" + "source": "https://github.com/symfony/browser-kit/tree/v7.4.14" }, "funding": [ { @@ -10003,7 +10448,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "symfony/css-selector", @@ -10318,16 +10763,16 @@ }, { "name": "symfony/process", - "version": "v7.4.11", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0" + "reference": "f5804be144caceb570f6747519999636b664f24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0", - "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", "shasum": "" }, "require": { @@ -10359,7 +10804,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.11" + "source": "https://github.com/symfony/process/tree/v7.4.13" }, "funding": [ { @@ -10379,7 +10824,7 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:55:21+00:00" + "time": "2026-05-23T16:05:06+00:00" }, { "name": "symfony/stopwatch", @@ -10449,16 +10894,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "558fe81a383302318d9b92f7661deb731153c86e" + "reference": "5dead36a9202a6008b54b95308bce7ab97a41fe0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/558fe81a383302318d9b92f7661deb731153c86e", - "reference": "558fe81a383302318d9b92f7661deb731153c86e", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/5dead36a9202a6008b54b95308bce7ab97a41fe0", + "reference": "5dead36a9202a6008b54b95308bce7ab97a41fe0", "shasum": "" }, "require": { @@ -10515,7 +10960,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.12" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.14" }, "funding": [ { @@ -10535,7 +10980,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "theseer/tokenizer", @@ -10589,37 +11034,37 @@ }, { "name": "vincentlanglet/twig-cs-fixer", - "version": "3.10.0", + "version": "4.0.2", "source": { "type": "git", "url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git", - "reference": "ee9b6a31d2c2522b2773ecf31f5d29c2e26311a6" + "reference": "1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/ee9b6a31d2c2522b2773ecf31f5d29c2e26311a6", - "reference": "ee9b6a31d2c2522b2773ecf31f5d29c2e26311a6", + "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a", + "reference": "1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a", "shasum": "" }, "require": { "composer-runtime-api": "^2.0.0", "ext-ctype": "*", - "ext-json": "*", - "php": ">=8.0", + "php": ">=8.1", "symfony/console": "^5.4.9 || ^6.4 || ^7.0 || ^8.0", "symfony/filesystem": "^5.4 || ^6.4 || ^7.0 || ^8.0", "symfony/finder": "^5.4 || ^6.4 || ^7.0 || ^8.0", "symfony/string": "^5.4.42 || ^6.4.10 || ~7.0.10 || ^7.1.3 || ^8.0", - "twig/twig": "^3.4", - "webmozart/assert": "^1.10" + "twig/twig": "^3.15", + "webmozart/assert": "^1.10 || ^2.0" }, "require-dev": { "composer/semver": "^3.2.0", "dereuromark/composer-prefer-lowest": "^0.1.10", "ergebnis/composer-normalize": "^2.29", "friendsofphp/php-cs-fixer": "^3.13.0", - "infection/infection": "^0.26.16 || ^0.29.14", + "infection/infection": "^0.26.16 || ^0.32.0", "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpstan/phpstan-symfony": "^2.0", @@ -10654,7 +11099,7 @@ "homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer", "support": { "issues": "https://github.com/VincentLanglet/Twig-CS-Fixer/issues", - "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/3.10.0" + "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/4.0.2" }, "funding": [ { @@ -10662,7 +11107,7 @@ "type": "github" } ], - "time": "2025-09-15T11:28:55+00:00" + "time": "2026-06-29T15:22:14+00:00" } ], "aliases": [], diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 3b0081b..416fa4c 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -60,7 +60,7 @@ api_platform: exception_to_status: # The 4 following handlers are registered by default, keep those lines to prevent unexpected side effects Symfony\Component\Serializer\Exception\ExceptionInterface: 400 # Use a raw status code (recommended) - ApiPlatform\Exception\InvalidArgumentException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST + ApiPlatform\Metadata\Exception\InvalidArgumentException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST ApiPlatform\ParameterValidator\Exception\ValidationExceptionInterface: 400 Doctrine\ORM\OptimisticLockException: 409 diff --git a/config/reference.php b/config/reference.php index 1cb7eb3..e03ffd3 100644 --- a/config/reference.php +++ b/config/reference.php @@ -121,7 +121,7 @@ * } * @psalm-type ServicesConfig = array{ * _defaults?: DefaultsType, - * _instanceof?: InstanceofType, + * _instanceof?: array, * ... * } * @psalm-type ExtensionType = array @@ -1069,7 +1069,11 @@ * inflector?: scalar|Param|null, // Specify an inflector to use. // Default: "api_platform.metadata.inflector" * validator?: array{ * serialize_payload_fields?: mixed, // Set to null to serialize all payload fields when a validation error is thrown, or set the fields you want to include explicitly. // Default: [] - * query_parameter_validation?: bool|Param, // Default: true + * query_parameter_validation?: bool|Param, // Deprecated: Will be removed in API Platform 5.0. // Default: true + * }, + * jsonapi?: array{ + * use_iri_as_id?: bool|Param, // Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses. // Default: true + * allow_client_generated_id?: bool|Param, // Allow client-generated IDs on JSON:API POST per https://jsonapi.org/format/#crud-creating-client-ids. Off by default to prevent id spoofing on public endpoints. // Default: false * }, * eager_loading?: bool|array{ * enabled?: bool|Param, // Default: true @@ -1079,13 +1083,15 @@ * }, * handle_symfony_errors?: bool|Param, // Allows to handle symfony exceptions. // Default: false * enable_swagger?: bool|Param, // Enable the Swagger documentation and export. // Default: true + * enable_json_streamer?: bool|Param, // Enable json streamer. // Default: false * enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: true * enable_re_doc?: bool|Param, // Enable ReDoc // Default: true + * enable_scalar?: bool|Param, // Enable Scalar API Reference // Default: true * enable_entrypoint?: bool|Param, // Enable the entrypoint // Default: true * enable_docs?: bool|Param, // Enable the docs // Default: true * enable_profiler?: bool|Param, // Enable the data collector and the WebProfilerBundle integration. // Default: true * enable_phpdoc_parser?: bool|Param, // Enable resource metadata collector using PHPStan PhpDocParser. // Default: true - * enable_link_security?: bool|Param, // Enable security for Links (sub resources) // Default: false + * enable_link_security?: bool|Param, // Deprecated: This option is always enabled and will be removed in API Platform 5.0. // Enable security for Links (sub resources). // Default: true * collection?: array{ * exists_parameter_name?: scalar|Param|null, // The name of the query parameter to filter on nullable field values. // Default: "exists" * order?: scalar|Param|null, // The default order of results. // Default: "ASC" @@ -1100,6 +1106,7 @@ * }, * }, * mapping?: array{ + * imports?: list, * paths?: list, * }, * resource_class_directories?: list, @@ -1134,7 +1141,9 @@ * enabled?: bool|Param, // Default: true * }, * max_query_depth?: int|Param, // Default: 20 - * graphql_playground?: array, + * graphql_playground?: bool|array{ // Deprecated: The "graphql_playground" configuration is deprecated and will be ignored. + * enabled?: bool|Param, // Default: false + * }, * max_query_complexity?: int|Param, // Default: 500 * nesting_separator?: scalar|Param|null, // The separator to use to filter nested fields. // Default: "_" * collection?: array{ @@ -1166,7 +1175,7 @@ * max_header_length?: int|Param, // Max header length supported by the cache server. // Default: 7500 * request_options?: mixed, // To pass options to the client charged with the request. // Default: [] * purger?: scalar|Param|null, // 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"). // Default: "api_platform.http_cache.purger.varnish" - * xkey?: array{ // Deprecated: The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate paramters. + * xkey?: array{ // Deprecated: The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate parameters. * glue?: scalar|Param|null, // xkey glue between keys // Default: " " * }, * }, @@ -1182,6 +1191,9 @@ * elasticsearch?: bool|array{ * enabled?: bool|Param, // Default: false * hosts?: list, + * ssl_ca_bundle?: scalar|Param|null, // Path to the SSL CA bundle file for Elasticsearch SSL verification. // Default: null + * ssl_verification?: bool|Param, // Enable or disable SSL verification for Elasticsearch connections. // Default: true + * client?: "elasticsearch"|"opensearch"|Param, // The search engine client to use: "elasticsearch" or "opensearch". // Default: "elasticsearch" * }, * openapi?: array{ * contact?: array{ @@ -1197,12 +1209,21 @@ * license?: array{ * name?: scalar|Param|null, // The license name used for the API. // Default: null * url?: scalar|Param|null, // URL to the license used for the API. MUST be in the format of a URL. // Default: null + * identifier?: scalar|Param|null, // An SPDX license expression for the API. The identifier field is mutually exclusive of the url field. // Default: null * }, * swagger_ui_extra_configuration?: mixed, // To pass extra configuration to Swagger UI, like docExpansion or filter. // Default: [] + * scalar_extra_configuration?: mixed, // To pass extra configuration to Scalar API Reference, like theme or darkMode. // Default: [] * overrideResponses?: bool|Param, // Whether API Platform adds automatic responses to the OpenAPI documentation. // Default: true + * error_resource_class?: scalar|Param|null, // The class used to represent errors in the OpenAPI documentation. // Default: null + * validation_error_resource_class?: scalar|Param|null, // The class used to represent validation errors in the OpenAPI documentation. // Default: null * }, * maker?: bool|array{ * enabled?: bool|Param, // Default: true + * namespace_prefix?: scalar|Param|null, // Add a prefix to all maker generated classes. e.g set it to "Api" to set the maker namespace to "App\Api\" (if the maker.root_namespace config is App). e.g. App\Api\State\MyStateProcessor // Default: "" + * }, + * mcp?: bool|array{ + * enabled?: bool|Param, // Default: true + * format?: scalar|Param|null, // The serialization format used for MCP tool input/output. Must be a format registered in api_platform.formats (e.g. "jsonld", "json", "jsonapi"). // Default: "jsonld" * }, * exception_to_status?: array, * formats?: array + * }>, * strict_query_parameter_validation?: mixed, * hide_hydra_operation?: mixed, + * json_stream?: mixed, * extra_properties?: mixed, + * map?: mixed, + * mcp?: mixed, * route_name?: mixed, * errors?: mixed, * read?: mixed, @@ -1298,6 +1346,7 @@ * validate?: mixed, * write?: mixed, * serialize?: mixed, + * content_negotiation?: mixed, * priority?: mixed, * name?: mixed, * allow_create?: mixed, diff --git a/config/services.yaml b/config/services.yaml index 80a25ef..6b804c1 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -56,6 +56,10 @@ services: arguments: $appEnv: '%env(string:APP_ENV)%' + App\Fixtures\FixtureLoader: + arguments: + $mappingsDir: '%kernel.project_dir%/tests/resources/mappings' + App\Security\ApiUserProvider: arguments: $apikeys: '%env(json:APP_API_KEYS)%' diff --git a/docker-compose.override.yml b/docker-compose.override.yml index eccaf6c..898fd23 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -33,7 +33,7 @@ services: - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${COMPOSE_DOMAIN}`) && (PathPrefix(`${APP_PATH_PREFIX}`) || PathPrefix(`/_wdt`) || PathPrefix(`/_profiler`))" elasticsearch: - image: elasticsearch:8.13.0 + image: elasticsearch:8.19.18 networks: - app - frontend diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 6008d5d..44c75cd 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -8,7 +8,7 @@ networks: services: phpfpm: - image: itkdev/php8.3-fpm:alpine + image: itkdev/php8.4-fpm:alpine restart: unless-stopped networks: - app diff --git a/docker-compose.yml b/docker-compose.yml index 09279af..dc2ef97 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: #- ENCRYPT=1 # Uncomment to enable database encryption. phpfpm: - image: itkdev/php8.3-fpm:latest + image: itkdev/php8.4-fpm:latest user: ${COMPOSE_USER:-deploy} networks: - app diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..28235fd --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,55 @@ +parameters: + ignoreErrors: + - + message: '#^Offset ''_source'' might not exist on array\|null\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Api/State/DailyOccurrenceRepresentationProvider.php + + - + message: '#^Return type \(App\\Service\\ElasticSearch\\ElasticSearchPaginator\|array\|null\) of method App\\Api\\State\\DailyOccurrenceRepresentationProvider\:\:provide\(\) should be covariant with return type \(iterable\\|object\|null\) of method ApiPlatform\\State\\ProviderInterface\\:\:provide\(\)$#' + identifier: method.childReturnType + count: 1 + path: src/Api/State/DailyOccurrenceRepresentationProvider.php + + - + message: '#^Offset ''_source'' might not exist on array\|null\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Api/State/EventRepresentationProvider.php + + - + message: '#^Return type \(App\\Service\\ElasticSearch\\ElasticSearchPaginator\|array\|null\) of method App\\Api\\State\\EventRepresentationProvider\:\:provide\(\) should be covariant with return type \(iterable\\|object\|null\) of method ApiPlatform\\State\\ProviderInterface\\:\:provide\(\)$#' + identifier: method.childReturnType + count: 1 + path: src/Api/State/EventRepresentationProvider.php + + - + message: '#^Offset ''_source'' might not exist on array\|null\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Api/State/LocationRepresentationProvider.php + + - + message: '#^Offset ''_source'' might not exist on array\|null\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Api/State/OccurrenceRepresentationProvider.php + + - + message: '#^Return type \(App\\Service\\ElasticSearch\\ElasticSearchPaginator\|array\|null\) of method App\\Api\\State\\OccurrenceRepresentationProvider\:\:provide\(\) should be covariant with return type \(iterable\\|object\|null\) of method ApiPlatform\\State\\ProviderInterface\\:\:provide\(\)$#' + identifier: method.childReturnType + count: 1 + path: src/Api/State/OccurrenceRepresentationProvider.php + + - + message: '#^Offset ''_source'' might not exist on array\|null\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Api/State/OrganizationRepresentationProvider.php + + - + message: '#^Parameter \#5 \$suggestedValues of method Symfony\\Component\\Console\\Command\\Command\:\:addArgument\(\) expects array\|\(Closure\(Symfony\\Component\\Console\\Completion\\CompletionInput, Symfony\\Component\\Console\\Completion\\CompletionSuggestions\)\: list\\), Closure\(Symfony\\Component\\Console\\Completion\\CompletionInput\)\: array given\.$#' + identifier: argument.type + count: 1 + path: src/Command/FixturesLoadCommand.php diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 62b59e7..695a209 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,23 +1,19 @@ +includes: + - phpstan-baseline.neon + parameters: - level: 6 + level: 8 paths: - bin/ - config/ - public/ - src/ - - tests/ - +# - tests/ excludePaths: - - tests/bootstrap.php - + # Auto-generated by the config reference dumper; not hand-maintained. + - config/reference.php ignoreErrors: - - messages: - - "#Method .+ has parameter .+ with no value type specified in iterable type array.#" - - "#Method .+ return type has no value type specified in iterable type array.#" - - "#Method .+ return type has no value type specified in iterable type iterable.#" - - "#Property .+ type has no value type specified in iterable type array.#" - - messages: - - "#Class .+ implements generic interface .+ but does not specify its types: #" - - messages: - # The ID properties are set by API Platform. - - "#Property App\\\\Api\\\\Dto\\\\[^\\\\]+::\\$id is unused#" + - identifier: missingType.generics + - identifier: missingType.iterableValue + # The ID properties are set reflectively by API Platform. + - '#Property App\\Api\\Dto\\[^\\]+::\$id is unused#' diff --git a/public/spec.yaml b/public/spec.yaml index 8714964..f3707c6 100644 --- a/public/spec.yaml +++ b/public/spec.yaml @@ -25,13 +25,10 @@ paths: application/ld+json: schema: type: object - properties: - 'hydra:member': { type: array, items: { $ref: '#/components/schemas/DailyOccurrence.DailyOccurrenceRepresentationProvider.jsonld' } } - 'hydra:totalItems': { type: integer, minimum: 0 } - 'hydra:view': { type: object, properties: { '@id': { type: string, format: iri-reference }, '@type': { type: string }, 'hydra:first': { type: string, format: iri-reference }, 'hydra:last': { type: string, format: iri-reference }, 'hydra:previous': { type: string, format: iri-reference }, 'hydra:next': { type: string, format: iri-reference } }, example: { '@id': string, type: string, 'hydra:first': string, 'hydra:last': string, 'hydra:previous': string, 'hydra:next': string } } - 'hydra:search': { type: object, properties: { '@type': { type: string }, 'hydra:template': { type: string }, 'hydra:variableRepresentation': { type: string }, 'hydra:mapping': { type: array, items: { type: object, properties: { '@type': { type: string }, variable: { type: string }, property: { type: [string, 'null'] }, required: { type: boolean } } } } } } - required: - - 'hydra:member' + description: 'DailyOccurrence.DailyOccurrenceRepresentationProvider.jsonld collection.' + allOf: + - { $ref: '#/components/schemas/HydraCollectionBaseSchema' } + - { type: object, required: ['hydra:member'], properties: { 'hydra:member': { type: array, items: { $ref: '#/components/schemas/DailyOccurrence.DailyOccurrenceRepresentationProvider.jsonld' } } } } summary: 'Retrieves the collection of DailyOccurrence resources.' description: 'Retrieves the collection of DailyOccurrence resources.' parameters: @@ -41,28 +38,24 @@ paths: description: 'The collection page number' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 1 style: form - explode: false - allowReserved: false + explode: true - name: itemsPerPage in: query description: 'The number of items per page' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 10 minimum: 0 maximum: 50 style: form - explode: false - allowReserved: false + explode: true - name: event.title in: query @@ -159,217 +152,180 @@ paths: description: 'Filter based on start greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00" (DEPRECATED - please use a filter with an explicit operator, e.g. start[gt]=2004-02-12T15:19:21+00:00) ' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'start[between]' in: query description: "Filter based on start between two ISO 8601 datetime [(yyyy-MM-dd'T'HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns) seperated by '..', e.g. \"2004-02-12T15:19:21+00:00..2004-02-13T16:20:22+00:00\"" required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'start[gt]' in: query description: 'Filter based on start greater than ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'start[gte]' in: query description: 'Filter based on start greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'start[lt]' in: query description: 'Filter based on start less then ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'start[lte]' in: query description: 'Filter based on start less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: end in: query description: 'Filter based on end less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00" (DEPRECATED - please use a filter with an explicit operator, e.g. end[gt]=2004-02-12T15:19:21+00:00) ' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'end[between]' in: query description: "Filter based on end between two ISO 8601 datetime [(yyyy-MM-dd'T'HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns) seperated by '..', e.g. \"2004-02-12T15:19:21+00:00..2004-02-13T16:20:22+00:00\"" required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'end[gt]' in: query description: 'Filter based on end greater than ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'end[gte]' in: query description: 'Filter based on end greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'end[lt]' in: query description: 'Filter based on end less then ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'end[lte]' in: query description: 'Filter based on end less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: updated in: query description: 'Filter based on updated greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00" (DEPRECATED - please use a filter with an explicit operator, e.g. updated[gt]=2004-02-12T15:19:21+00:00) ' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[between]' in: query description: "Filter based on updated between two ISO 8601 datetime [(yyyy-MM-dd'T'HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns) seperated by '..', e.g. \"2004-02-12T15:19:21+00:00..2004-02-13T16:20:22+00:00\"" required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[gt]' in: query description: 'Filter based on updated greater than ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[gte]' in: query description: 'Filter based on updated greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[lt]' in: query description: 'Filter based on updated less then ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[lte]' in: query description: 'Filter based on updated less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - deprecated: false '/api/v2/daily_occurrences/{id}': get: operationId: api_daily_occurrences_id_get @@ -378,6 +334,10 @@ paths: responses: '200': description: 'Single daily occurrence' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/DailyOccurrence.DailyOccurrenceRepresentationProvider.jsonld' '404': description: 'Not found' content: @@ -397,16 +357,13 @@ paths: - name: id in: path - description: '' + description: 'DailyOccurrence identifier' required: true deprecated: false - allowEmptyValue: false schema: type: integer style: simple explode: false - allowReserved: false - deprecated: false /api/v2/events: get: operationId: api_events_get_collection @@ -419,13 +376,10 @@ paths: application/ld+json: schema: type: object - properties: - 'hydra:member': { type: array, items: { $ref: '#/components/schemas/Event.EventRepresentationProvider.jsonld' } } - 'hydra:totalItems': { type: integer, minimum: 0 } - 'hydra:view': { type: object, properties: { '@id': { type: string, format: iri-reference }, '@type': { type: string }, 'hydra:first': { type: string, format: iri-reference }, 'hydra:last': { type: string, format: iri-reference }, 'hydra:previous': { type: string, format: iri-reference }, 'hydra:next': { type: string, format: iri-reference } }, example: { '@id': string, type: string, 'hydra:first': string, 'hydra:last': string, 'hydra:previous': string, 'hydra:next': string } } - 'hydra:search': { type: object, properties: { '@type': { type: string }, 'hydra:template': { type: string }, 'hydra:variableRepresentation': { type: string }, 'hydra:mapping': { type: array, items: { type: object, properties: { '@type': { type: string }, variable: { type: string }, property: { type: [string, 'null'] }, required: { type: boolean } } } } } } - required: - - 'hydra:member' + description: 'Event.EventRepresentationProvider.jsonld collection.' + allOf: + - { $ref: '#/components/schemas/HydraCollectionBaseSchema' } + - { type: object, required: ['hydra:member'], properties: { 'hydra:member': { type: array, items: { $ref: '#/components/schemas/Event.EventRepresentationProvider.jsonld' } } } } summary: 'Retrieves the collection of Event resources.' description: 'Retrieves the collection of Event resources.' parameters: @@ -435,28 +389,24 @@ paths: description: 'The collection page number' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 1 style: form - explode: false - allowReserved: false + explode: true - name: itemsPerPage in: query description: 'The number of items per page' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 10 minimum: 0 maximum: 50 style: form - explode: false - allowReserved: false + explode: true - name: title in: query @@ -553,217 +503,180 @@ paths: description: 'Filter based on occurrences.start greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00" (DEPRECATED - please use a filter with an explicit operator, e.g. occurrences.start[gt]=2004-02-12T15:19:21+00:00) ' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'occurrences.start[between]' in: query description: "Filter based on occurrences.start between two ISO 8601 datetime [(yyyy-MM-dd'T'HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns) seperated by '..', e.g. \"2004-02-12T15:19:21+00:00..2004-02-13T16:20:22+00:00\"" required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'occurrences.start[gt]' in: query description: 'Filter based on occurrences.start greater than ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'occurrences.start[gte]' in: query description: 'Filter based on occurrences.start greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'occurrences.start[lt]' in: query description: 'Filter based on occurrences.start less then ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'occurrences.start[lte]' in: query description: 'Filter based on occurrences.start less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: occurrences.end in: query description: 'Filter based on occurrences.end less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00" (DEPRECATED - please use a filter with an explicit operator, e.g. occurrences.end[gt]=2004-02-12T15:19:21+00:00) ' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'occurrences.end[between]' in: query description: "Filter based on occurrences.end between two ISO 8601 datetime [(yyyy-MM-dd'T'HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns) seperated by '..', e.g. \"2004-02-12T15:19:21+00:00..2004-02-13T16:20:22+00:00\"" required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'occurrences.end[gt]' in: query description: 'Filter based on occurrences.end greater than ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'occurrences.end[gte]' in: query description: 'Filter based on occurrences.end greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'occurrences.end[lt]' in: query description: 'Filter based on occurrences.end less then ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'occurrences.end[lte]' in: query description: 'Filter based on occurrences.end less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: updated in: query description: 'Filter based on updated greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00" (DEPRECATED - please use a filter with an explicit operator, e.g. updated[gt]=2004-02-12T15:19:21+00:00) ' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[between]' in: query description: "Filter based on updated between two ISO 8601 datetime [(yyyy-MM-dd'T'HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns) seperated by '..', e.g. \"2004-02-12T15:19:21+00:00..2004-02-13T16:20:22+00:00\"" required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[gt]' in: query description: 'Filter based on updated greater than ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[gte]' in: query description: 'Filter based on updated greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[lt]' in: query description: 'Filter based on updated less then ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[lte]' in: query description: 'Filter based on updated less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - deprecated: false '/api/v2/events/{id}': get: operationId: api_events_id_get @@ -772,6 +685,10 @@ paths: responses: '200': description: 'Single event' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/Event.EventRepresentationProvider.jsonld' '404': description: 'Not found' content: @@ -791,16 +708,13 @@ paths: - name: id in: path - description: '' + description: 'Event identifier' required: true deprecated: false - allowEmptyValue: false schema: type: integer style: simple explode: false - allowReserved: false - deprecated: false /api/v2/locations: get: operationId: api_locations_get_collection @@ -813,13 +727,10 @@ paths: application/ld+json: schema: type: object - properties: - 'hydra:member': { type: array, items: { $ref: '#/components/schemas/Location.LocationRepresentationProvider.jsonld' } } - 'hydra:totalItems': { type: integer, minimum: 0 } - 'hydra:view': { type: object, properties: { '@id': { type: string, format: iri-reference }, '@type': { type: string }, 'hydra:first': { type: string, format: iri-reference }, 'hydra:last': { type: string, format: iri-reference }, 'hydra:previous': { type: string, format: iri-reference }, 'hydra:next': { type: string, format: iri-reference } }, example: { '@id': string, type: string, 'hydra:first': string, 'hydra:last': string, 'hydra:previous': string, 'hydra:next': string } } - 'hydra:search': { type: object, properties: { '@type': { type: string }, 'hydra:template': { type: string }, 'hydra:variableRepresentation': { type: string }, 'hydra:mapping': { type: array, items: { type: object, properties: { '@type': { type: string }, variable: { type: string }, property: { type: [string, 'null'] }, required: { type: boolean } } } } } } - required: - - 'hydra:member' + description: 'Location.LocationRepresentationProvider.jsonld collection.' + allOf: + - { $ref: '#/components/schemas/HydraCollectionBaseSchema' } + - { type: object, required: ['hydra:member'], properties: { 'hydra:member': { type: array, items: { $ref: '#/components/schemas/Location.LocationRepresentationProvider.jsonld' } } } } summary: 'Retrieves the collection of Location resources.' description: 'Retrieves the collection of Location resources.' parameters: @@ -829,28 +740,24 @@ paths: description: 'The collection page number' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 1 style: form - explode: false - allowReserved: false + explode: true - name: itemsPerPage in: query description: 'The number of items per page' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 20 minimum: 0 maximum: 100 style: form - explode: false - allowReserved: false + explode: true - name: name in: query @@ -881,73 +788,60 @@ paths: description: 'Filter based on updated greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00" (DEPRECATED - please use a filter with an explicit operator, e.g. updated[gt]=2004-02-12T15:19:21+00:00) ' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[between]' in: query description: "Filter based on updated between two ISO 8601 datetime [(yyyy-MM-dd'T'HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns) seperated by '..', e.g. \"2004-02-12T15:19:21+00:00..2004-02-13T16:20:22+00:00\"" required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[gt]' in: query description: 'Filter based on updated greater than ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[gte]' in: query description: 'Filter based on updated greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[lt]' in: query description: 'Filter based on updated less then ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[lte]' in: query description: 'Filter based on updated less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - deprecated: false '/api/v2/locations/{id}': get: operationId: api_locations_id_get @@ -956,6 +850,10 @@ paths: responses: '200': description: 'Single location' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/Location.LocationRepresentationProvider.jsonld' '404': description: 'Not found' content: @@ -970,21 +868,18 @@ paths: $ref: '#/components/schemas/Error' links: { } summary: 'Get single location based on identifier' - description: 'Retrieves a Location resource.' + description: 'Get single location based on identifier' parameters: - name: id in: path - description: '' + description: 'Location identifier' required: true deprecated: false - allowEmptyValue: false schema: type: integer style: simple explode: false - allowReserved: false - deprecated: false /api/v2/occurrences: get: operationId: api_occurrences_get_collection @@ -997,13 +892,10 @@ paths: application/ld+json: schema: type: object - properties: - 'hydra:member': { type: array, items: { $ref: '#/components/schemas/Occurrence.OccurrenceRepresentationProvider.jsonld' } } - 'hydra:totalItems': { type: integer, minimum: 0 } - 'hydra:view': { type: object, properties: { '@id': { type: string, format: iri-reference }, '@type': { type: string }, 'hydra:first': { type: string, format: iri-reference }, 'hydra:last': { type: string, format: iri-reference }, 'hydra:previous': { type: string, format: iri-reference }, 'hydra:next': { type: string, format: iri-reference } }, example: { '@id': string, type: string, 'hydra:first': string, 'hydra:last': string, 'hydra:previous': string, 'hydra:next': string } } - 'hydra:search': { type: object, properties: { '@type': { type: string }, 'hydra:template': { type: string }, 'hydra:variableRepresentation': { type: string }, 'hydra:mapping': { type: array, items: { type: object, properties: { '@type': { type: string }, variable: { type: string }, property: { type: [string, 'null'] }, required: { type: boolean } } } } } } - required: - - 'hydra:member' + description: 'Occurrence.OccurrenceRepresentationProvider.jsonld collection.' + allOf: + - { $ref: '#/components/schemas/HydraCollectionBaseSchema' } + - { type: object, required: ['hydra:member'], properties: { 'hydra:member': { type: array, items: { $ref: '#/components/schemas/Occurrence.OccurrenceRepresentationProvider.jsonld' } } } } summary: 'Retrieves the collection of Occurrence resources.' description: 'Retrieves the collection of Occurrence resources.' parameters: @@ -1013,28 +905,24 @@ paths: description: 'The collection page number' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 1 style: form - explode: false - allowReserved: false + explode: true - name: itemsPerPage in: query description: 'The number of items per page' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 10 minimum: 0 maximum: 50 style: form - explode: false - allowReserved: false + explode: true - name: event.title in: query @@ -1119,217 +1007,180 @@ paths: description: 'Filter based on start greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00" (DEPRECATED - please use a filter with an explicit operator, e.g. start[gt]=2004-02-12T15:19:21+00:00) ' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'start[between]' in: query description: "Filter based on start between two ISO 8601 datetime [(yyyy-MM-dd'T'HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns) seperated by '..', e.g. \"2004-02-12T15:19:21+00:00..2004-02-13T16:20:22+00:00\"" required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'start[gt]' in: query description: 'Filter based on start greater than ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'start[gte]' in: query description: 'Filter based on start greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'start[lt]' in: query description: 'Filter based on start less then ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'start[lte]' in: query description: 'Filter based on start less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: end in: query description: 'Filter based on end less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00" (DEPRECATED - please use a filter with an explicit operator, e.g. end[gt]=2004-02-12T15:19:21+00:00) ' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'end[between]' in: query description: "Filter based on end between two ISO 8601 datetime [(yyyy-MM-dd'T'HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns) seperated by '..', e.g. \"2004-02-12T15:19:21+00:00..2004-02-13T16:20:22+00:00\"" required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'end[gt]' in: query description: 'Filter based on end greater than ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'end[gte]' in: query description: 'Filter based on end greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'end[lt]' in: query description: 'Filter based on end less then ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'end[lte]' in: query description: 'Filter based on end less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: updated in: query description: 'Filter based on updated greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00" (DEPRECATED - please use a filter with an explicit operator, e.g. updated[gt]=2004-02-12T15:19:21+00:00) ' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[between]' in: query description: "Filter based on updated between two ISO 8601 datetime [(yyyy-MM-dd'T'HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns) seperated by '..', e.g. \"2004-02-12T15:19:21+00:00..2004-02-13T16:20:22+00:00\"" required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[gt]' in: query description: 'Filter based on updated greater than ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[gte]' in: query description: 'Filter based on updated greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[lt]' in: query description: 'Filter based on updated less then ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[lte]' in: query description: 'Filter based on updated less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - deprecated: false '/api/v2/occurrences/{id}': get: operationId: api_occurrences_id_get @@ -1338,6 +1189,10 @@ paths: responses: '200': description: 'Single occurrence' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/Occurrence.OccurrenceRepresentationProvider.jsonld' '404': description: 'Not found' content: @@ -1352,21 +1207,18 @@ paths: $ref: '#/components/schemas/Error' links: { } summary: 'Get single occurrence based on identifier' - description: 'Retrieves a Occurrence resource.' + description: 'Get single occurrence based on identifier' parameters: - name: id in: path - description: '' + description: 'Occurrence identifier' required: true deprecated: false - allowEmptyValue: false schema: type: integer style: simple explode: false - allowReserved: false - deprecated: false /api/v2/organizations: get: operationId: api_organizations_get_collection @@ -1379,13 +1231,10 @@ paths: application/ld+json: schema: type: object - properties: - 'hydra:member': { type: array, items: { $ref: '#/components/schemas/Organization.OrganizationRepresentationProvider.jsonld' } } - 'hydra:totalItems': { type: integer, minimum: 0 } - 'hydra:view': { type: object, properties: { '@id': { type: string, format: iri-reference }, '@type': { type: string }, 'hydra:first': { type: string, format: iri-reference }, 'hydra:last': { type: string, format: iri-reference }, 'hydra:previous': { type: string, format: iri-reference }, 'hydra:next': { type: string, format: iri-reference } }, example: { '@id': string, type: string, 'hydra:first': string, 'hydra:last': string, 'hydra:previous': string, 'hydra:next': string } } - 'hydra:search': { type: object, properties: { '@type': { type: string }, 'hydra:template': { type: string }, 'hydra:variableRepresentation': { type: string }, 'hydra:mapping': { type: array, items: { type: object, properties: { '@type': { type: string }, variable: { type: string }, property: { type: [string, 'null'] }, required: { type: boolean } } } } } } - required: - - 'hydra:member' + description: 'Organization.OrganizationRepresentationProvider.jsonld collection.' + allOf: + - { $ref: '#/components/schemas/HydraCollectionBaseSchema' } + - { type: object, required: ['hydra:member'], properties: { 'hydra:member': { type: array, items: { $ref: '#/components/schemas/Organization.OrganizationRepresentationProvider.jsonld' } } } } summary: 'Retrieves the collection of Organization resources.' description: 'Retrieves the collection of Organization resources.' parameters: @@ -1395,28 +1244,24 @@ paths: description: 'The collection page number' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 1 style: form - explode: false - allowReserved: false + explode: true - name: itemsPerPage in: query description: 'The number of items per page' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 20 minimum: 0 maximum: 100 style: form - explode: false - allowReserved: false + explode: true - name: name in: query @@ -1435,73 +1280,60 @@ paths: description: 'Filter based on updated greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00" (DEPRECATED - please use a filter with an explicit operator, e.g. updated[gt]=2004-02-12T15:19:21+00:00) ' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[between]' in: query description: "Filter based on updated between two ISO 8601 datetime [(yyyy-MM-dd'T'HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns) seperated by '..', e.g. \"2004-02-12T15:19:21+00:00..2004-02-13T16:20:22+00:00\"" required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[gt]' in: query description: 'Filter based on updated greater than ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[gte]' in: query description: 'Filter based on updated greater then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[lt]' in: query description: 'Filter based on updated less then ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - name: 'updated[lte]' in: query description: 'Filter based on updated less then or equal to ISO 8601 datetime [(yyyy-MM-dd''T''HH:mm:ssz)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html#patterns), e.g. "2004-02-12T15:19:21+00:00"' required: false deprecated: false - allowEmptyValue: false schema: type: string style: form explode: false - allowReserved: false - deprecated: false '/api/v2/organizations/{id}': get: operationId: api_organizations_id_get @@ -1510,6 +1342,10 @@ paths: responses: '200': description: 'Single organization' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/Organization.OrganizationRepresentationProvider.jsonld' '404': description: 'Not found' content: @@ -1524,21 +1360,18 @@ paths: $ref: '#/components/schemas/Error' links: { } summary: 'Get single organization based on identifier' - description: 'Retrieves a Organization resource.' + description: 'Get single organization based on identifier' parameters: - name: id in: path - description: '' + description: 'Organization identifier' required: true deprecated: false - allowEmptyValue: false schema: type: integer style: simple explode: false - allowReserved: false - deprecated: false /api/v2/tags: get: operationId: api_tags_get_collection @@ -1551,13 +1384,10 @@ paths: application/ld+json: schema: type: object - properties: - 'hydra:member': { type: array, items: { $ref: '#/components/schemas/Tag.jsonld' } } - 'hydra:totalItems': { type: integer, minimum: 0 } - 'hydra:view': { type: object, properties: { '@id': { type: string, format: iri-reference }, '@type': { type: string }, 'hydra:first': { type: string, format: iri-reference }, 'hydra:last': { type: string, format: iri-reference }, 'hydra:previous': { type: string, format: iri-reference }, 'hydra:next': { type: string, format: iri-reference } }, example: { '@id': string, type: string, 'hydra:first': string, 'hydra:last': string, 'hydra:previous': string, 'hydra:next': string } } - 'hydra:search': { type: object, properties: { '@type': { type: string }, 'hydra:template': { type: string }, 'hydra:variableRepresentation': { type: string }, 'hydra:mapping': { type: array, items: { type: object, properties: { '@type': { type: string }, variable: { type: string }, property: { type: [string, 'null'] }, required: { type: boolean } } } } } } - required: - - 'hydra:member' + description: 'Tag.jsonld collection.' + allOf: + - { $ref: '#/components/schemas/HydraCollectionBaseSchema' } + - { type: object, required: ['hydra:member'], properties: { 'hydra:member': { type: array, items: { $ref: '#/components/schemas/Tag.jsonld' } } } } summary: 'Retrieves the collection of Tag resources.' description: 'Retrieves the collection of Tag resources.' parameters: @@ -1567,28 +1397,24 @@ paths: description: 'The collection page number' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 1 style: form - explode: false - allowReserved: false + explode: true - name: itemsPerPage in: query description: 'The number of items per page' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 20 minimum: 0 maximum: 100 style: form - explode: false - allowReserved: false + explode: true - name: name in: query @@ -1613,7 +1439,6 @@ paths: style: form explode: false allowReserved: false - deprecated: false '/api/v2/tags/{slug}': get: operationId: api_tags_slug_get @@ -1622,6 +1447,10 @@ paths: responses: '200': description: 'Get single tag' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/Tag.jsonld' '404': description: 'Not found' content: @@ -1636,21 +1465,18 @@ paths: $ref: '#/components/schemas/Error' links: { } summary: 'Get single tag' - description: 'Retrieves a Tag resource.' + description: 'Get single tag' parameters: - name: slug in: path - description: '' + description: 'Tag identifier' required: true deprecated: false - allowEmptyValue: false schema: type: string style: simple explode: false - allowReserved: false - deprecated: false /api/v2/vocabularies: get: operationId: api_vocabularies_get_collection @@ -1663,13 +1489,10 @@ paths: application/ld+json: schema: type: object - properties: - 'hydra:member': { type: array, items: { $ref: '#/components/schemas/Vocabulary.jsonld' } } - 'hydra:totalItems': { type: integer, minimum: 0 } - 'hydra:view': { type: object, properties: { '@id': { type: string, format: iri-reference }, '@type': { type: string }, 'hydra:first': { type: string, format: iri-reference }, 'hydra:last': { type: string, format: iri-reference }, 'hydra:previous': { type: string, format: iri-reference }, 'hydra:next': { type: string, format: iri-reference } }, example: { '@id': string, type: string, 'hydra:first': string, 'hydra:last': string, 'hydra:previous': string, 'hydra:next': string } } - 'hydra:search': { type: object, properties: { '@type': { type: string }, 'hydra:template': { type: string }, 'hydra:variableRepresentation': { type: string }, 'hydra:mapping': { type: array, items: { type: object, properties: { '@type': { type: string }, variable: { type: string }, property: { type: [string, 'null'] }, required: { type: boolean } } } } } } - required: - - 'hydra:member' + description: 'Vocabulary.jsonld collection.' + allOf: + - { $ref: '#/components/schemas/HydraCollectionBaseSchema' } + - { type: object, required: ['hydra:member'], properties: { 'hydra:member': { type: array, items: { $ref: '#/components/schemas/Vocabulary.jsonld' } } } } summary: 'Retrieves the collection of Vocabulary resources.' description: 'Retrieves the collection of Vocabulary resources.' parameters: @@ -1679,28 +1502,24 @@ paths: description: 'The collection page number' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 1 style: form - explode: false - allowReserved: false + explode: true - name: itemsPerPage in: query description: 'The number of items per page' required: false deprecated: false - allowEmptyValue: true schema: type: integer default: 20 minimum: 0 maximum: 100 style: form - explode: false - allowReserved: false + explode: true - name: name in: query @@ -1725,7 +1544,6 @@ paths: style: form explode: false allowReserved: false - deprecated: false '/api/v2/vocabularies/{slug}': get: operationId: api_vocabularies_slug_get @@ -1734,6 +1552,10 @@ paths: responses: '200': description: 'Get single vocabulary' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/Vocabulary.jsonld' '404': description: 'Not found' content: @@ -1748,117 +1570,46 @@ paths: $ref: '#/components/schemas/Error' links: { } summary: 'Get a vocabulary based on slug' - description: 'Retrieves a Vocabulary resource.' + description: 'Get a vocabulary based on slug' parameters: - name: slug in: path - description: '' + description: 'Vocabulary identifier' required: true deprecated: false - allowEmptyValue: false schema: type: string style: simple explode: false - allowReserved: false - deprecated: false components: schemas: DailyOccurrence.DailyOccurrenceRepresentationProvider.jsonld: - type: object - description: '' - deprecated: false - properties: - '@id': - readOnly: true - type: string - '@type': - readOnly: true - type: string - '@context': - readOnly: true - oneOf: - - - type: string - - - type: object - properties: - '@vocab': - type: string - hydra: - type: string - enum: ['http://www.w3.org/ns/hydra/core#'] - required: - - '@vocab' - - hydra - additionalProperties: true + allOf: + - + $ref: '#/components/schemas/HydraItemBaseSchema' + - + type: object Error: type: object description: 'A representation of common errors.' - deprecated: false properties: title: readOnly: true description: 'A short, human-readable summary of the problem.' - type: string - detail: - readOnly: true - description: 'A human-readable explanation specific to this occurrence of the problem.' - type: string - status: - type: number - examples: - - 404 - default: 400 - instance: - readOnly: true - description: 'A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.' type: - string - 'null' - type: - readOnly: true - description: 'A URI reference that identifies the problem type' - type: string - Error.jsonld: - type: object - description: 'A representation of common errors.' - deprecated: false - properties: - '@context': - readOnly: true - oneOf: - - - type: string - - - type: object - properties: - '@vocab': - type: string - hydra: - type: string - enum: ['http://www.w3.org/ns/hydra/core#'] - required: - - '@vocab' - - hydra - additionalProperties: true - '@id': - readOnly: true - type: string - '@type': - readOnly: true - type: string - title: - readOnly: true - description: 'A short, human-readable summary of the problem.' - type: string detail: readOnly: true description: 'A human-readable explanation specific to this occurrence of the problem.' - type: string + type: + - string + - 'null' status: - type: number + type: + - integer + - 'null' examples: - 404 default: 400 @@ -1872,108 +1623,116 @@ components: readOnly: true description: 'A URI reference that identifies the problem type' type: string - description: - readOnly: true - type: - - string - - 'null' - Event.EventRepresentationProvider.jsonld: - type: object - description: '' - deprecated: false - properties: - '@id': - readOnly: true - type: string - '@type': - readOnly: true - type: string - '@context': - readOnly: true - oneOf: - - + Error.jsonld: + allOf: + - + $ref: '#/components/schemas/HydraItemBaseSchema' + - + type: object + properties: + title: + readOnly: true + description: 'A short, human-readable summary of the problem.' + type: + - string + - 'null' + detail: + readOnly: true + description: 'A human-readable explanation specific to this occurrence of the problem.' + type: + - string + - 'null' + status: + type: + - integer + - 'null' + examples: + - 404 + default: 400 + instance: + readOnly: true + description: 'A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.' + type: + - string + - 'null' + type: + readOnly: true + description: 'A URI reference that identifies the problem type' type: string - - + description: + readOnly: true + type: + - string + - 'null' + description: 'A representation of common errors.' + Event.EventRepresentationProvider.jsonld: + allOf: + - + $ref: '#/components/schemas/HydraItemBaseSchema' + - + type: object + HydraCollectionBaseSchema: + allOf: + - + $ref: '#/components/schemas/HydraCollectionBaseSchemaNoPagination' + - + type: object + properties: + 'hydra:view': type: object properties: - '@vocab': + '@id': type: string - hydra: + format: iri-reference + '@type': type: string - enum: ['http://www.w3.org/ns/hydra/core#'] - required: - - '@vocab' - - hydra - additionalProperties: true - Location.LocationRepresentationProvider.jsonld: + 'hydra:first': + type: [string, 'null'] + format: iri-reference + 'hydra:last': + type: [string, 'null'] + format: iri-reference + 'hydra:previous': + type: [string, 'null'] + format: iri-reference + 'hydra:next': + type: [string, 'null'] + format: iri-reference + example: + '@id': string + '@type': string + 'hydra:first': string + 'hydra:last': string + 'hydra:previous': string + 'hydra:next': string + HydraCollectionBaseSchemaNoPagination: type: object - description: '' - deprecated: false properties: - '@id': - readOnly: true - type: string - '@type': - readOnly: true - type: string - '@context': - readOnly: true - oneOf: - - + 'hydra:totalItems': + type: integer + minimum: 0 + 'hydra:search': + type: object + properties: + '@type': type: string - - - type: object - properties: - '@vocab': - type: string - hydra: - type: string - enum: ['http://www.w3.org/ns/hydra/core#'] - required: - - '@vocab' - - hydra - additionalProperties: true - Occurrence.OccurrenceRepresentationProvider.jsonld: - type: object - description: '' - deprecated: false - properties: - '@id': - readOnly: true - type: string - '@type': - readOnly: true - type: string - '@context': - readOnly: true - oneOf: - - + 'hydra:template': type: string - - - type: object - properties: - '@vocab': - type: string - hydra: - type: string - enum: ['http://www.w3.org/ns/hydra/core#'] - required: - - '@vocab' - - hydra - additionalProperties: true - Organization.OrganizationRepresentationProvider.jsonld: + 'hydra:variableRepresentation': + type: string + 'hydra:mapping': + type: array + items: + type: object + properties: + '@type': { type: string } + variable: { type: string } + property: { type: [string, 'null'] } + required: { type: boolean } + HydraItemBaseSchema: type: object - description: '' - deprecated: false properties: - '@id': - readOnly: true - type: string - '@type': - readOnly: true - type: string '@context': - readOnly: true oneOf: - type: string @@ -1989,76 +1748,61 @@ components: - '@vocab' - hydra additionalProperties: true - Tag.jsonld: - type: object - description: '' - deprecated: false - properties: '@id': - readOnly: true type: string '@type': - readOnly: true type: string - '@context': - readOnly: true - oneOf: - - + required: + - '@id' + - '@type' + Location.LocationRepresentationProvider.jsonld: + allOf: + - + $ref: '#/components/schemas/HydraItemBaseSchema' + - + type: object + Occurrence.OccurrenceRepresentationProvider.jsonld: + allOf: + - + $ref: '#/components/schemas/HydraItemBaseSchema' + - + type: object + Organization.OrganizationRepresentationProvider.jsonld: + allOf: + - + $ref: '#/components/schemas/HydraItemBaseSchema' + - + type: object + Tag.jsonld: + allOf: + - + $ref: '#/components/schemas/HydraItemBaseSchema' + - + type: object + properties: + name: + type: string + slug: type: string - - - type: object - properties: - '@vocab': - type: string - hydra: - type: string - enum: ['http://www.w3.org/ns/hydra/core#'] - required: - - '@vocab' - - hydra - additionalProperties: true - slug: - type: string - name: - type: string Vocabulary.jsonld: - type: object - description: '' - deprecated: false - properties: - '@id': - readOnly: true - type: string - '@type': - readOnly: true - type: string - '@context': - readOnly: true - oneOf: - - + allOf: + - + $ref: '#/components/schemas/HydraItemBaseSchema' + - + type: object + properties: + name: type: string - - - type: object - properties: - '@vocab': - type: string - hydra: - type: string - enum: ['http://www.w3.org/ns/hydra/core#'] - required: - - '@vocab' - - hydra - additionalProperties: true - slug: - type: string - name: - type: string - description: - type: string - tags: - type: array - items: - type: string + slug: + type: string + description: + type: string + tags: + type: array + items: + type: + - string + - 'null' responses: { } parameters: { } examples: { } @@ -2076,16 +1820,23 @@ security: tags: - name: DailyOccurrence + description: "Resource 'DailyOccurrence' operations." - name: Event + description: "Resource 'Event' operations." - name: Location + description: "Resource 'Location' operations." - name: Occurrence + description: "Resource 'Occurrence' operations." - name: Organization + description: "Resource 'Organization' operations." - name: Tag + description: "Resource 'Tag' operations." - name: Vocabulary + description: "Resource 'Vocabulary' operations." webhooks: { } diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..37ea484 --- /dev/null +++ b/rector.php @@ -0,0 +1,34 @@ +withPaths([ + __DIR__.'/src', + __DIR__.'/tests', + ]) + ->withCache(__DIR__.'/var/cache/rector') + // Coding style and import (`use`) management are owned by php-cs-fixer + // (PostToolUse hook / coding standards), so Rector does not touch them. + // + // Modernize to the language level declared in composer.json (PHP >= 8.3). + ->withPhpSets() + // Version-based upgrade + quality rules resolved from the installed package + // versions (Symfony 7.4, PHPUnit 13). No domain Doctrine here — the entities + // and migrations live in event-database-imports — so the Doctrine sets are + // intentionally omitted. + ->withComposerBased(symfony: true, phpunit: true) + // Convert any remaining annotations to native PHP attributes. + ->withAttributesSets(symfony: true, phpunit: true) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + typeDeclarations: true, + privatization: true, + instanceOf: true, + earlyReturn: true, + symfonyCodeQuality: true, + phpunitCodeQuality: true, + ); diff --git a/scripts/claude-hook-check-index-contract.sh b/scripts/claude-hook-check-index-contract.sh new file mode 100755 index 0000000..f7aa402 --- /dev/null +++ b/scripts/claude-hook-check-index-contract.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Stop-hook helper: warn when the Elasticsearch index contract changed. +# +# event-database-imports WRITES the ES indices; this repo (event-database-api) +# READS them. The contract (index-name enum + document field names/types) is +# duplicated by hand across both repos with no compile-time link. This repo owns +# the index-name enum (src/Model/IndexName.php) and a production-parity COPY of +# the importer's mappings (tests/resources/mappings/) that the test harness +# applies. See CLAUDE.md -> "Works with event-database-imports". +set -u + +cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 + +CHANGED="$(git status --porcelain 2>/dev/null | awk '{print $NF}')" + +echo "$CHANGED" | grep -qE '^(src/Model/IndexName\.php|tests/resources/mappings/)' || exit 0 + +cat >&2 <<'MSG' +WARN: the Elasticsearch index contract changed (src/Model/IndexName.php or + tests/resources/mappings/). event-database-imports owns the source of + truth: index names in its src/Model/Indexing/IndexNames.php and mappings in + src/Model/Indexing/Mappings/. A name or field/type that diverges from what + the importer creates yields empty or failing API resources, and stale test + mappings make the filter suite green for semantics production does not have. + Reconcile against event-database-imports before deploying. +MSG + +exit 0 diff --git a/scripts/claude-hook-check-spec-drift.sh b/scripts/claude-hook-check-spec-drift.sh new file mode 100755 index 0000000..e781079 --- /dev/null +++ b/scripts/claude-hook-check-spec-drift.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Stop-hook helper: warn when an API resource changed but public/spec.yaml did not. +# +# public/spec.yaml is the committed OpenAPI export and is checked in CI +# (.github/workflows/api-spec.yml). Editing an #[ApiResource] DTO without +# regenerating the spec fails that check. This warns locally, before CI does. +# See CLAUDE.md -> "Claude Code automation". +set -u + +cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 + +CHANGED="$(git status --porcelain 2>/dev/null | awk '{print $NF}')" + +# A DTO changed? +echo "$CHANGED" | grep -qE '^src/Api/Dto/.*\.php$' || exit 0 +# ...but the spec did not. +echo "$CHANGED" | grep -qE '^public/spec\.yaml$' && exit 0 + +cat >&2 <<'MSG' +WARN: an API resource under src/Api/Dto/ changed but public/spec.yaml was not + regenerated. CI (.github/workflows/api-spec.yml) checks the committed + OpenAPI export and will fail on a stale spec. Regenerate it with: + task api:spec:export (or the /update-api-spec skill) +MSG + +exit 0 diff --git a/src/Api/Dto/DailyOccurrence.php b/src/Api/Dto/DailyOccurrence.php index 0f3074a..5a61d15 100644 --- a/src/Api/Dto/DailyOccurrence.php +++ b/src/Api/Dto/DailyOccurrence.php @@ -1,5 +1,7 @@ name = $name; - $this->slug = $slug; + public function __construct( + public string $name, + #[ApiProperty(identifier: true)] + public string $slug, + ) { } } diff --git a/src/Api/Dto/Vocabulary.php b/src/Api/Dto/Vocabulary.php index b766b4b..8f88f82 100644 --- a/src/Api/Dto/Vocabulary.php +++ b/src/Api/Dto/Vocabulary.php @@ -1,5 +1,7 @@ name = $name; - $this->slug = $slug; - $this->description = $description; - $this->tags = $tags; + public function __construct( + public string $name, + #[ApiProperty( + identifier: true, + )] + public string $slug, + public string $description, + public array $tags, + ) { } } diff --git a/src/Api/Filter/ElasticSearch/BooleanFilter.php b/src/Api/Filter/ElasticSearch/BooleanFilter.php index b74b778..e7fbe2a 100644 --- a/src/Api/Filter/ElasticSearch/BooleanFilter.php +++ b/src/Api/Filter/ElasticSearch/BooleanFilter.php @@ -1,5 +1,7 @@ $terms + ['boost' => 1.0]]; + return [] === $terms ? $terms : ['terms' => $terms + ['boost' => 1.0]]; } public function getDescription(string $resourceClass): array { - if (!$this->properties) { + if (null === $this->properties || [] === $this->properties) { return []; } $description = []; - foreach ($this->properties as $filterParameterName => $value) { + foreach (array_keys($this->properties) as $filterParameterName) { $description[$filterParameterName] = [ 'property' => $filterParameterName, 'type' => TypeIdentifier::BOOL->value, diff --git a/src/Api/Filter/ElasticSearch/DateFilter.php b/src/Api/Filter/ElasticSearch/DateFilter.php index 358a26b..00eab8e 100644 --- a/src/Api/Filter/ElasticSearch/DateFilter.php +++ b/src/Api/Filter/ElasticSearch/DateFilter.php @@ -45,12 +45,12 @@ public function apply(array $clauseBody, string $resourceClass, ?Operation $oper { $ranges = []; - if (!$this->properties) { + if (null === $this->properties || [] === $this->properties) { return $ranges; } foreach ($this->properties as $property => $value) { - if (!empty($context['filters'][$property])) { + if (isset($context['filters'][$property]) && '' !== $context['filters'][$property] && [] !== $context['filters'][$property]) { $conf = $this->config[$value]; $ranges[] = [ 'range' => [ @@ -67,7 +67,7 @@ public function apply(array $clauseBody, string $resourceClass, ?Operation $oper public function getDescription(string $resourceClass): array { - if (!$this->properties) { + if (null === $this->properties || [] === $this->properties) { return []; } diff --git a/src/Api/Filter/ElasticSearch/DateRangeFilter.php b/src/Api/Filter/ElasticSearch/DateRangeFilter.php index 07dbaec..9ba4657 100644 --- a/src/Api/Filter/ElasticSearch/DateRangeFilter.php +++ b/src/Api/Filter/ElasticSearch/DateRangeFilter.php @@ -3,6 +3,7 @@ namespace App\Api\Filter\ElasticSearch; use ApiPlatform\Elasticsearch\Filter\AbstractFilter; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -47,12 +48,12 @@ public function apply(array $clauseBody, string $resourceClass, ?Operation $oper { $ranges = []; - if (!$this->properties) { + if (null === $this->properties || [] === $this->properties) { return $ranges; } - foreach ($this->properties as $property => $value) { - if (!empty($context['filters'][$property])) { + foreach (array_keys($this->properties) as $property) { + if (isset($context['filters'][$property]) && '' !== $context['filters'][$property] && [] !== $context['filters'][$property]) { $ranges[] = $this->getElasticSearchQueryRanges($property, $context['filters'][$property]); } } @@ -62,7 +63,7 @@ public function apply(array $clauseBody, string $resourceClass, ?Operation $oper public function getDescription(string $resourceClass): array { - if (!$this->properties) { + if (null === $this->properties || [] === $this->properties) { return []; } @@ -85,21 +86,30 @@ private function getElasticSearchQueryRanges(string $property, string|array $fil if (null === $this->properties) { throw new \InvalidArgumentException('The property must be defined in the filter.'); } + + $throwOnInvalid = $this->config[$this->properties[$property]]->throwOnInvalid; + if (!\is_array($filter)) { - $fallbackOperator = $this->properties[$property]; - $operator = $this->config[$fallbackOperator]->limit; + $operator = $this->config[$this->properties[$property]]->limit; $value = $filter; } else { - $operator = DateLimit::{array_key_first($filter)}; + $operator = $this->resolveOperator((string) array_key_first($filter), $throwOnInvalid); + if (!$operator instanceof DateLimit) { + return []; + } $value = array_shift($filter); } switch ($operator) { case DateLimit::between: - $values = explode('..', $value); + $values = explode('..', (string) $value); if (2 !== count($values)) { - throw new \InvalidArgumentException('Invalid date range'); + if ($throwOnInvalid) { + throw new InvalidArgumentException(sprintf('Invalid date range for "%s": expected two ISO 8601 datetimes separated by "..".', $property)); + } + + return []; } return [ @@ -126,6 +136,28 @@ private function getElasticSearchQueryRanges(string $property, string|array $fil } } + /** + * Resolve a client-supplied operator key (e.g. "gt") to a DateLimit case. + * + * DateLimit case names are the operators the client uses in `field[op]=…`. + * Returns null for an unknown key when the filter is not configured to + * throw, so the caller can skip the clause instead of leaking a 5xx. + */ + private function resolveOperator(string $key, bool $throwOnInvalid): ?DateLimit + { + foreach (DateLimit::cases() as $case) { + if ($case->name === $key) { + return $case; + } + } + + if ($throwOnInvalid) { + throw new InvalidArgumentException(sprintf('Unknown date range operator "%s".', $key)); + } + + return null; + } + private function getFilterDescription(string $fieldName, DateLimit $operator, bool $isDefault = false): array { $propertyName = $this->normalizePropertyName($fieldName); diff --git a/src/Api/Filter/ElasticSearch/IdFilter.php b/src/Api/Filter/ElasticSearch/IdFilter.php index 073835b..02858a2 100644 --- a/src/Api/Filter/ElasticSearch/IdFilter.php +++ b/src/Api/Filter/ElasticSearch/IdFilter.php @@ -1,5 +1,7 @@ properties) { + if (null === $this->properties || [] === $this->properties) { return []; } $description = []; - foreach ($this->properties as $filterParameterName => $value) { + foreach (array_keys($this->properties) as $filterParameterName) { $description[$filterParameterName] = [ 'property' => $filterParameterName, 'type' => TypeIdentifier::ARRAY->value, diff --git a/src/Api/Filter/ElasticSearch/MatchFilter.php b/src/Api/Filter/ElasticSearch/MatchFilter.php index 0f10323..b6a4d02 100644 --- a/src/Api/Filter/ElasticSearch/MatchFilter.php +++ b/src/Api/Filter/ElasticSearch/MatchFilter.php @@ -1,5 +1,7 @@ [$property => $context['filters'][$property]]]; } } @@ -29,12 +31,12 @@ public function apply(array $clauseBody, string $resourceClass, ?Operation $oper public function getDescription(string $resourceClass): array { - if (!$this->properties) { + if (null === $this->properties || [] === $this->properties) { return []; } $description = []; - foreach ($this->properties as $filterParameterName => $value) { + foreach (array_keys($this->properties) as $filterParameterName) { $description[$filterParameterName] = [ 'property' => $filterParameterName, 'type' => TypeIdentifier::STRING->value, diff --git a/src/Api/Filter/ElasticSearch/TagFilter.php b/src/Api/Filter/ElasticSearch/TagFilter.php index 196bef1..b31f4d2 100644 --- a/src/Api/Filter/ElasticSearch/TagFilter.php +++ b/src/Api/Filter/ElasticSearch/TagFilter.php @@ -1,5 +1,7 @@ $terms + ['boost' => 1.0]]; + return [] === $terms ? $terms : ['terms' => $terms + ['boost' => 1.0]]; } public function getDescription(string $resourceClass): array { - if (!$this->properties) { + if (null === $this->properties || [] === $this->properties) { return []; } $description = []; - foreach ($this->properties as $filterParameterName => $value) { + foreach (array_keys($this->properties) as $filterParameterName) { $description[$filterParameterName] = [ 'property' => $filterParameterName, 'type' => TypeIdentifier::ARRAY->value, diff --git a/src/Api/State/AbstractProvider.php b/src/Api/State/AbstractProvider.php index b3e8567..69ce23d 100644 --- a/src/Api/State/AbstractProvider.php +++ b/src/Api/State/AbstractProvider.php @@ -54,7 +54,7 @@ protected function getFilters(Operation $operation, array $context = []): array if ($filter instanceof FilterInterface) { $data = $filter->apply([], IndexName::Events->value, $operation, $context); - if (!empty($data)) { + if ([] !== $data) { if ($filter instanceof SortFilterInterface) { $outputFilters[FilterType::Sort->value][] = $data; } else { diff --git a/src/Api/State/TagRepresentationProvider.php b/src/Api/State/TagRepresentationProvider.php index 02a23ab..54beb67 100644 --- a/src/Api/State/TagRepresentationProvider.php +++ b/src/Api/State/TagRepresentationProvider.php @@ -20,7 +20,7 @@ final class TagRepresentationProvider extends AbstractProvider implements Provid * @throws NotFoundExceptionInterface * @throws IndexException */ - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object { if ($operation instanceof CollectionOperationInterface) { $filters = $this->getFilters($operation, $context); diff --git a/src/Api/State/VocabularyRepresentationProvider.php b/src/Api/State/VocabularyRepresentationProvider.php index 04c7e09..953f4a4 100644 --- a/src/Api/State/VocabularyRepresentationProvider.php +++ b/src/Api/State/VocabularyRepresentationProvider.php @@ -20,7 +20,7 @@ final class VocabularyRepresentationProvider extends AbstractProvider implements * @throws NotFoundExceptionInterface * @throws IndexException */ - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object { if ($operation instanceof CollectionOperationInterface) { $filters = $this->getFilters($operation, $context); diff --git a/src/Command/FixturesLoadCommand.php b/src/Command/FixturesLoadCommand.php index 3257d27..34f84a9 100644 --- a/src/Command/FixturesLoadCommand.php +++ b/src/Command/FixturesLoadCommand.php @@ -40,9 +40,7 @@ protected function configure(): void InputArgument::REQUIRED, sprintf('Index to populate with fixture data (one of %s)', implode(', ', IndexName::values())), null, - function (CompletionInput $input): array { - return array_filter(IndexName::values(), fn ($item) => str_starts_with($item, $input->getCompletionValue())); - } + fn (CompletionInput $input): array => array_filter(IndexName::values(), fn ($item): bool => str_starts_with((string) $item, $input->getCompletionValue())) ) ->addOption('url', null, InputOption::VALUE_OPTIONAL, 'Remote url to read fixture data from', 'https://raw.githubusercontent.com/itk-dev/event-database-imports/develop/src/DataFixtures/indexes/[index].json'); } @@ -70,7 +68,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - if (!in_array($indexName, IndexName::values())) { + if (!in_array($indexName, IndexName::values(), true)) { $io->error(sprintf('Index %s does not exist', $indexName)); return Command::FAILURE; diff --git a/src/Controller/RootController.php b/src/Controller/RootController.php index 7642cfc..a6e8daf 100644 --- a/src/Controller/RootController.php +++ b/src/Controller/RootController.php @@ -1,14 +1,15 @@ redirectToRoute('api_doc'); diff --git a/src/Exception/AppException.php b/src/Exception/AppException.php index d4fa913..760351d 100644 --- a/src/Exception/AppException.php +++ b/src/Exception/AppException.php @@ -1,5 +1,7 @@ /.+)$~', $url, $matches)) { + if (1 === preg_match('~^file://(?/.+)$~', $url, $matches)) { $path = $matches['path']; if (!is_readable($path)) { throw new \HttpException('Unable to load fixture data'); } - $data = json_decode(file_get_contents($path), true); - if (empty($data)) { + $contents = file_get_contents($path); + if (false === $contents) { + throw new \HttpException('Unable to load fixture data'); + } + $data = json_decode($contents, true); + if (!is_array($data) || [] === $data) { throw new \HttpException('Unable to load fixture data'); } @@ -108,9 +114,10 @@ private function indexItems(string $indexName, array $items): void } try { // No other places in this part of the frontend should index data, hence it's not in the index service. + /** @var Elasticsearch $response */ $response = $this->client->index($params); - if (!in_array($response->getStatusCode(), [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_NO_CONTENT])) { + if (!in_array($response->getStatusCode(), [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_NO_CONTENT], true)) { throw new \Exception('Unable to add item to index', $response->getStatusCode()); } } catch (ClientResponseException|MissingParameterException|ServerResponseException $e) { @@ -134,19 +141,37 @@ private function indexItems(string $indexName, array $items): void */ private function createIndex(string $indexName): void { - if (!$this->index->indexExists($indexName)) { - // This creation of the index is not in den index service as this is the only place it should be used. In - // production and in many cases, you should connect to the index managed by the backend (imports). - $this->client->indices()->create([ - 'index' => $indexName, - 'body' => [ - 'settings' => [ - 'number_of_shards' => 5, - 'number_of_replicas' => 0, - ], - ], - ]); + if ($this->index->indexExists($indexName)) { + return; + } + + // This creation of the index is not in the index service as this is the only place it should be used. In + // production you connect to the index managed by the backend (imports). The mapping applied here is a + // production-parity copy of the importer's `dynamic: strict` mappings (tests/resources/mappings/), so filter + // tests exercise real Elasticsearch field semantics (keyword vs text) instead of dynamic-mapping artefacts. + $mappingFile = $this->mappingsDir.'/'.$indexName.'.json'; + if (!is_readable($mappingFile)) { + throw new \RuntimeException(sprintf('Missing production-parity mapping for index "%s" (expected %s). Export it from event-database-imports (src/Model/Indexing/Mappings); the test harness must never create an index with dynamic mapping.', $indexName, $mappingFile)); + } + $contents = file_get_contents($mappingFile); + if (false === $contents) { + throw new \RuntimeException(sprintf('Unable to read mapping file %s', $mappingFile)); } + $mappings = json_decode($contents, true); + if (!is_array($mappings)) { + throw new \RuntimeException(sprintf('Invalid mapping JSON in %s', $mappingFile)); + } + + $this->client->indices()->create([ + 'index' => $indexName, + 'body' => [ + 'settings' => [ + 'number_of_shards' => 5, + 'number_of_replicas' => 0, + ], + 'mappings' => $mappings, + ], + ]); } /** diff --git a/src/Kernel.php b/src/Kernel.php index 779cd1f..ad0fb48 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -1,5 +1,7 @@ title() ->toString(); - $reason = explode(': ', $message->error->root_cause[0]->reason)[0]; + $reason = explode(': ', (string) $message->error->root_cause[0]->reason)[0]; return $type.': '.$reason; } diff --git a/src/Service/ElasticSearch/ElasticSearchIndex.php b/src/Service/ElasticSearch/ElasticSearchIndex.php index be130f4..5344c52 100644 --- a/src/Service/ElasticSearch/ElasticSearchIndex.php +++ b/src/Service/ElasticSearch/ElasticSearchIndex.php @@ -3,8 +3,6 @@ namespace App\Service\ElasticSearch; use App\Exception\IndexException; -use App\Model\FilterType; -use App\Model\IndexName; use App\Model\SearchResults; use App\Service\IndexInterface; use Elastic\Elasticsearch\Client; @@ -18,6 +16,7 @@ class ElasticSearchIndex implements IndexInterface { public function __construct( private readonly Client $client, + private readonly SearchParamsBuilder $paramsBuilder, ) { } @@ -41,16 +40,16 @@ public function get(string $indexName, int|string $id, string $indexField = 'id' { if ('id' === $indexField) { return $this->getById($indexName, $id); - } else { - return $this->getByCustomIdField($indexName, $id, $indexField); } + + return $this->getByCustomIdField($indexName, $id, $indexField); } private function getById(string $indexName, int|string $id): array { $params = [ 'index' => $indexName, - 'id' => $id, + 'id' => (string) $id, ]; try { @@ -113,7 +112,7 @@ private function getByCustomIdField(string $indexName, int|string $id, string $i public function getAll(string $indexName, array $filters = [], int $from = 0, int $size = 10): SearchResults { - $params = $this->buildParams($indexName, $filters, $from, $size); + $params = $this->paramsBuilder->buildParams($indexName, $filters, $from, $size); try { /** @var Elasticsearch $response */ @@ -132,79 +131,6 @@ public function getAll(string $indexName, array $filters = [], int $from = 0, in ); } - /** - * Builds the parameters for the Elasticsearch search request. - * - * @param string $indexName - * The name of the index to search in - * @param array $filters - * An array of filters to apply to the search query - * @param int $from - * The starting offset for the search results - * @param int $size - * The maximum number of search results to return - * - * @return array - * The built parameters for the Elasticsearch search request - */ - private function buildParams(string $indexName, array $filters, int $from, int $size): array - { - $params = [ - 'index' => $indexName, - 'body' => [ - 'query' => [ - 'match_all' => (object) [], - ], - 'size' => $size, - 'from' => $from, - // @TODO: make a proper sort filter to allow client to set sort direction - 'sort' => $this->getSort($indexName), - ], - ]; - - $body = $this->buildBody($filters); - if (!empty($body)) { - $params['body']['query'] = $body; - } - - return $params; - } - - /** - * Builds the body for Elasticsearch request using the given filters. - * - * @param array $filters - * The filters to be included in the body - * - * @return array - * The built body for Elasticsearch request - */ - private function buildBody(array $filters): array - { - $body = []; - $combined = (bool) count($filters[FilterType::Filters->value]); - foreach ($filters[FilterType::Filters->value] as $filter) { - if ($combined) { - if (!array_key_exists('bool', $body)) { - $body['bool'] = ['must' => []]; - } - // Ensure that associative arrays and lists are not combined with keys "0","1" etc. in the final json. - // So we need to loop over lists to ensure keys are "reset" in the final body statement. - if (array_is_list($filter)) { - foreach ($filter as $val) { - $body['bool']['must'][] = $val; - } - } else { - $body['bool']['must'][] = $filter; - } - } else { - $body += $filter; - } - } - - return $body; - } - /** * Parses the response from Elasticsearch and returns it as an array. * @@ -254,50 +180,4 @@ private function getTotalHits(array $data): int { return $data['hits']['total']['value'] ?? 0; } - - /** - * Get the sorting configuration for a specific index. - * - * This method returns an array containing the sorting configuration based on the given index name. - * If the index name matches one of the predefined index names, a specific sorting configuration will be returned. - * Otherwise, an empty array will be returned indicating no sorting is required. - * - * @param string $indexName the name of the index - * - * @return array the sorting configuration - */ - private function getSort(string $indexName): array - { - // Translates a string or int into the corresponding Enum case, if any. - // If there is no matching case defined, it will return null. - $indexName = IndexName::tryFrom($indexName); - - return match ($indexName) { - IndexName::Events => [ - '_score', - [ - 'title.keyword' => [ - 'order' => 'asc', - ], - ], - ], - IndexName::DailyOccurrences, IndexName::Occurrences => [ - 'start' => [ - 'order' => 'asc', - 'format' => 'strict_date_optional_time_nanos', - ], - ], - IndexName::Tags, IndexName::Vocabularies,IndexName::Locations, IndexName::Organizations => [ - '_score', - [ - 'name.keyword' => [ - 'order' => 'asc', - ], - ], - ], - default => [ - '_score', - ], - }; - } } diff --git a/src/Service/ElasticSearch/ElasticSearchPaginator.php b/src/Service/ElasticSearch/ElasticSearchPaginator.php index 4a9ec07..f23ccc5 100644 --- a/src/Service/ElasticSearch/ElasticSearchPaginator.php +++ b/src/Service/ElasticSearch/ElasticSearchPaginator.php @@ -1,5 +1,7 @@ results->total; + return max(0, $this->results->total); } public function getLastPage(): float @@ -28,7 +30,9 @@ public function getLastPage(): float return 1.; } - return ceil($this->getTotalItems() / $this->limit) ?: 1.; + $lastPage = ceil($this->getTotalItems() / $this->limit); + + return 0.0 === $lastPage ? 1. : $lastPage; } public function getTotalItems(): float diff --git a/src/Service/ElasticSearch/SearchParamsBuilder.php b/src/Service/ElasticSearch/SearchParamsBuilder.php new file mode 100644 index 0000000..44e8746 --- /dev/null +++ b/src/Service/ElasticSearch/SearchParamsBuilder.php @@ -0,0 +1,100 @@ +> $filters compiled clauses keyed by FilterType + * + * @return array + */ + public function buildParams(string $indexName, array $filters, int $from, int $size): array + { + $params = [ + 'index' => $indexName, + 'body' => [ + 'query' => [ + 'match_all' => (object) [], + ], + 'size' => $size, + 'from' => $from, + // @TODO: make a proper sort filter to allow client to set sort direction + 'sort' => $this->buildSort($indexName), + ], + ]; + + $body = $this->buildBody($filters); + if ([] !== $body) { + $params['body']['query'] = $body; + } + + return $params; + } + + /** + * Combines the filter clauses into a single `bool`/`must` query. + * + * @param array> $filters + * + * @return array + */ + private function buildBody(array $filters): array + { + $body = []; + foreach ($filters[FilterType::Filters->value] as $filter) { + if (!array_key_exists('bool', $body)) { + $body['bool'] = ['must' => []]; + } + // Ensure that associative arrays and lists are not combined with keys "0","1" etc. in the final json. + // So we need to loop over lists to ensure keys are "reset" in the final body statement. + if (array_is_list($filter)) { + foreach ($filter as $val) { + $body['bool']['must'][] = $val; + } + } else { + $body['bool']['must'][] = $filter; + } + } + + return $body; + } + + /** + * The per-index sort configuration. + * + * @return array + */ + private function buildSort(string $indexName): array + { + return match (IndexName::tryFrom($indexName)) { + IndexName::Events => [ + '_score', + ['title.keyword' => ['order' => 'asc']], + ], + IndexName::DailyOccurrences, IndexName::Occurrences => [ + 'start' => [ + 'order' => 'asc', + 'format' => 'strict_date_optional_time_nanos', + ], + ], + IndexName::Tags, IndexName::Vocabularies, IndexName::Locations, IndexName::Organizations => [ + '_score', + ['name.keyword' => ['order' => 'asc']], + ], + default => [ + '_score', + ], + }; + } +} diff --git a/src/Service/IndexInterface.php b/src/Service/IndexInterface.php index 6d3e01d..c565b96 100644 --- a/src/Service/IndexInterface.php +++ b/src/Service/IndexInterface.php @@ -1,5 +1,7 @@ format(\DateTimeImmutable::ATOM); } + + /** + * Assert a collection response contains exactly the expected member identities. + * + * Reads $field from each `hydra:member` — `entityId` for most resources, + * `slug` for Tag/Vocabulary. The comparison is order-independent by default; + * pass $ordered to also assert the sequence (used by the sort contract). + * + * @param array $expected + */ + protected function assertMemberIds(array $expected, ResponseInterface $response, string $field = 'entityId', bool $ordered = false, string $message = ''): void + { + $data = $response->toArray(); + $this->assertArrayHasKey('hydra:member', $data, $message); + + $actual = array_map( + static fn (array $member) => $member[$field] ?? null, + $data['hydra:member'] + ); + + if ($ordered) { + $this->assertSame($expected, $actual, $message); + + return; + } + + sort($expected); + sort($actual); + $this->assertSame($expected, $actual, $message); + } } diff --git a/tests/ApiPlatform/ApiTest.php b/tests/ApiPlatform/ApiTest.php index dccfa9e..42aac29 100644 --- a/tests/ApiPlatform/ApiTest.php +++ b/tests/ApiPlatform/ApiTest.php @@ -1,12 +1,14 @@ request('GET', '/'); + self::createClient()->request('GET', '/'); $this->assertResponseRedirects('/api/v2/docs'); } diff --git a/tests/ApiPlatform/AuthenticationTest.php b/tests/ApiPlatform/AuthenticationTest.php new file mode 100644 index 0000000..17c391e --- /dev/null +++ b/tests/ApiPlatform/AuthenticationTest.php @@ -0,0 +1,63 @@ +request('GET', $path, ['headers' => ['accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + } + + #[DataProvider('protectedEndpointProvider')] + public function testInvalidApiKeyReturns401(string $path): void + { + self::createClient() + ->request('GET', $path, [ + 'headers' => [ + 'accept' => 'application/ld+json', + 'x-api-key' => 'wrong_key', + ], + ]); + + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + } + + #[DataProvider('protectedEndpointProvider')] + public function testValidApiKeyReturns200(string $path): void + { + $this->get([], $path); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + } + + public function testDocsEndpointIsPublic(): void + { + self::createClient()->request('GET', '/api/v2/docs'); + $this->assertResponseIsSuccessful(); + } + + public static function protectedEndpointProvider(): iterable + { + yield 'events' => ['/api/v2/events']; + yield 'occurrences' => ['/api/v2/occurrences']; + yield 'daily_occurrences' => ['/api/v2/daily_occurrences']; + yield 'locations' => ['/api/v2/locations']; + yield 'organizations' => ['/api/v2/organizations']; + yield 'tags' => ['/api/v2/tags']; + yield 'vocabularies' => ['/api/v2/vocabularies']; + } +} diff --git a/tests/ApiPlatform/Consumer/AarhusguidenContractTest.php b/tests/ApiPlatform/Consumer/AarhusguidenContractTest.php new file mode 100644 index 0000000..99b7146 --- /dev/null +++ b/tests/ApiPlatform/Consumer/AarhusguidenContractTest.php @@ -0,0 +1,107 @@ +get([ + 'event.tags' => 'ITKDev', + 'event.publicAccess' => 'true', + 'start' => self::formatDateTime('2024-01-01'), + 'end' => self::formatDateTime('2024-12-31'), + 'page' => 1, + 'itemsPerPage' => 6, + ]); + + $this->assertResponseIsSuccessful(); + $this->assertMemberIds([12], $response, 'entityId', message: 'Only daily occurrence 12 belongs to the ITKDev-tagged public event in 2024'); + } + + // Tag view: a category expands to several comma-separated tags, matched as OR. + public function testTagViewAcceptsMultipleCommaSeparatedTags(): void + { + $response = $this->get([ + 'event.tags' => 'ITKDev,Koncert', + 'event.publicAccess' => 'true', + ]); + + $this->assertResponseIsSuccessful(); + // ITKDev → 12, Koncert → 10 & 11 (event 8). Comma is match-any. + $this->assertMemberIds([10, 11, 12], $response, 'entityId', message: 'Comma-separated tags match any (ES terms query)'); + } + + // Free-text search box → event.title (tokenised text match). + public function testSearchByEventTitle(): void + { + $response = $this->get(['event.title' => 'ITKDev', 'itemsPerPage' => 6]); + + $this->assertResponseIsSuccessful(); + $this->assertMemberIds([10, 11, 12], $response, 'entityId', message: 'All fixture events carry the "ITKDev" title token'); + } + + // Pagination: the site requests fixed-size pages and reads hydra:view to page. + public function testPaginationExposesSliceAndNavigation(): void + { + $data = $this->get(['itemsPerPage' => 1, 'page' => 1])->toArray(); + + $this->assertCount(1, $data['hydra:member'], 'itemsPerPage=1 must return a single member'); + $this->assertSame(3, $data['hydra:totalItems'], 'totalItems must report the full unpaginated count'); + $this->assertArrayHasKey('hydra:view', $data, 'A multi-page result must expose hydra:view for navigation'); + $this->assertArrayHasKey('hydra:next', $data['hydra:view'], 'Page 1 of 3 must expose hydra:next'); + } + + // The site reads these fields off each daily-occurrence member and its + // embedded event; losing any of them breaks rendering. + public function testMembersCarryTheFieldsTheSiteRenders(): void + { + $data = $this->get(['itemsPerPage' => 1])->toArray(); + $member = $data['hydra:member'][0]; + + foreach (['entityId', 'start', 'end', 'event'] as $key) { + $this->assertArrayHasKey($key, $member, 'daily occurrence member must expose '.$key); + } + foreach (['entityId', 'title', 'tags', 'imageUrls', 'location', 'url'] as $key) { + $this->assertArrayHasKey($key, $member['event'], 'embedded event must expose '.$key); + } + } + + // Event detail page: GET /events/{id} returns the D6 collection wrapper and + // the site reads hydra:member[0]. Pinning this makes any future switch to a + // plain single-item response a conscious, consumer-breaking decision. + public function testEventDetailReturnsCollectionWrapper(): void + { + $data = $this->get([], '/api/v2/events/7')->toArray(); + + $this->assertArrayHasKey('hydra:member', $data, 'Item endpoint must return a hydra:Collection wrapper (D6)'); + $this->assertNotEmpty($data['hydra:member'], 'Wrapper must contain the single event as member[0]'); + $this->assertSame(7, $data['hydra:member'][0]['entityId'], 'member[0] must be the requested event'); + foreach (['title', 'tags', 'imageUrls', 'location', 'occurrences'] as $key) { + $this->assertArrayHasKey($key, $data['hydra:member'][0], 'detail event must expose '.$key); + } + } +} diff --git a/tests/ApiPlatform/Consumer/Os2displayContractTest.php b/tests/ApiPlatform/Consumer/Os2displayContractTest.php new file mode 100644 index 0000000..5811f6b --- /dev/null +++ b/tests/ApiPlatform/Consumer/Os2displayContractTest.php @@ -0,0 +1,139 @@ +get([ + 'event.tags' => 'Koncert', + 'event.location.entityId' => 4, + 'end' => ['gt' => self::formatDateTime('2024-12-01')], + 'itemsPerPage' => 20, + 'page' => 1, + ]); + + $this->assertResponseIsSuccessful(); + // Koncert → occ 10 & 11 (event 8); end[gt] 2024-12-01 keeps only occ 10 (11 ends 2024-11-08). + $this->assertMemberIds([10], $response, 'entityId', message: 'end[gt] must exclude the November occurrence'); + } + + // IdFilter accepts comma-separated ids (helper joins organizer/location ids). + public function testOccurrencesAcceptCommaSeparatedEntityIds(): void + { + $response = $this->get(['event.organizer.entityId' => '9,999']); + + $this->assertResponseIsSuccessful(); + $this->assertMemberIds([10, 11, 12], $response, 'entityId', message: 'All fixture occurrences belong to organizer 9'); + } + + // Single poster: GET /occurrences/{id} returns the D6 collection wrapper. + public function testSingleOccurrenceReturnsCollectionWrapper(): void + { + $data = $this->get([], '/api/v2/occurrences/10')->toArray(); + + $this->assertArrayHasKey('hydra:member', $data, 'occurrences item endpoint must return a hydra:Collection wrapper (D6)'); + $this->assertNotEmpty($data['hydra:member']); + $member = $data['hydra:member'][0]; + $this->assertSame(10, $member['entityId']); + // Fields the helper reads to build a Poster from an occurrence. + foreach (['start', 'end', 'ticketPriceRange', 'status', 'event'] as $key) { + $this->assertArrayHasKey($key, $member, 'occurrence must expose '.$key); + } + foreach (['entityId', 'title', 'excerpt', 'description', 'url', 'ticketUrl', 'imageUrls', 'location', 'organizer'] as $key) { + $this->assertArrayHasKey($key, $member['event'], 'occurrence.event must expose '.$key); + } + } + + // Search (events): title + location filter + upcoming occurrences. + public function testEventSearchQuery(): void + { + $response = $this->get([ + 'title' => 'ITKDev', + 'location.entityId' => 4, + 'occurrences.end' => ['gt' => self::formatDateTime('2024-01-01')], + 'itemsPerPage' => 10, + ], '/api/v2/events'); + + $this->assertResponseIsSuccessful(); + // All events match the title token; location 4 keeps 7 & 8 (event 9 is at location 5). + $this->assertMemberIds([7, 8], $response, 'entityId', message: 'location.entityId must exclude the event at location 5'); + } + + // Entity config: GET /events/{id} (D6 wrapper) with the fields mapEventToOutput reads. + public function testEventEntityReturnsCollectionWrapper(): void + { + $data = $this->get([], '/api/v2/events/7')->toArray(); + + $this->assertNotEmpty($data['hydra:member'] ?? [], 'events item endpoint must return a non-empty hydra:Collection wrapper (D6)'); + $event = $data['hydra:member'][0]; + $this->assertSame(7, $event['entityId']); + foreach (['title', 'organizer', 'location', 'imageUrls', 'occurrences'] as $key) { + $this->assertArrayHasKey($key, $event, 'event must expose '.$key); + } + $this->assertArrayHasKey('name', $event['organizer'], 'organizer.name is required'); + $this->assertArrayHasKey('name', $event['location'], 'location.name is required'); + foreach (['small', 'medium', 'large'] as $size) { + $this->assertArrayHasKey($size, $event['imageUrls'], 'imageUrls.'.$size.' is required'); + } + } + + // Options: paginated tags/organizations/locations, hydra:totalItems drives paging. + #[DataProvider('optionEntityProvider')] + public function testOptionCollectionsExposeTotalItemsAndIdField(string $path, string $idField): void + { + $data = $this->get(['itemsPerPage' => 50, 'page' => 1], $path)->toArray(); + + $this->assertIsInt($data['hydra:totalItems'], $path.' hydra:totalItems must be an int (paging loop compares it)'); + $this->assertNotEmpty($data['hydra:member']); + $this->assertArrayHasKey($idField, $data['hydra:member'][0], $path.' option must expose '.$idField); + $this->assertArrayHasKey('name', $data['hydra:member'][0], $path.' option must expose name'); + } + + public static function optionEntityProvider(): iterable + { + // toPosterOption uses `name` as the id for tags, `entityId` for the rest. + yield 'tags' => ['/api/v2/tags', 'name']; + yield 'organizations' => ['/api/v2/organizations', 'entityId']; + yield 'locations' => ['/api/v2/locations', 'entityId']; + } + + // A removed/expired item 404s; OS2display unpublishes the slide off the + // status code alone — it never reads the error body. + public function testMissingItemReturnsNotFoundStatus(): void + { + $response = $this->get([], '/api/v2/occurrences/99999'); + + $this->assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + } +} diff --git a/tests/ApiPlatform/Contract/CollectionContractTest.php b/tests/ApiPlatform/Contract/CollectionContractTest.php new file mode 100644 index 0000000..7c65a30 --- /dev/null +++ b/tests/ApiPlatform/Contract/CollectionContractTest.php @@ -0,0 +1,94 @@ +get([], $path)->toArray(); + + $this->assertSame('/api/v2/contexts/'.$context, $data['@context'], $path.' @context'); + $this->assertSame($path, $data['@id'], $path.' @id'); + $this->assertSame('hydra:Collection', $data['@type'], $path.' @type'); + $this->assertIsInt($data['hydra:totalItems'], $path.' hydra:totalItems must be an int'); + $this->assertIsList($data['hydra:member'], $path.' hydra:member must be a JSON list'); + } + + #[DataProvider('collectionProvider')] + public function testCollectionMemberFieldSet(string $path, string $context, array $expectedKeys): void + { + unset($context); + $data = $this->get([], $path)->toArray(); + $this->assertNotEmpty($data['hydra:member'], $path.' needs at least one fixture member'); + + $this->assertEqualsCanonicalizing( + $expectedKeys, + array_keys($data['hydra:member'][0]), + $path.' member field set changed — potential BC break' + ); + } + + #[DataProvider('collectionProvider')] + public function testCollectionExposesHydraSearch(string $path, string $context, array $expectedKeys): void + { + unset($context, $expectedKeys); + $data = $this->get([], $path)->toArray(); + + $this->assertArrayHasKey('hydra:search', $data, $path.' must expose hydra:search'); + $this->assertSame('hydra:IriTemplate', $data['hydra:search']['@type']); + $this->assertIsString($data['hydra:search']['hydra:template']); + $this->assertIsList($data['hydra:search']['hydra:mapping']); + $this->assertSame('IriTemplateMapping', $data['hydra:search']['hydra:mapping'][0]['@type']); + } + + public static function collectionProvider(): iterable + { + // Nested resources (Event/Occurrence/DailyOccurrence/Location/Organization) + // are serialized as plain objects — NOTE their members carry NO @id/@type. + yield 'events' => ['/api/v2/events', 'Event', [ + 'entityId', 'title', 'excerpt', 'description', 'url', 'ticketUrl', 'publicAccess', + 'organizer', 'partners', 'occurrences', 'dailyOccurrences', 'tags', 'imageUrls', + 'created', 'updated', 'location', + ]]; + yield 'occurrences' => ['/api/v2/occurrences', 'Occurrence', [ + 'entityId', 'start', 'end', 'ticketPriceRange', 'room', 'event', 'status', + ]]; + yield 'daily_occurrences' => ['/api/v2/daily_occurrences', 'DailyOccurrence', [ + 'entityId', 'start', 'end', 'ticketPriceRange', 'room', 'status', 'event', + ]]; + yield 'locations' => ['/api/v2/locations', 'Location', [ + 'entityId', 'name', 'image', 'url', 'telephone', 'disabilityAccess', 'mail', + 'street', 'suite', 'region', 'city', 'country', 'postalCode', 'coordinates', + ]]; + yield 'organizations' => ['/api/v2/organizations', 'Organization', [ + 'entityId', 'name', 'email', 'url', 'created', 'updated', + ]]; + // Tag/Vocabulary ARE true API Platform resources — their members DO carry @id/@type. + yield 'tags' => ['/api/v2/tags', 'Tag', [ + '@id', '@type', 'slug', 'name', + ]]; + yield 'vocabularies' => ['/api/v2/vocabularies', 'Vocabulary', [ + '@id', '@type', 'slug', 'name', 'description', 'tags', + ]]; + } +} diff --git a/tests/ApiPlatform/Contract/ContentNegotiationContractTest.php b/tests/ApiPlatform/Contract/ContentNegotiationContractTest.php new file mode 100644 index 0000000..a075060 --- /dev/null +++ b/tests/ApiPlatform/Contract/ContentNegotiationContractTest.php @@ -0,0 +1,54 @@ +requestWithAccept('application/ld+json'); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + } + + public function testPlainJsonIsNotAcceptable(): void + { + $response = $this->requestWithAccept('application/json'); + + $this->assertSame(Response::HTTP_NOT_ACCEPTABLE, $response->getStatusCode(), 'The API currently rejects application/json'); + } + + public function testHtmlIsNotAcceptable(): void + { + $response = $this->requestWithAccept('text/html'); + + $this->assertSame(Response::HTTP_NOT_ACCEPTABLE, $response->getStatusCode(), 'The API currently rejects text/html on resource endpoints'); + } + + private function requestWithAccept(string $accept): ResponseInterface + { + $client = self::createClient(defaultOptions: [ + 'headers' => [ + 'accept' => [$accept], + 'x-api-key' => 'test_api_key', + ], + ]); + + return $client->request('GET', '/api/v2/events'); + } +} diff --git a/tests/ApiPlatform/Contract/ContractSchemaTest.php b/tests/ApiPlatform/Contract/ContractSchemaTest.php new file mode 100644 index 0000000..7ef7444 --- /dev/null +++ b/tests/ApiPlatform/Contract/ContractSchemaTest.php @@ -0,0 +1,111 @@ +get([], $path)->toArray(); + $this->assertNotEmpty($data['hydra:member'], $path.' needs at least one fixture member'); + + foreach ($data['hydra:member'] as $i => $member) { + $this->assertMatchesDefinition($member, $definition, $path.' member #'.$i); + } + } + + #[DataProvider('itemProvider')] + public function testItemMatchesSchema(string $path, string $definition, bool $collectionWrapped): void + { + $data = $this->get([], $path)->toArray(); + + if ($collectionWrapped) { + $this->assertArrayHasKey('hydra:member', $data, $path.' should return a collection wrapper (D6)'); + $this->assertNotEmpty($data['hydra:member']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesDefinition($member, $definition, $path.' item member'); + } + + return; + } + + $this->assertMatchesDefinition($data, $definition, $path.' item'); + } + + public static function collectionProvider(): iterable + { + yield 'events' => ['/api/v2/events', 'event']; + yield 'occurrences' => ['/api/v2/occurrences', 'occurrence']; + yield 'daily_occurrences' => ['/api/v2/daily_occurrences', 'occurrence']; + yield 'locations' => ['/api/v2/locations', 'location']; + yield 'organizations' => ['/api/v2/organizations', 'organization']; + yield 'tags' => ['/api/v2/tags', 'tag']; + yield 'vocabularies' => ['/api/v2/vocabularies', 'vocabulary']; + } + + public static function itemProvider(): iterable + { + // [path, definition, collectionWrapped] + yield 'events' => ['/api/v2/events/7', 'event', true]; + yield 'occurrences' => ['/api/v2/occurrences/10', 'occurrence', true]; + yield 'daily_occurrences' => ['/api/v2/daily_occurrences/10', 'occurrence', true]; + yield 'locations' => ['/api/v2/locations/4', 'location', true]; + yield 'organizations' => ['/api/v2/organizations/9', 'organization', true]; + yield 'tags' => ['/api/v2/tags/aros', 'tag', false]; + yield 'vocabularies' => ['/api/v2/vocabularies/aarhusguiden', 'vocabulary', false]; + } + + private function assertMatchesDefinition(array $payload, string $definition, string $message): void + { + $storage = new SchemaStorage(); + $schema = json_decode((string) file_get_contents(self::SCHEMA_PATH), false, 512, JSON_THROW_ON_ERROR); + $storage->addSchema(self::SCHEMA_URI, $schema); + + $validator = new Validator(new Factory($storage)); + + // Re-decode as stdClass objects so json-schema distinguishes objects from lists. + $data = json_decode(json_encode($payload, JSON_THROW_ON_ERROR), false, 512, JSON_THROW_ON_ERROR); + + $validator->validate($data, (object) ['$ref' => self::SCHEMA_URI.'#/definitions/'.$definition]); + + $this->assertTrue($validator->isValid(), $message.' failed contract schema "'.$definition.'": '.$this->formatErrors($validator->getErrors())); + } + + /** + * @param array $errors + */ + private function formatErrors(array $errors): string + { + return implode('; ', array_map( + static fn (array $e): string => trim(($e['property'] ?? '').' '.($e['message'] ?? '')), + $errors + )); + } +} diff --git a/tests/ApiPlatform/Contract/DateFormatContractTest.php b/tests/ApiPlatform/Contract/DateFormatContractTest.php new file mode 100644 index 0000000..eb49f13 --- /dev/null +++ b/tests/ApiPlatform/Contract/DateFormatContractTest.php @@ -0,0 +1,37 @@ +get([], '/api/v2/occurrences')->toArray(); + $member = $data['hydra:member'][0]; + + $this->assertMatchesRegularExpression(self::ISO_8601_OFFSET, $member['start'], 'occurrence.start'); + $this->assertMatchesRegularExpression(self::ISO_8601_OFFSET, $member['end'], 'occurrence.end'); + } + + public function testAuditDatesUseIso8601WithOffset(): void + { + $data = $this->get([], '/api/v2/organizations')->toArray(); + $member = $data['hydra:member'][0]; + + $this->assertMatchesRegularExpression(self::ISO_8601_OFFSET, $member['created'], 'organization.created'); + $this->assertMatchesRegularExpression(self::ISO_8601_OFFSET, $member['updated'], 'organization.updated'); + } +} diff --git a/tests/ApiPlatform/Contract/ErrorContractTest.php b/tests/ApiPlatform/Contract/ErrorContractTest.php new file mode 100644 index 0000000..d5656a6 --- /dev/null +++ b/tests/ApiPlatform/Contract/ErrorContractTest.php @@ -0,0 +1,111 @@ +request('GET', '/api/v2/events'); + + $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + $data = $response->toArray(false); + + $this->assertSame('/api/v2/contexts/Error', $data['@context']); + $this->assertSame('hydra:Error', $data['@type']); + $this->assertSame(401, $data['status']); + $this->assertSame('/errors/401', $data['type']); + $this->assertArrayHasKey('detail', $data); + // 4.3 hydra-prefixes the human-readable fields (was unprefixed `title` in 4.1). + $this->assertArrayHasKey('hydra:title', $data); + $this->assertArrayHasKey('hydra:description', $data); + } + + public function testNotFoundErrorShape(): void + { + $response = $this->get([], '/api/v2/events/99999'); + + $this->assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + $data = $response->toArray(false); + + $this->assertSame('/api/v2/contexts/Error', $data['@context']); + $this->assertSame('hydra:Error', $data['@type']); + $this->assertSame(404, $data['status']); + $this->assertSame('/errors/404', $data['type']); + } + + /** + * `rfc_7807_compliant_errors: true` (config/packages/api_platform.yaml) + * registers problem+json as an error format, so an auth error requested as + * `application/problem+json` is served as RFC 7807 (unprefixed + * title/detail/status/type) rather than hydra. The 401 also short-circuits + * before resource content negotiation, so the format is honoured. + * + * @see https://api-platform.com/docs/core/content-negotiation/ + */ + public function testUnauthenticatedErrorIsAvailableAsProblemJson(): void + { + $client = self::createClient(defaultOptions: [ + 'headers' => ['accept' => ['application/problem+json']], + ]); + $response = $client->request('GET', '/api/v2/events'); + + $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + $this->assertStringContainsString('application/problem+json', $response->getHeaders(false)['content-type'][0] ?? ''); + + $data = $response->toArray(false); + $this->assertSame(401, $data['status']); + $this->assertSame('/errors/401', $data['type']); + $this->assertArrayHasKey('title', $data); + $this->assertArrayHasKey('detail', $data); + } + + /** + * The only resource format configured under `formats` in + * config/packages/api_platform.yaml is `application/ld+json`, so content + * negotiation cannot satisfy `Accept: application/problem+json` on a resource + * read: it yields 406 Not Acceptable (NOT a 404), and the 406 body is itself + * a problem+json error. Pinning this documents that problem+json is not a + * resource representation (consumers must read resources as ld+json) and + * guards the `formats` allow-list — adding problem+json there would flip this. + * + * @see https://api-platform.com/docs/core/content-negotiation/ + */ + public function testProblemJsonIsNotAcceptableForResourceReads(): void + { + $client = self::createClient(defaultOptions: [ + 'headers' => [ + 'accept' => ['application/problem+json'], + 'x-api-key' => 'test_api_key', + ], + ]); + $response = $client->request('GET', '/api/v2/events/99999'); + + $this->assertSame(Response::HTTP_NOT_ACCEPTABLE, $response->getStatusCode()); + $this->assertStringContainsString('application/problem+json', $response->getHeaders(false)['content-type'][0] ?? ''); + + $data = $response->toArray(false); + $this->assertSame(406, $data['status']); + } +} diff --git a/tests/ApiPlatform/Contract/ItemContractTest.php b/tests/ApiPlatform/Contract/ItemContractTest.php new file mode 100644 index 0000000..3c6ec51 --- /dev/null +++ b/tests/ApiPlatform/Contract/ItemContractTest.php @@ -0,0 +1,66 @@ +get([], $path)->toArray(); + + $this->assertSame($path, $data['@id'], $path.' @id'); + $this->assertSame('hydra:Collection', $data['@type'], $path.' item is currently wrapped in a hydra:Collection'); + $this->assertSame(1, $data['hydra:totalItems'], $path.' should wrap exactly one item'); + $this->assertCount(1, $data['hydra:member']); + $this->assertSame($entityId, $data['hydra:member'][0]['entityId']); + $this->assertArrayNotHasKey('@id', $data['hydra:member'][0], 'Wrapped member currently has no @id'); + $this->assertArrayNotHasKey('@type', $data['hydra:member'][0], 'Wrapped member currently has no @type'); + } + + #[DataProvider('flatItemProvider')] + public function testFlatItem(string $path, string $type, string $slug): void + { + $data = $this->get([], $path)->toArray(); + + $this->assertSame('/api/v2/contexts/'.$type, $data['@context']); + $this->assertSame($path, $data['@id'], $path.' @id'); + $this->assertSame($type, $data['@type'], $path.' flat item @type'); + $this->assertSame($slug, $data['slug']); + $this->assertArrayNotHasKey('hydra:member', $data, $path.' is a flat item, not a collection'); + } + + public static function collectionWrappedItemProvider(): iterable + { + yield 'events' => ['/api/v2/events/7', 7]; + yield 'occurrences' => ['/api/v2/occurrences/10', 10]; + yield 'daily_occurrences' => ['/api/v2/daily_occurrences/10', 10]; + yield 'locations' => ['/api/v2/locations/4', 4]; + yield 'organizations' => ['/api/v2/organizations/9', 9]; + } + + public static function flatItemProvider(): iterable + { + yield 'tags' => ['/api/v2/tags/aros', 'Tag', 'aros']; + yield 'vocabularies' => ['/api/v2/vocabularies/aarhusguiden', 'Vocabulary', 'aarhusguiden']; + } +} diff --git a/tests/ApiPlatform/DailyOccurrencesFilterTest.php b/tests/ApiPlatform/DailyOccurrencesFilterTest.php new file mode 100644 index 0000000..1b9efb6 --- /dev/null +++ b/tests/ApiPlatform/DailyOccurrencesFilterTest.php @@ -0,0 +1,54 @@ +get($query); + + $this->assertMemberIds($expectedIds, $response, 'entityId', message: $message ?? ''); + } + + public static function getDailyOccurrencesProvider(): iterable + { + yield 'unfiltered' => [[], [10, 11, 12]]; + + // BooleanFilter on event.publicAccess (DailyOccurrence-specific). + yield 'event publicAccess=true' => [ + ['event.publicAccess' => 'true'], + [10, 11, 12], + 'All daily occurrences belong to public events', + ]; + + // DateRangeFilter on start. + yield 'start in December 2024' => [ + ['start[between]' => self::formatDateTime('2024-12-01').'..'.self::formatDateTime('2024-12-31')], + [10, 12], + ]; + + // MatchFilter on event.title (token-based — see OccurrencesFilterTest comment). + yield 'event title token ITKDev' => [['event.title' => 'ITKDev'], [10, 11, 12]]; + + // IdFilter on event.organizer.entityId. + yield 'event organizer 9' => [['event.organizer.entityId' => 9], [10, 11, 12]]; + + // TagFilter on event.tags — keyword field, exact/case-sensitive match. + yield 'event tag ITKDev' => [['event.tags' => 'ITKDev'], [12]]; + yield 'event tag unknown' => [['event.tags' => 'unknown-tag'], []]; + } +} diff --git a/tests/ApiPlatform/DailyOccurrencesTest.php b/tests/ApiPlatform/DailyOccurrencesTest.php new file mode 100644 index 0000000..c7a7883 --- /dev/null +++ b/tests/ApiPlatform/DailyOccurrencesTest.php @@ -0,0 +1,25 @@ +get($query); - $data = $response->toArray(); - $this->assertArrayHasKey('hydra:member', $data, $message); - $this->assertCount($expectedCount, $data['hydra:member'], $message); + $this->assertMemberIds($expectedIds, $response, 'entityId', message: $message ?? ''); } public static function getEventsProvider(): iterable { - yield [ - [], - 3, - ]; - - // Test BooleanFilter. - yield [ - ['publicAccess' => 'true'], - 2, - ]; - - yield [ - ['publicAccess' => 'false'], - 1, - ]; - - // Test DateRangeFilter. - yield [ - ['occurrences.start[between]' => implode('..', [ - (new \DateTimeImmutable('2001-01-01'))->format(\DateTimeImmutable::ATOM), - (new \DateTimeImmutable('2100-01-01'))->format(\DateTimeImmutable::ATOM), - ])], - 3, - 'Events in 21st century', - ]; - - yield [ - ['occurrences.start[between]' => implode('..', [ - (new \DateTimeImmutable('2001-01-01'))->format(\DateTimeImmutable::ATOM), - (new \DateTimeImmutable('2100-01-01'))->format(\DateTimeImmutable::ATOM), - ])], - 3, - 'Events in 21st century', - ]; - - yield [ - ['occurrences.start[between]' => static::formatDateTime('2026-01-01').'..'.static::formatDateTime('2026-12-31')], - 1, - 'Events in 2026', - ]; + yield 'unfiltered' => [[], [7, 8, 9]]; - // Test IdFilter. - yield [ - ['organizer.entityId' => 9], - 2, - ]; + // BooleanFilter on publicAccess (7 and 9 are public, 8 is not). + yield 'publicAccess=true' => [['publicAccess' => 'true'], [7, 9]]; + yield 'publicAccess=false' => [['publicAccess' => 'false'], [8]]; - yield [ - ['organizer.entityId' => 11], - 1, + // DateRangeFilter on occurrences.start. + yield 'occurrences in 21st century' => [ + ['occurrences.start[between]' => self::formatDateTime('2001-01-01').'..'.self::formatDateTime('2100-01-01')], + [7, 8, 9], + 'Every fixture event has an occurrence in the 21st century', ]; - - // Test MatchFilter. - yield [ - ['location.name' => 'somewhere'], - 1, - 'An event somewhere', + yield 'occurrences in 2026' => [ + ['occurrences.start[between]' => self::formatDateTime('2026-01-01').'..'.self::formatDateTime('2026-12-31')], + [9], + 'Only event 9 has an occurrence in 2026', ]; - yield [ - ['location.name' => 'Another place'], - 0, + // DateRangeFilter on updated (default operator: gte). + yield 'updated on or after 2024' => [ + ['updated' => self::formatDateTime('2024-01-01')], + [7, 8, 9], + 'All events were updated on or after 2024-01-01', ]; - - // Test TagFilter. - yield [ - ['tags' => 'itkdev'], - 2, - 'Events tagged with "itkdev"', + yield 'updated after 2100' => [ + ['updated[gte]' => self::formatDateTime('2100-01-01')], + [], + 'No events updated after 2100', ]; - yield [ - ['tags' => 'itkdevelopment'], - 0, - 'Events tagged with "itkdevelopment"', + // F7: occurrences is a plain object (NOT `nested`) in production, so range + // clauses on occurrences.start and occurrences.end are evaluated + // independently across the flattened arrays. Event 8 has occurrences + // (start 2024-12-07 / end 2024-12-07) and (start 2024-11-08 / end 2024-11-08): + // NO single occurrence has start >= 2024-12-01 AND end <= 2024-11-30, yet the + // event still matches. Pinning this makes a future switch to `nested` mapping + // a conscious, BC-visible decision. + yield 'F7: start/end satisfied by different occurrences' => [ + [ + 'occurrences.start[gte]' => self::formatDateTime('2024-12-01'), + 'occurrences.end[lte]' => self::formatDateTime('2024-11-30'), + ], + [8], + 'Non-nested occurrences: event 8 matches although no single occurrence satisfies both bounds', ]; - // @todo Does tags filtering use the tag slug or name? - // yield [ - // ['tags' => 'for børn'], - // 0, - // 'Events tagged with "for børn"', - // ]; - // - // yield [ - // ['tags' => 'for-boern'], - // 1, - // 'Events tagged with "for-boern"', - // ]; + // IdFilter on organizer.entityId (7 and 8 belong to organizer 9; 9 to organizer 11). + yield 'organizer 9' => [['organizer.entityId' => 9], [7, 8]]; + yield 'organizer 11' => [['organizer.entityId' => 11], [9]]; - // @todo It seems that filtering in tags use som sort of "contains word" - // stuff, i.e. we can match the tag "for-boern" by filtering on "boern" - // or on "for" – but not on "for-boern" … - yield [ - ['tags' => 'boern'], - 1, - 'Events tagged with "boern"', - ]; + // MatchFilter on location.name (`name` is a `text` field → token match). + yield 'location somewhere' => [['location.name' => 'somewhere'], [9], 'Event 9 is at "Somewhere"']; + yield 'location another place' => [['location.name' => 'Another place'], []]; - yield [ - ['tags' => 'for'], - 2, - 'Events tagged with "for"', - ]; + // TagFilter. In production `tags` is a keyword field (see event-database-imports + // Mappings/Event) — matching is exact, case-sensitive and whole-value (no tokenisation). + yield 'tag ITKDev' => [['tags' => 'ITKDev'], [7, 9], 'Events tagged "ITKDev" (exact keyword match)']; + yield 'tag itkdev (wrong case)' => [['tags' => 'itkdev'], [], 'Tag matching is case-sensitive']; + yield 'tag aros' => [['tags' => 'aros'], [7, 8, 9], 'All fixture events are tagged "aros"']; + yield 'tag for-boern' => [['tags' => 'for-boern'], [9], 'A hyphenated tag matches as a whole value']; + yield 'tag boern (substring)' => [['tags' => 'boern'], [], 'No substring/token match on a keyword field']; + yield 'tag itkdevelopment' => [['tags' => 'itkdevelopment'], []]; // Combined filters. - yield [ + yield 'occurrences in 2026 AND tagged aros' => [ [ - 'occurrences.start[between]' => static::formatDateTime('2026-01-01').'..'.static::formatDateTime('2026-12-31'), - 'tags' => 'itkdev', + 'occurrences.start[between]' => self::formatDateTime('2026-01-01').'..'.self::formatDateTime('2026-12-31'), + 'tags' => 'aros', ], - 1, - 'Events in 2026 tagged with "itkdev"', + [9], + 'Event 9 is the only 2026 event also tagged "aros"', ]; } } diff --git a/tests/ApiPlatform/EventsTest.php b/tests/ApiPlatform/EventsTest.php index 8c2095f..6b0f0d4 100644 --- a/tests/ApiPlatform/EventsTest.php +++ b/tests/ApiPlatform/EventsTest.php @@ -1,15 +1,28 @@ get($query, $path); + $statusCode = $response->getStatusCode(); + $this->assertGreaterThanOrEqual(400, $statusCode, $message.': '.$statusCode); + $this->assertLessThan(500, $statusCode, 'Filter errors must not leak as 5xx — '.$message); + } + + public static function invalidDateRangeProvider(): iterable + { + // Missing '..' separator → DateRangeFilter throws InvalidArgumentException. + yield 'events: missing separator' => [ + '/api/v2/events', + ['occurrences.start[between]' => '2024-01-01T00:00:00+00:00'], + 'Between filter without ".." separator', + ]; + + // Too many '..' segments. + yield 'events: three segments' => [ + '/api/v2/events', + ['occurrences.start[between]' => '2024-01-01T00:00:00+00:00..2024-06-01T00:00:00+00:00..2024-12-31T00:00:00+00:00'], + 'Between filter with three segments', + ]; + + // Unknown operator → DateRangeFilter can no longer resolve the DateLimit + // case and throws InvalidArgumentException (used to be a raw \Error → 500). + yield 'events: unknown operator' => [ + '/api/v2/events', + ['occurrences.start[whenever]' => '2024-01-01T00:00:00+00:00'], + 'Unknown date range operator', + ]; + + // Non-date value passes the filter but Elasticsearch cannot parse it → + // ElasticIndexException (mapped to 400). + yield 'events: non-date value' => [ + '/api/v2/events', + ['occurrences.start[gte]' => 'not-a-date'], + 'Non-date value for a date range filter', + ]; + + yield 'occurrences: missing separator' => [ + '/api/v2/occurrences', + ['start[between]' => '2024-01-01T00:00:00+00:00'], + 'Between filter on occurrences without ".." separator', + ]; + + yield 'daily_occurrences: missing separator' => [ + '/api/v2/daily_occurrences', + ['start[between]' => '2024-01-01T00:00:00+00:00'], + 'Between filter on daily_occurrences without ".." separator', + ]; + } +} diff --git a/tests/ApiPlatform/LocationsFilterTest.php b/tests/ApiPlatform/LocationsFilterTest.php new file mode 100644 index 0000000..6866c51 --- /dev/null +++ b/tests/ApiPlatform/LocationsFilterTest.php @@ -0,0 +1,40 @@ +get($query); + + $this->assertMemberIds($expectedIds, $response, 'entityId', message: $message ?? ''); + } + + public static function getLocationsProvider(): iterable + { + yield 'unfiltered' => [[], [4, 5]]; + + // MatchFilter on name (`name` is a `text` field → token match). + yield 'name ITK Development' => [['name' => 'ITK Development'], [4], 'Location 4 is "ITK Development"']; + yield 'name Somewhere' => [['name' => 'Somewhere'], [5], 'Location 5 is "Somewhere"']; + yield 'name nonexistent' => [['name' => 'nonexistent'], []]; + + // MatchFilter on postalCode (`postalCode` is a `keyword` field → exact match). + yield 'postalCode 8000' => [['postalCode' => '8000'], [4], 'Location 4 has postal code 8000']; + yield 'postalCode 9999' => [['postalCode' => '9999'], []]; + } +} diff --git a/tests/ApiPlatform/LocationsTest.php b/tests/ApiPlatform/LocationsTest.php new file mode 100644 index 0000000..4db86b7 --- /dev/null +++ b/tests/ApiPlatform/LocationsTest.php @@ -0,0 +1,25 @@ +get($query); + + $this->assertMemberIds($expectedIds, $response, 'entityId', message: $message ?? ''); + } + + public static function getOccurrencesProvider(): iterable + { + yield 'unfiltered' => [[], [10, 11, 12]]; + + // DateRangeFilter on start. + yield 'start in December 2024' => [ + ['start[between]' => self::formatDateTime('2024-12-01').'..'.self::formatDateTime('2024-12-31')], + [10, 12], + 'Occurrences 10 and 12 start in December 2024', + ]; + yield 'start in November 2024' => [ + ['start[between]' => self::formatDateTime('2024-11-01').'..'.self::formatDateTime('2024-11-30')], + [11], + 'Only occurrence 11 starts in November 2024', + ]; + + // DateRangeFilter on end. + yield 'end around 2024-12-08' => [ + ['end[between]' => self::formatDateTime('2024-12-07').'..'.self::formatDateTime('2024-12-09')], + [10, 12], + 'Occurrences 10 and 12 end within 2024-12-07..2024-12-09', + ]; + + // MatchFilter on event.title. `title` is a `text` field in production (see + // event-database-imports Mappings/Event), so ES tokenises it and this is a word + // match — all three fixture events share the token "ITKDev". + yield 'event title token ITKDev' => [ + ['event.title' => 'ITKDev'], + [10, 11, 12], + 'All fixture events have "ITKDev" in the title', + ]; + yield 'event title absent token' => [['event.title' => 'totallyuniqueword'], []]; + + // MatchFilter on event.organizer.name / event.location.name (all events share these). + yield 'event organizer name ITKDev' => [['event.organizer.name' => 'ITKDev'], [10, 11, 12]]; + yield 'event location name' => [['event.location.name' => 'ITK Development'], [10, 11, 12]]; + + // IdFilter on event.organizer.entityId / event.location.entityId. + yield 'event organizer 9' => [['event.organizer.entityId' => 9], [10, 11, 12]]; + yield 'event organizer 99' => [['event.organizer.entityId' => 99], []]; + yield 'event location 4' => [['event.location.entityId' => 4], [10, 11, 12]]; + + // TagFilter on event.tags — keyword field, exact/case-sensitive match. Only + // occurrence 12 belongs to event 7, which carries the "ITKDev" tag. + yield 'event tag ITKDev' => [['event.tags' => 'ITKDev'], [12], 'Occurrence 12 belongs to the ITKDev-tagged event']; + yield 'event tag unknown' => [['event.tags' => 'unknown-tag'], []]; + } +} diff --git a/tests/ApiPlatform/OccurrencesTest.php b/tests/ApiPlatform/OccurrencesTest.php new file mode 100644 index 0000000..b516b84 --- /dev/null +++ b/tests/ApiPlatform/OccurrencesTest.php @@ -0,0 +1,25 @@ +get($query); + + $this->assertMemberIds($expectedIds, $response, 'entityId', message: $message ?? ''); + } + + public static function getOrganizationsProvider(): iterable + { + yield 'unfiltered' => [[], [9, 10, 11]]; + + // MatchFilter on name (`name` is a `text` field → token match). + yield 'name ITKDev' => [['name' => 'ITKDev'], [9], 'Organization 9 is "ITKDev"']; + yield 'name Dokk1' => [['name' => 'Dokk1'], [11]]; + yield 'name Aakb' => [['name' => 'Aakb'], [10]]; + yield 'name nonexistent' => [['name' => 'nonexistent'], []]; + } +} diff --git a/tests/ApiPlatform/OrganizationsTest.php b/tests/ApiPlatform/OrganizationsTest.php new file mode 100644 index 0000000..92e7eb6 --- /dev/null +++ b/tests/ApiPlatform/OrganizationsTest.php @@ -0,0 +1,25 @@ +get([], $path); + $data = $response->toArray(); + + $this->assertArrayHasKey('hydra:member', $data); + $this->assertArrayHasKey('hydra:totalItems', $data); + $this->assertSame($fixtureCount, $data['hydra:totalItems'], $path.' should report '.$fixtureCount.' total items'); + } + + #[DataProvider('resourceProvider')] + public function testItemsPerPageClientOverride(string $path, int $fixtureCount, int $maxPerPage): void + { + unset($maxPerPage); + + if ($fixtureCount < 2) { + $this->markTestSkipped($path.' has < 2 fixtures — cannot test itemsPerPage=1 slicing'); + } + + $response = $this->get(['itemsPerPage' => 1], $path); + $data = $response->toArray(); + + $this->assertCount(1, $data['hydra:member'], $path.' should honour itemsPerPage=1'); + $this->assertSame($fixtureCount, $data['hydra:totalItems']); + } + + #[DataProvider('resourceProvider')] + public function testItemsPerPageRespectsMaximum(string $path, int $fixtureCount, int $maxPerPage): void + { + $response = $this->get(['itemsPerPage' => $maxPerPage + 100], $path); + $data = $response->toArray(); + + $expected = min($fixtureCount, $maxPerPage); + $this->assertLessThanOrEqual($maxPerPage, count($data['hydra:member']), $path.' must clamp itemsPerPage to '.$maxPerPage); + $this->assertCount($expected, $data['hydra:member']); + } + + public function testPageNavigationLinksPresentWhenSliced(): void + { + $response = $this->get(['itemsPerPage' => 1, 'page' => 1], '/api/v2/events'); + $data = $response->toArray(); + + $this->assertArrayHasKey('hydra:view', $data, 'Multi-page result must expose hydra:view'); + $view = $data['hydra:view']; + + $this->assertArrayHasKey('hydra:next', $view, 'Page 1 of 3 should expose hydra:next'); + $this->assertArrayHasKey('hydra:last', $view); + + $response = $this->get(['itemsPerPage' => 1, 'page' => 3], '/api/v2/events'); + $data = $response->toArray(); + $this->assertArrayHasKey('hydra:view', $data); + $this->assertArrayHasKey('hydra:previous', $data['hydra:view'], 'Page 3 of 3 should expose hydra:previous'); + } + + public function testOutOfRangePageReturnsEmptyMemberButFullTotal(): void + { + // 3 events, itemsPerPage=1 → page 99 is past the end. The slice is empty + // but hydra:totalItems still reports the full unpaginated count. + $data = $this->get(['itemsPerPage' => 1, 'page' => 99], '/api/v2/events')->toArray(); + + $this->assertSame([], $data['hydra:member'], 'An out-of-range page must return an empty member list'); + $this->assertSame(3, $data['hydra:totalItems'], 'totalItems must ignore pagination'); + } + + public function testTotalItemsReflectsActiveFilter(): void + { + // hydra:totalItems must count the filtered result set, not the whole index. + $data = $this->get(['publicAccess' => 'true'], '/api/v2/events')->toArray(); + + $this->assertSame(2, $data['hydra:totalItems'], 'totalItems must reflect the publicAccess=true filter'); + $this->assertCount(2, $data['hydra:member']); + } + + public function testPagingThroughSingleItemPagesCoversTheFullSet(): void + { + // Walking every page at itemsPerPage=1 must yield each event exactly once — + // no gaps, no duplicates across page boundaries. + $seen = []; + for ($page = 1; $page <= 3; ++$page) { + $data = $this->get(['itemsPerPage' => 1, 'page' => $page], '/api/v2/events')->toArray(); + $this->assertCount(1, $data['hydra:member'], 'Page '.$page.' should hold exactly one event'); + $seen[] = $data['hydra:member'][0]['entityId']; + } + + sort($seen); + $this->assertSame([7, 8, 9], $seen, 'Pages 1∪2∪3 must equal the full event set with no duplicates'); + } + + public static function resourceProvider(): iterable + { + // [path, fixture count, paginationMaximumItemsPerPage] + yield 'events' => ['/api/v2/events', 3, 50]; + yield 'occurrences' => ['/api/v2/occurrences', 3, 50]; + yield 'daily_occurrences' => ['/api/v2/daily_occurrences', 3, 50]; + yield 'locations' => ['/api/v2/locations', 2, 100]; + yield 'organizations' => ['/api/v2/organizations', 3, 100]; + yield 'tags' => ['/api/v2/tags', 5, 100]; + yield 'vocabularies' => ['/api/v2/vocabularies', 2, 100]; + } +} diff --git a/tests/ApiPlatform/SortTest.php b/tests/ApiPlatform/SortTest.php new file mode 100644 index 0000000..7e707ad --- /dev/null +++ b/tests/ApiPlatform/SortTest.php @@ -0,0 +1,59 @@ +get([], '/api/v2/events')->toArray(); + $titles = array_column($data['hydra:member'], 'title'); + + $sorted = $titles; + sort($sorted, SORT_STRING); + + $this->assertSame($sorted, $titles, 'Events must be ordered by title.keyword ascending'); + } + + public function testOccurrencesAreSortedByStartAscending(): void + { + $response = $this->get([], '/api/v2/occurrences'); + + // Fixture starts: 11 = 2024-11-08, 10 = 2024-12-07, 12 = 2024-12-08. + $this->assertMemberIds([11, 10, 12], $response, 'entityId', ordered: true, message: 'Occurrences must be ordered by start ascending'); + + $starts = array_column($response->toArray()['hydra:member'], 'start'); + $sorted = $starts; + sort($sorted, SORT_STRING); + $this->assertSame($sorted, $starts, 'start values must be ascending'); + } + + public function testDailyOccurrencesAreSortedByStartAscending(): void + { + $response = $this->get([], '/api/v2/daily_occurrences'); + + $this->assertMemberIds([11, 10, 12], $response, 'entityId', ordered: true, message: 'Daily occurrences must be ordered by start ascending'); + } + + public function testOrganizationsAreSortedByNameAscending(): void + { + $response = $this->get([], '/api/v2/organizations'); + + // name.keyword ascending (byte order): Aakb (10), Dokk1 (11), ITKDev (9). + $this->assertMemberIds([10, 11, 9], $response, 'entityId', ordered: true, message: 'Organizations must be ordered by name.keyword ascending'); + } +} diff --git a/tests/ApiPlatform/TagsFilterTest.php b/tests/ApiPlatform/TagsFilterTest.php new file mode 100644 index 0000000..f1a6d74 --- /dev/null +++ b/tests/ApiPlatform/TagsFilterTest.php @@ -0,0 +1,45 @@ +get($query); + + $this->assertMemberIds($expectedSlugs, $response, 'slug', message: $message ?? ''); + } + + public static function getTagsProvider(): iterable + { + yield 'unfiltered' => [[], ['aros', 'theoceanraceaarhus', 'koncert', 'for-boern', 'itkdev']]; + + // MatchFilter on name (`name` is a `text` field → token match); slug is the identity. + yield 'name aros' => [['name' => 'aros'], ['aros'], 'Tag named "aros"']; + yield 'name Koncert' => [['name' => 'Koncert'], ['koncert'], 'Tag "Koncert" has slug "koncert"']; + yield 'name nonexistent' => [['name' => 'nonexistent'], []]; + + // MatchFilter on vocabulary. + yield 'vocabulary aarhusguiden' => [ + ['vocabulary' => 'aarhusguiden'], + ['aros', 'theoceanraceaarhus', 'koncert', 'for-boern', 'itkdev'], + 'All fixture tags belong to the aarhusguiden vocabulary', + ]; + yield 'vocabulary feeds' => [['vocabulary' => 'feeds'], []]; + } +} diff --git a/tests/ApiPlatform/TagsTest.php b/tests/ApiPlatform/TagsTest.php index 7307969..c3b0610 100644 --- a/tests/ApiPlatform/TagsTest.php +++ b/tests/ApiPlatform/TagsTest.php @@ -1,15 +1,26 @@ assertResponseStatusCodeSame(Response::HTTP_OK); $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); $this->assertJson($response->getContent()); + + $data = $response->toArray(); + $this->assertArrayHasKey('hydra:member', $data); + $this->assertArrayHasKey('hydra:totalItems', $data); + + if (isset(static::$resourceClass) && static::$assertsGeneratedCollectionSchema) { + $this->assertMatchesResourceCollectionJsonSchema(static::$resourceClass); + } } } diff --git a/tests/ApiPlatform/Trait/GetItemTestTrait.php b/tests/ApiPlatform/Trait/GetItemTestTrait.php new file mode 100644 index 0000000..6102c5e --- /dev/null +++ b/tests/ApiPlatform/Trait/GetItemTestTrait.php @@ -0,0 +1,35 @@ +get([], $path, authenticated: false); + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + + $response = $this->get([], $path); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJson($response->getContent()); + $this->assertMatchesResourceItemJsonSchema(static::$resourceClass); + } + + public function testGetItemNotFound(): void + { + assert($this instanceof AbstractApiTestCase); + + $path = static::$requestPath.'/'.static::$unknownItemId; + + $this->get([], $path); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } +} diff --git a/tests/ApiPlatform/VocabulariesFilterTest.php b/tests/ApiPlatform/VocabulariesFilterTest.php new file mode 100644 index 0000000..51533e0 --- /dev/null +++ b/tests/ApiPlatform/VocabulariesFilterTest.php @@ -0,0 +1,41 @@ +get($query); + + $this->assertMemberIds($expectedSlugs, $response, 'slug', message: $message ?? ''); + } + + public static function getVocabulariesProvider(): iterable + { + yield 'unfiltered' => [[], ['aarhusguiden', 'feeds']]; + + // MatchFilter on name (`name` is a `text` field → token match); slug is the identity. + yield 'name aarhusguiden' => [['name' => 'aarhusguiden'], ['aarhusguiden']]; + yield 'name feeds' => [['name' => 'feeds'], ['feeds']]; + yield 'name nonexistent' => [['name' => 'nonexistent'], []]; + + // MatchFilter on tags — only aarhusguiden contains the "aros" tag. + yield 'tags aros' => [['tags' => 'aros'], ['aarhusguiden'], 'aarhusguiden vocabulary contains the "aros" tag']; + yield 'tags unknown' => [['tags' => 'unknown-tag'], []]; + } +} diff --git a/tests/ApiPlatform/VocabulariesTest.php b/tests/ApiPlatform/VocabulariesTest.php index 99b145f..5f86b89 100644 --- a/tests/ApiPlatform/VocabulariesTest.php +++ b/tests/ApiPlatform/VocabulariesTest.php @@ -1,15 +1,26 @@ filterDependencies(); + $filter = new BooleanFilter($names, $meta, $resolver, null, ['publicAccess' => null]); + + $this->assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + } + + public static function applyProvider(): iterable + { + yield 'no filters → empty' => [[], []]; + yield 'empty string → skipped' => [['publicAccess' => ''], []]; + yield 'true' => [['publicAccess' => 'true'], ['terms' => ['publicAccess' => ['true'], 'boost' => 1.0]]]; + yield 'false' => [['publicAccess' => 'false'], ['terms' => ['publicAccess' => ['false'], 'boost' => 1.0]]]; + // "0" is a real value and must reach ES (regression guard for the old empty() drop). + yield "'0' reaches ES" => [['publicAccess' => '0'], ['terms' => ['publicAccess' => ['0'], 'boost' => 1.0]]]; + yield 'comma list is split' => [['publicAccess' => 'true,false'], ['terms' => ['publicAccess' => ['true', 'false'], 'boost' => 1.0]]]; + } + + // Goal: getDescription() advertises the property as an optional boolean parameter. + public function testGetDescriptionShape(): void + { + [$names, $meta, $resolver] = $this->filterDependencies(); + $filter = new BooleanFilter($names, $meta, $resolver, null, ['publicAccess' => null]); + + $description = $filter->getDescription(self::RESOURCE); + + $this->assertArrayHasKey('publicAccess', $description); + $this->assertSame('bool', $description['publicAccess']['type']); + $this->assertFalse($description['publicAccess']['required']); + } +} diff --git a/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php b/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php new file mode 100644 index 0000000..3ea4dc9 --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php @@ -0,0 +1,138 @@ +filterDependencies(); + + return new DateRangeFilter($names, $meta, $resolver, null, $properties, $config); + } + + // Goal: apply() maps the fallback and explicit operators to `range` DSL, and + // expands `between` to exclusive gt/lt bounds. + #[DataProvider('applyProvider')] + public function testApplyEmitsExpectedDsl(array $filters, array $expected): void + { + $filter = $this->newFilter( + ['updated' => 'gte'], + ['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => true]], + ); + + $this->assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + } + + public static function applyProvider(): iterable + { + yield 'no filters → empty' => [[], []]; + yield 'empty string → skipped' => [['updated' => ''], []]; + + // Bare value → the configured fallback operator (gte). + yield 'fallback operator' => [ + ['updated' => '2024-01-01T00:00:00+00:00'], + ['range' => ['updated' => ['gte' => '2024-01-01T00:00:00+00:00']]], + ]; + + // Explicit operator via `updated[gt]=…`. + yield 'explicit gt' => [ + ['updated' => ['gt' => '2024-01-01T00:00:00+00:00']], + ['range' => ['updated' => ['gt' => '2024-01-01T00:00:00+00:00']]], + ]; + + // `between` expands to EXCLUSIVE gt/lt bounds — a consumer-visible semantic. + yield 'between → exclusive gt/lt' => [ + ['updated' => ['between' => '2024-01-01T00:00:00+00:00..2024-02-01T00:00:00+00:00']], + ['range' => ['updated' => [ + 'gt' => '2024-01-01T00:00:00+00:00', + 'lt' => '2024-02-01T00:00:00+00:00', + ]]], + ]; + } + + // Goal: a malformed `between` value throws ApiPlatform's InvalidArgumentException + // (mapped to HTTP 400) rather than a native throwable that leaks as a 500. + public function testMalformedBetweenThrows(): void + { + $filter = $this->newFilter( + ['updated' => 'gte'], + ['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => true]], + ); + + // No ".." separator → invalid range. + $this->expectException(InvalidArgumentException::class); + $filter->apply([], self::RESOURCE, null, ['filters' => ['updated' => ['between' => '2024-01-01T00:00:00+00:00']]]); + } + + // Goal: an unknown operator (`updated[foo]=…`) throws ApiPlatform's + // InvalidArgumentException instead of the native \Error it used to leak. + public function testUnknownOperatorThrows(): void + { + $filter = $this->newFilter( + ['updated' => 'gte'], + ['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => true]], + ); + + $this->expectException(InvalidArgumentException::class); + $filter->apply([], self::RESOURCE, null, ['filters' => ['updated' => ['foo' => 'x']]]); + } + + // Goal: `throwOnInvalid: false` makes invalid input skip the clause (empty DSL) + // instead of throwing — the flag is consulted for both invalid paths. + #[DataProvider('invalidInputProvider')] + public function testThrowOnInvalidFalseSkipsInvalidInput(array $filters): void + { + $filter = $this->newFilter( + ['updated' => 'gte'], + ['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => false]], + ); + + $this->assertSame([], $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + } + + public static function invalidInputProvider(): iterable + { + yield 'malformed between' => [['updated' => ['between' => 'no-separator']]]; + yield 'unknown operator' => [['updated' => ['foo' => 'x']]]; + } + + // Goal: getDescription() advertises the default parameter plus every + // [operator] variant — the surface the OpenAPI spec gate protects. + public function testGetDescriptionExposesEveryOperatorVariant(): void + { + $filter = $this->newFilter( + ['updated' => 'gte'], + ['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => true]], + ); + + $description = $filter->getDescription(self::RESOURCE); + + foreach (['updated', 'updated[between]', 'updated[gt]', 'updated[gte]', 'updated[lt]', 'updated[lte]'] as $key) { + $this->assertArrayHasKey($key, $description, $key.' should be described'); + } + } +} diff --git a/tests/Unit/Api/Filter/ElasticSearch/FilterFactoryMockTrait.php b/tests/Unit/Api/Filter/ElasticSearch/FilterFactoryMockTrait.php new file mode 100644 index 0000000..ff86fe1 --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/FilterFactoryMockTrait.php @@ -0,0 +1,33 @@ +createStub(PropertyNameCollectionFactoryInterface::class), + $this->createStub(PropertyMetadataFactoryInterface::class), + $this->createStub(ResourceClassResolverInterface::class), + ]; + } +} diff --git a/tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php b/tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php new file mode 100644 index 0000000..29ca841 --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php @@ -0,0 +1,70 @@ +filterDependencies(); + $filter = new IdFilter($names, $meta, $resolver, null, $properties); + + $this->assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + } + + public static function applyProvider(): iterable + { + $props = ['organizer.entityId' => null, 'location.entityId' => null]; + + yield 'no filters → empty' => [$props, [], []]; + yield 'empty string → skipped' => [$props, ['organizer.entityId' => ''], []]; + yield 'single id' => [ + $props, + ['organizer.entityId' => '9'], + [['terms' => ['organizer.entityId' => ['9'], 'boost' => 1.0]]], + ]; + yield 'comma list is split' => [ + $props, + ['organizer.entityId' => '9,11'], + [['terms' => ['organizer.entityId' => ['9', '11'], 'boost' => 1.0]]], + ]; + yield 'two properties → two clauses' => [ + $props, + ['organizer.entityId' => '9', 'location.entityId' => '4'], + [ + ['terms' => ['organizer.entityId' => ['9'], 'boost' => 1.0]], + ['terms' => ['location.entityId' => ['4'], 'boost' => 1.0]], + ], + ]; + } + + // Goal: getDescription() advertises the property as a collection parameter. + public function testGetDescriptionShape(): void + { + [$names, $meta, $resolver] = $this->filterDependencies(); + $filter = new IdFilter($names, $meta, $resolver, null, ['organizer.entityId' => null]); + + $description = $filter->getDescription(self::RESOURCE); + + $this->assertArrayHasKey('organizer.entityId', $description); + $this->assertTrue($description['organizer.entityId']['is_collection']); + } +} diff --git a/tests/Unit/Api/Filter/ElasticSearch/MatchFilterTest.php b/tests/Unit/Api/Filter/ElasticSearch/MatchFilterTest.php new file mode 100644 index 0000000..2fe70ef --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/MatchFilterTest.php @@ -0,0 +1,76 @@ + $filters + */ + #[DataProvider('applyProvider')] + public function testApplyEmitsExpectedDsl(array $properties, array $filters, mixed $expected): void + { + [$names, $meta, $resolver] = $this->filterDependencies(); + $filter = new MatchFilter($names, $meta, $resolver, null, $properties); + + $this->assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + } + + public static function applyProvider(): iterable + { + $props = ['title' => null, 'organizer.name' => null]; + + yield 'no filters → empty' => [$props, [], []]; + yield 'unset property → empty' => [$props, ['somethingElse' => 'x'], []]; + yield 'empty string → skipped' => [$props, ['title' => ''], []]; + yield 'empty array → skipped' => [$props, ['title' => []], []]; + + // A single match returns the bare clause (not wrapped in a list). + yield 'single match' => [$props, ['title' => 'bicycle'], ['match' => ['title' => 'bicycle']]]; + + // "0" is a real value and must reach Elasticsearch (regression guard for + // the old empty() drop). + yield "'0' reaches ES" => [$props, ['title' => '0'], ['match' => ['title' => '0']]]; + + // Two matches are returned as a list. + yield 'two matches → list' => [ + $props, + ['title' => 'a', 'organizer.name' => 'b'], + [['match' => ['title' => 'a']], ['match' => ['organizer.name' => 'b']]], + ]; + } + + // Goal: getDescription() advertises the property as an optional string parameter + // (the surface the OpenAPI spec gate protects). + public function testGetDescriptionShape(): void + { + [$names, $meta, $resolver] = $this->filterDependencies(); + $filter = new MatchFilter($names, $meta, $resolver, null, ['title' => null]); + + $description = $filter->getDescription(self::RESOURCE); + + $this->assertArrayHasKey('title', $description); + $this->assertSame('title', $description['title']['property']); + $this->assertSame('string', $description['title']['type']); + $this->assertFalse($description['title']['required']); + } +} diff --git a/tests/Unit/Api/Filter/ElasticSearch/TagFilterTest.php b/tests/Unit/Api/Filter/ElasticSearch/TagFilterTest.php new file mode 100644 index 0000000..73b685b --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/TagFilterTest.php @@ -0,0 +1,54 @@ +filterDependencies(); + $filter = new TagFilter($names, $meta, $resolver, null, ['tags' => null]); + + $this->assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + } + + public static function applyProvider(): iterable + { + yield 'no filters → empty' => [[], []]; + yield 'empty string → skipped' => [['tags' => ''], []]; + yield 'single tag, verbatim casing' => [['tags' => 'ITKDev'], ['terms' => ['tags' => ['ITKDev'], 'boost' => 1.0]]]; + yield 'comma list is split' => [['tags' => 'aros,ITKDev'], ['terms' => ['tags' => ['aros', 'ITKDev'], 'boost' => 1.0]]]; + // A hyphenated tag is a single whole value, not split. + yield 'hyphenated tag is one value' => [['tags' => 'for-boern'], ['terms' => ['tags' => ['for-boern'], 'boost' => 1.0]]]; + } + + // Goal: getDescription() advertises the property as a collection parameter. + public function testGetDescriptionShape(): void + { + [$names, $meta, $resolver] = $this->filterDependencies(); + $filter = new TagFilter($names, $meta, $resolver, null, ['tags' => null]); + + $description = $filter->getDescription(self::RESOURCE); + + $this->assertArrayHasKey('tags', $description); + $this->assertTrue($description['tags']['is_collection']); + } +} diff --git a/tests/Unit/Service/ElasticSearch/ElasticIndexExceptionTest.php b/tests/Unit/Service/ElasticSearch/ElasticIndexExceptionTest.php new file mode 100644 index 0000000..82d7300 --- /dev/null +++ b/tests/Unit/Service/ElasticSearch/ElasticIndexExceptionTest.php @@ -0,0 +1,36 @@ +". + public function test400ParsesElasticErrorMessage(): void + { + $raw = '400 Bad Request: {"error":{"root_cause":[{"type":"parse_exception",' + .'"reason":"failed to parse date field [2004-02-12T15:19:21+0000]: [details]"}]}}'; + + $exception = new ElasticIndexException($raw, 400); + + $this->assertSame('Parse exception: failed to parse date field [2004-02-12T15:19:21+0000]', $exception->getMessage()); + $this->assertSame(400, $exception->getCode()); + } + + // Goal: non-400 codes collapse to a generic message (no ES JSON to parse). + public function testNon400CollapsesToBadRequest(): void + { + $exception = new ElasticIndexException('500 Internal Server Error: something', 500); + + $this->assertSame('Bad Request', $exception->getMessage()); + } +} diff --git a/tests/Unit/Service/ElasticSearch/SearchParamsBuilderTest.php b/tests/Unit/Service/ElasticSearch/SearchParamsBuilderTest.php new file mode 100644 index 0000000..7ba3c20 --- /dev/null +++ b/tests/Unit/Service/ElasticSearch/SearchParamsBuilderTest.php @@ -0,0 +1,107 @@ +value => [], FilterType::Sort->value => []]; + } + + /** + * @param array $clauses + */ + private function withFilters(array $clauses): array + { + return [FilterType::Filters->value => $clauses, FilterType::Sort->value => []]; + } + + // Goal: with no filters the query defaults to match_all and pagination/sort are set. + public function testDefaultsToMatchAllWithPagination(): void + { + $params = (new SearchParamsBuilder())->buildParams(IndexName::Events->value, $this->noFilters(), 20, 5); + + $this->assertSame(IndexName::Events->value, $params['index']); + $this->assertEquals(['match_all' => (object) []], $params['body']['query']); + $this->assertSame(5, $params['body']['size']); + $this->assertSame(20, $params['body']['from']); + $this->assertArrayHasKey('sort', $params['body']); + } + + // Goal: a single associative clause (e.g. a MatchFilter hit) is wrapped in bool/must. + public function testSingleClauseWrappedInBoolMust(): void + { + $params = (new SearchParamsBuilder())->buildParams( + IndexName::Events->value, + $this->withFilters([['match' => ['title' => 'x']]]), + 0, + 10, + ); + + $this->assertSame(['bool' => ['must' => [['match' => ['title' => 'x']]]]], $params['body']['query']); + } + + // Goal: a list-shaped clause (e.g. IdFilter output) is flattened into must, not + // nested under numeric keys. + public function testListClauseIsFlattenedIntoMust(): void + { + $params = (new SearchParamsBuilder())->buildParams( + IndexName::Events->value, + $this->withFilters([[['terms' => ['organizer.entityId' => ['9'], 'boost' => 1.0]]]]), + 0, + 10, + ); + + $this->assertSame(['bool' => ['must' => [['terms' => ['organizer.entityId' => ['9'], 'boost' => 1.0]]]]], $params['body']['query']); + } + + // Goal: multiple clauses accumulate under a single bool/must. + public function testMultipleClausesCombine(): void + { + $params = (new SearchParamsBuilder())->buildParams( + IndexName::Events->value, + $this->withFilters([ + ['match' => ['title' => 'x']], + ['terms' => ['tags' => ['aros'], 'boost' => 1.0]], + ]), + 0, + 10, + ); + + $this->assertSame([ + ['match' => ['title' => 'x']], + ['terms' => ['tags' => ['aros'], 'boost' => 1.0]], + ], $params['body']['query']['bool']['must']); + } + + // Goal: each index gets its documented sort; unknown indexes fall back to _score. + #[DataProvider('sortProvider')] + public function testSortPerIndex(string $index, array $expectedSort): void + { + $params = (new SearchParamsBuilder())->buildParams($index, $this->noFilters(), 0, 10); + + $this->assertSame($expectedSort, $params['body']['sort']); + } + + public static function sortProvider(): iterable + { + yield 'events' => [IndexName::Events->value, ['_score', ['title.keyword' => ['order' => 'asc']]]]; + yield 'occurrences' => [IndexName::Occurrences->value, ['start' => ['order' => 'asc', 'format' => 'strict_date_optional_time_nanos']]]; + yield 'tags' => [IndexName::Tags->value, ['_score', ['name.keyword' => ['order' => 'asc']]]]; + yield 'unknown → _score' => ['not_an_index', ['_score']]; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 47a5855..8e0530d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,5 +1,7 @@