From 3a35e2292bedb07dcdf75c9eaf7479186eb89aff Mon Sep 17 00:00:00 2001 From: turegjorup Date: Fri, 22 May 2026 10:53:40 +0200 Subject: [PATCH 01/63] chore: add Claude Code setup adapted to this repo Adds CLAUDE.md, project-level .claude/ config, two agents (pr-readiness, reload-fixtures) and an update-api-spec skill, all targeted at this repo's Taskfile / Elasticsearch / API Platform setup. settings.local.json is gitignored. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/agents/pr-readiness.md | 28 ++++ .claude/agents/reload-fixtures.md | 25 ++++ .claude/settings.json | 169 ++++++++++++++++++++++++ .claude/skills/update-api-spec/SKILL.md | 18 +++ .gitignore | 4 + CLAUDE.md | 90 +++++++++++++ 6 files changed, 334 insertions(+) create mode 100644 .claude/agents/pr-readiness.md create mode 100644 .claude/agents/reload-fixtures.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/update-api-spec/SKILL.md create mode 100644 CLAUDE.md diff --git a/.claude/agents/pr-readiness.md b/.claude/agents/pr-readiness.md new file mode 100644 index 0000000..52b9ee3 --- /dev/null +++ b/.claude/agents/pr-readiness.md @@ -0,0 +1,28 @@ +--- +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 6)**: `task code-analysis:phpstan` +8. **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. +9. **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. +10. **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..3f516b2 --- /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 (the Taskfile sets `ignore_error: true` because some fixtures emit a benign `Warning: Undefined array key "entityId"`). diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9c33183 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,169 @@ +{ + "$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..." + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "case \"$CLAUDE_FILE_PATH\" in */composer.lock|*/yarn.lock|*/.env.local|*/.env.local.*) echo 'BLOCKED: Do not edit lock files or .env.local directly' >&2; exit 1 ;; esac" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "case \"$CLAUDE_FILE_PATH\" in *.php) REL_PATH=\"${CLAUDE_FILE_PATH#$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": "case \"$CLAUDE_FILE_PATH\" in *.php) REL_PATH=\"${CLAUDE_FILE_PATH#$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": 30 + }, + { + "type": "command", + "command": "case \"$CLAUDE_FILE_PATH\" in *.twig) REL_PATH=\"${CLAUDE_FILE_PATH#$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": "case \"$CLAUDE_FILE_PATH\" in */composer.json) docker compose exec -T phpfpm composer normalize --quiet 2>/dev/null || true ;; esac", + "timeout": 30 + }, + { + "type": "command", + "command": "case \"$CLAUDE_FILE_PATH\" in *.yaml|*.yml) REL_PATH=\"${CLAUDE_FILE_PATH#$CLAUDE_PROJECT_DIR/}\"; docker compose run --rm -T prettier \"$REL_PATH\" --write 2>/dev/null || true ;; esac", + "timeout": 15 + }, + { + "type": "command", + "command": "case \"$CLAUDE_FILE_PATH\" in *.md) REL_PATH=\"${CLAUDE_FILE_PATH#$CLAUDE_PROJECT_DIR/}\"; docker compose run --rm -T markdownlint markdownlint \"$REL_PATH\" --fix 2>/dev/null || true ;; esac", + "timeout": 15 + } + ] + } + ], + "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..." + } + ] + } + ] + }, + "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/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/.gitignore b/.gitignore index 0f3aac4..e34bf05 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ ###> phpstan/phpstan ### phpstan.neon ###< phpstan/phpstan ### + +###> claude-code ### +/.claude/settings.local.json +###< claude-code ### diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..de3e5e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# 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.1, Elasticsearch 8.x. Runs entirely in Docker via `itkdev/php8.3-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. + +### 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 6 +``` + +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). From fb0a4390941c106348d2f41634fb675c20437089 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Fri, 22 May 2026 10:57:16 +0200 Subject: [PATCH 02/63] style: wrap CLAUDE.md prose at 120 cols for markdownlint Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 67 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index de3e5e3..4408c38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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). +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.1, Elasticsearch 8.x. Runs entirely in Docker via `itkdev/php8.3-fpm` + nginx. +Stack: PHP 8.3+, Symfony 7.4, API Platform 4.1, Elasticsearch 8.x. Runs entirely in Docker via +`itkdev/php8.3-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. +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 @@ -21,7 +27,8 @@ 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`). +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 @@ -31,7 +38,9 @@ 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. +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. ### Lint / static analysis @@ -55,36 +64,56 @@ task api:spec:export ### Request flow -API Platform resources are **DTOs**, not Doctrine entities. Each resource follows the same pattern — `Event` is the canonical example: +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`. +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. +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. +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`). +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. +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/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`. +- `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). +- 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). From e7cf4974429bbf903f1954b9bb9891c395aeb62c Mon Sep 17 00:00:00 2001 From: turegjorup Date: Fri, 22 May 2026 10:58:14 +0200 Subject: [PATCH 03/63] docs: add CHANGELOG entry for Claude Code setup Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e3ab8..12f306d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) From 017e08e64cb66b4825c8403de11f67fd9cfd4c39 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 7 Jul 2026 09:03:29 +0200 Subject: [PATCH 04/63] chore: adapt Claude Code setup from event-database-imports Fix the Edit/Write hooks to read the file path from the tool payload via jq instead of the unset CLAUDE_FILE_PATH env var (the previous hooks silently no-oped), add a SessionStart jq prerequisite check, widen the PreToolUse blocklist (symfony.lock, config/reference.php, vendor, node_modules, var/cache), and add a Stop hook that warns when the shared Elasticsearch index contract (src/Model/IndexName.php) changes. Document the cross-repo relationship and the automation in CLAUDE.md. --- .claude/settings.json | 41 +++++++++++++++------ CHANGELOG.md | 5 +++ CLAUDE.md | 39 ++++++++++++++++++++ scripts/claude-hook-check-index-contract.sh | 26 +++++++++++++ 4 files changed, 99 insertions(+), 12 deletions(-) create mode 100755 scripts/claude-hook-check-index-contract.sh diff --git a/.claude/settings.json b/.claude/settings.json index 9c33183..15f0d31 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -89,52 +89,63 @@ "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|Write", + "matcher": "Edit|MultiEdit|Write", "hooks": [ { "type": "command", - "command": "case \"$CLAUDE_FILE_PATH\" in */composer.lock|*/yarn.lock|*/.env.local|*/.env.local.*) echo 'BLOCKED: Do not edit lock files or .env.local directly' >&2; exit 1 ;; esac" + "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": "Write|Edit", + "matcher": "Edit|MultiEdit|Write", "hooks": [ { "type": "command", - "command": "case \"$CLAUDE_FILE_PATH\" in *.php) REL_PATH=\"${CLAUDE_FILE_PATH#$CLAUDE_PROJECT_DIR/}\"; docker compose exec -T phpfpm vendor/bin/php-cs-fixer fix --quiet \"$REL_PATH\" 2>/dev/null || true ;; esac", + "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": "case \"$CLAUDE_FILE_PATH\" in *.php) REL_PATH=\"${CLAUDE_FILE_PATH#$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": 30 + "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": "case \"$CLAUDE_FILE_PATH\" in *.twig) REL_PATH=\"${CLAUDE_FILE_PATH#$CLAUDE_PROJECT_DIR/}\"; docker compose exec -T phpfpm vendor/bin/twig-cs-fixer lint --fix \"$REL_PATH\" 2>/dev/null || true ;; esac", + "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": "case \"$CLAUDE_FILE_PATH\" in */composer.json) docker compose exec -T phpfpm composer normalize --quiet 2>/dev/null || true ;; esac", + "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": "case \"$CLAUDE_FILE_PATH\" in *.yaml|*.yml) REL_PATH=\"${CLAUDE_FILE_PATH#$CLAUDE_PROJECT_DIR/}\"; docker compose run --rm -T prettier \"$REL_PATH\" --write 2>/dev/null || true ;; esac", - "timeout": 15 + "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": "case \"$CLAUDE_FILE_PATH\" in *.md) REL_PATH=\"${CLAUDE_FILE_PATH#$CLAUDE_PROJECT_DIR/}\"; docker compose run --rm -T markdownlint markdownlint \"$REL_PATH\" --fix 2>/dev/null || true ;; esac", - "timeout": 15 + "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 } ] } @@ -147,6 +158,12 @@ "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..." } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f306d..10aa1aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [PR-28](https://github.com/itk-dev/event-database-api/pull/28) + 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 shared Elasticsearch index contract on + Stop, and document the cross-repo relationship in CLAUDE.md - [PR-27](https://github.com/itk-dev/event-database-api/pull/27) Add Claude Code project setup (CLAUDE.md, agents, skills) diff --git a/CLAUDE.md b/CLAUDE.md index 4408c38..322f8df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,3 +117,42 @@ JSON-encoded `APP_API_KEYS` env var. `/api/v2/docs` is public; everything else u 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 + 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`) and + warns on ES index-contract changes (`scripts/claude-hook-check-index-contract.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) and `reload-fixtures` (reload ES + fixtures / recover a not-ready cluster). +- **Skills** (`.claude/skills/`, user-invocable): `/update-api-spec` (regenerate `public/spec.yaml` after changing + an API resource). diff --git a/scripts/claude-hook-check-index-contract.sh b/scripts/claude-hook-check-index-contract.sh new file mode 100755 index 0000000..deb2a98 --- /dev/null +++ b/scripts/claude-hook-check-index-contract.sh @@ -0,0 +1,26 @@ +#!/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 +# only the index-name enum; the document mappings live solely in the importer. +# 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' || exit 0 + +cat >&2 <<'MSG' +WARN: the Elasticsearch index-name enum changed (src/Model/IndexName.php). + event-database-imports writes these indices and duplicates the contract by + hand in its src/Model/Indexing/IndexNames.php. An index name that does not + match what the importer creates (and populates behind the queried alias) + yields an empty or failing API resource. Coordinate the change with + event-database-imports before deploying. +MSG + +exit 0 From f11230f639e807ec2fa62d25361c6b80a185617d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 7 Jul 2026 10:20:14 +0200 Subject: [PATCH 05/63] chore: add Claude Code automations (skills, subagent, spec-drift hook) Add two user-invocable skills (changelog-entry, new-resource), a filter-provider-reviewer subagent for the ES filter/provider layer, and a Stop hook that warns when an Api/Dto resource changed without regenerating public/spec.yaml (which CI's api-spec.yml would otherwise fail on). The Elasticsearch MCP from the automation review was intentionally left out: it is the only recommended item needing an external runtime, the sibling event-database-imports repo did not adopt one, and ad-hoc ES access via `docker compose exec phpfpm curl` covers the need with no new dependency. --- .claude/agents/filter-provider-reviewer.md | 34 ++++++++++++++++ .claude/settings.json | 6 +++ .claude/skills/changelog-entry/SKILL.md | 45 ++++++++++++++++++++++ .claude/skills/new-resource/SKILL.md | 39 +++++++++++++++++++ CHANGELOG.md | 7 ++-- CLAUDE.md | 13 ++++--- scripts/claude-hook-check-spec-drift.sh | 26 +++++++++++++ 7 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 .claude/agents/filter-provider-reviewer.md create mode 100644 .claude/skills/changelog-entry/SKILL.md create mode 100644 .claude/skills/new-resource/SKILL.md create mode 100755 scripts/claude-hook-check-spec-drift.sh diff --git a/.claude/agents/filter-provider-reviewer.md b/.claude/agents/filter-provider-reviewer.md new file mode 100644 index 0000000..169ae04 --- /dev/null +++ b/.claude/agents/filter-provider-reviewer.md @@ -0,0 +1,34 @@ +--- +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.** Field names/types referenced in the DSL must match what `event-database-imports` + writes (mappings live only there — this repo has none). A typo'd or renamed field yields empty/incorrect results + silently, not an error. Flag any field reference you can't confirm against the index contract and recommend + verifying against 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/settings.json b/.claude/settings.json index 15f0d31..98d3cae 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -164,6 +164,12 @@ "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..." } ] } 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/CHANGELOG.md b/CHANGELOG.md index 10aa1aa..66e7ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,10 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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 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 shared Elasticsearch index contract on - Stop, and document the cross-repo relationship in CLAUDE.md + 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) diff --git a/CLAUDE.md b/CLAUDE.md index 322f8df..7215c45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,11 +148,14 @@ run tooling **inside the `phpfpm` container**. - **Hooks** — `SessionStart` boots the Docker stack and checks host prerequisites; `PostToolUse` auto-runs 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`) and - warns on ES index-contract changes (`scripts/claude-hook-check-index-contract.sh`). + (`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) and `reload-fixtures` (reload ES - fixtures / recover a not-ready cluster). +- **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). + 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/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 From 1de10df7ce29415e9af72c9cac6051e1af74c159 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 7 Jul 2026 10:23:27 +0200 Subject: [PATCH 06/63] docs: tighten CHANGELOG entry to match house tone --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10aa1aa..56e4bc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,7 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] - [PR-28](https://github.com/itk-dev/event-database-api/pull/28) - 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 shared Elasticsearch index contract on - Stop, and document the cross-repo relationship in CLAUDE.md + 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) From 50004ecb77efedcda9c84ea4d582d25ceeb7b4ee Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 7 Jul 2026 10:37:15 +0200 Subject: [PATCH 07/63] fix: resolve composer audit failures The composer-audit CI job failed for two reasons: it ran `composer audit` in a fresh container with no install (so it errored with "No installed packages found"), and once running it reported 14 advisories across 6 packages (twig, symfony/*, guzzle) published after the last green run. Audit the lock file directly with `composer audit --locked` (matching the updated devops_itkdev-docker template) and update the vulnerable dependencies to patched versions within the existing constraints. All advisories clear; the API test suite passes. --- .github/workflows/composer.yaml | 2 +- CHANGELOG.md | 2 + composer.lock | 275 ++++++++++++++++---------------- 3 files changed, 143 insertions(+), 136 deletions(-) diff --git a/.github/workflows/composer.yaml b/.github/workflows/composer.yaml index a0e5a94..3b43b64 100644 --- a/.github/workflows/composer.yaml +++ b/.github/workflows/composer.yaml @@ -77,4 +77,4 @@ jobs: docker network create frontend - run: | - docker compose run --rm phpfpm composer audit + docker compose run --rm phpfpm composer audit --locked diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f306d..3cc148f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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-27](https://github.com/itk-dev/event-database-api/pull/27) Add Claude Code project setup (CLAUDE.md, agents, skills) diff --git a/composer.lock b/composer.lock index c162754..48ac8ae 100644 --- a/composer.lock +++ b/composer.lock @@ -477,25 +477,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.3", + "version": "7.13.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86" + "reference": "bcd989ad36c92d42a3715379af91f2defee5b8dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", - "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/bcd989ad36c92d42a3715379af91f2defee5b8dd", + "reference": "bcd989ad36c92d42a3715379af91f2defee5b8dd", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.12.3", "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 +504,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 +585,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.13.2" }, "funding": [ { @@ -600,24 +601,25 @@ "type": "tidelift" } ], - "time": "2026-05-20T22:59:19+00:00" + "time": "2026-07-05T19:00:11+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.1", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e" + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e", - "reference": "d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", "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 +669,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.0" }, "funding": [ { @@ -683,27 +685,29 @@ "type": "tidelift" } ], - "time": "2026-05-19T18:30:48+00:00" + "time": "2026-06-02T12:23:43+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.8.1", + "version": "2.12.3", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "718f1ee6a878be5290af3557aeda0c91278361d9" + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/718f1ee6a878be5290af3557aeda0c91278361d9", - "reference": "718f1ee6a878be5290af3557aeda0c91278361d9", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", "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 +715,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 +788,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.3" }, "funding": [ { @@ -799,7 +804,7 @@ "type": "tidelift" } ], - "time": "2026-03-10T09:55:26+00:00" + "time": "2026-06-23T15:21:08+00:00" }, { "name": "nelmio/cors-bundle", @@ -2513,16 +2518,16 @@ }, { "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 +2565,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,7 +2585,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/dotenv", @@ -2662,16 +2667,16 @@ }, { "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 +2725,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 +2745,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 +2810,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 +2830,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 +2890,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,7 +2910,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/expression-language", @@ -3529,16 +3534,16 @@ }, { "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 +3592,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 +3612,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 +3711,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 +3731,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 +3811,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 +3869,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 +3889,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 +3954,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 +3974,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 +4039,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,7 +4059,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php82", @@ -4138,16 +4143,16 @@ }, { "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 +4199,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,7 +4219,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-27T06:51:48+00:00" }, { "name": "symfony/polyfill-php84", @@ -4298,16 +4303,16 @@ }, { "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 +4359,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 +4379,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 +4554,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 +4615,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,7 +4635,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { "name": "symfony/runtime", @@ -4830,16 +4835,16 @@ }, { "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 +4902,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 +4922,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 +5000,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 +5068,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,7 +5088,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-19T08:40:54+00:00" }, { "name": "symfony/serializer", @@ -5191,16 +5196,16 @@ }, { "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 +5259,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 +5279,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 +5350,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,7 +5370,7 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:04:42+00:00" + "time": "2026-05-23T15:23:29+00:00" }, { "name": "symfony/translation-contracts", @@ -5843,16 +5848,16 @@ }, { "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 +5911,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,7 +5931,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:44:50+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "symfony/var-exporter", @@ -6174,16 +6179,16 @@ }, { "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 +6243,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,7 +6255,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:31:59+00:00" + "time": "2026-07-03T20:44:34+00:00" }, { "name": "webmozart/assert", From b439776d74afbb66dd95d8d02413ed41cb2b188d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 7 Jul 2026 13:03:18 +0200 Subject: [PATCH 08/63] chore: align dev tooling with event-database-imports Bring the dev-tooling surface in line with the canonical event-database-imports setup (its PR #88 raised PHPStan and added strict rules): - PHPStan level 6 -> 8 with phpstan/phpstan-strict-rules, existing findings baselined (phpstan-baseline.neon) so new code is held to the higher bar. - Exclude the auto-generated config/reference.php; drop tests/ from analysis (matching importers) and use identifier-based ignores. - PHP 8.4 as the committed docker-compose default (dev override already used it). - twig-cs-fixer ^3.7 -> ^4.0 and PHPUnit ^12 -> ^13; php-cs-fixer follows to 3.95 (one else-after-return fix applied). phpstan (level 8), php-cs-fixer, twig-cs-fixer, composer audit, and the API test suite (19 tests) all pass. --- CHANGELOG.md | 2 + composer.json | 6 +- composer.lock | 1005 +++++++++++------ docker-compose.server.yml | 2 +- docker-compose.yml | 2 +- phpstan-baseline.neon | 205 ++++ phpstan.dist.neon | 26 +- .../ElasticSearch/ElasticSearchIndex.php | 4 +- 8 files changed, 916 insertions(+), 336 deletions(-) create mode 100644 phpstan-baseline.neon diff --git a/CHANGELOG.md b/CHANGELOG.md index c890c11..789fd8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) diff --git a/composer.json b/composer.json index c8e856f..3363fac 100644 --- a/composer.json +++ b/composer.json @@ -32,14 +32,16 @@ "ergebnis/composer-normalize": "^2.47", "friendsofphp/php-cs-fixer": "^3.86", "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", "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 48ac8ae..38d7273 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "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": "96179f4d3c4439728b30f58af22cf645", "packages": [ { "name": "api-platform/core", @@ -2336,16 +2336,16 @@ }, { "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": { @@ -2410,7 +2410,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.11" + "source": "https://github.com/symfony/console/tree/v7.4.14" }, "funding": [ { @@ -2430,7 +2430,7 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:04:42+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/dependency-injection", @@ -3052,16 +3052,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": { @@ -3096,7 +3096,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": [ { @@ -3116,7 +3116,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/flex", @@ -4223,16 +4223,16 @@ }, { "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": { @@ -4279,7 +4279,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": [ { @@ -4299,7 +4299,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php85", @@ -6259,23 +6259,23 @@ }, { "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": "", @@ -6284,8 +6284,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": { @@ -6301,6 +6305,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.", @@ -6311,9 +6319,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", @@ -6439,28 +6447,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": { @@ -6498,7 +6507,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": [ { @@ -6508,13 +6517,9 @@ { "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", @@ -6659,6 +6664,75 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "ergebnis/agent-detector", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "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": { + "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": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "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" + }, + "time": "2026-05-07T08:19:07+00:00" + }, { "name": "ergebnis/composer-normalize", "version": "2.48.2", @@ -7233,22 +7307,23 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.89.2", + "version": "v3.95.11", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "7569658f91e475ec93b99bd5964b059ad1336dcf" + "reference": "35f98e1293283397824d7f349ce5afb8747c3cd5" }, "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/35f98e1293283397824d7f349ce5afb8747c3cd5", + "reference": "35f98e1293283397824d7f349ce5afb8747c3cd5", "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": "*", @@ -7259,31 +7334,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.34 || ^10.5.63 || ^11.5.55", + "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", @@ -7298,7 +7374,7 @@ "PhpCsFixer\\": "src/" }, "exclude-from-classmap": [ - "src/Fixer/Internal/*" + "src/**/Internal/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -7324,7 +7400,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.11" }, "funding": [ { @@ -7332,7 +7408,7 @@ "type": "github" } ], - "time": "2025-11-06T21:12:50+00:00" + "time": "2026-06-25T14:17:04+00:00" }, { "name": "justinrainbow/json-schema", @@ -7666,20 +7742,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" @@ -7718,9 +7793,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", @@ -7890,11 +7965,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": { @@ -7917,6 +7992,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -7939,7 +8025,58 @@ "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", @@ -8017,33 +8154,35 @@ }, { "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", @@ -8052,7 +8191,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5.x-dev" + "dev-main": "14.2.x-dev" } }, "autoload": { @@ -8081,7 +8220,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": [ { @@ -8101,32 +8240,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": { @@ -8154,7 +8293,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": [ { @@ -8174,28 +8313,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": "*" @@ -8203,7 +8342,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -8230,40 +8369,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": { @@ -8290,40 +8441,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": { @@ -8350,56 +8513,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.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "e78c9ad74f73fd3642a23e65ace83746dc8df26d" + "reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e78c9ad74f73fd3642a23e65ace83746dc8df26d", - "reference": "e78c9ad74f73fd3642a23e65ace83746dc8df26d", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d76d0e24225e587d6a5f0c6f6d9fef0d90712b54", + "reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54", "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.2", + "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": [ @@ -8408,7 +8585,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5-dev" + "dev-main": "13.2-dev" } }, "autoload": { @@ -8440,7 +8617,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.3" }, "funding": [ { @@ -8448,7 +8625,7 @@ "type": "other" } ], - "time": "2026-05-21T12:36:53+00:00" + "time": "2026-07-06T14:55:56+00:00" }, { "name": "react/cache", @@ -8599,16 +8776,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": { @@ -8663,7 +8840,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": [ { @@ -8671,20 +8848,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": { @@ -8735,7 +8912,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": [ { @@ -8743,7 +8920,7 @@ "type": "open_collective" } ], - "time": "2023-11-13T13:48:05+00:00" + "time": "2025-11-17T20:46:25+00:00" }, { "name": "react/promise", @@ -8820,16 +8997,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": { @@ -8888,7 +9065,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": [ { @@ -8896,7 +9073,7 @@ "type": "open_collective" } ], - "time": "2024-07-26T10:38:09+00:00" + "time": "2025-11-19T20:47:34+00:00" }, { "name": "react/stream", @@ -8978,28 +9155,28 @@ }, { "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": { @@ -9023,7 +9200,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": [ { @@ -9043,31 +9220,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" @@ -9075,7 +9252,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.1-dev" + "dev-main": "8.3-dev" } }, "autoload": { @@ -9115,7 +9292,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": [ { @@ -9135,33 +9312,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": { @@ -9185,41 +9362,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": { @@ -9252,35 +9441,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": "*" @@ -9288,7 +9489,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.1-dev" + "dev-main": "9.3-dev" } }, "autoload": { @@ -9316,7 +9517,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": [ { @@ -9336,34 +9537,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": { @@ -9406,7 +9607,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": [ { @@ -9426,35 +9627,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": { @@ -9480,7 +9819,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": [ { @@ -9500,33 +9839,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": { @@ -9550,7 +9889,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": [ { @@ -9570,34 +9909,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": { @@ -9620,40 +9959,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": { @@ -9676,40 +10027,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": { @@ -9740,7 +10103,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": [ { @@ -9760,32 +10123,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": { @@ -9809,7 +10172,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": [ { @@ -9829,29 +10192,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": { @@ -9875,15 +10238,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", @@ -10323,16 +10698,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": { @@ -10364,7 +10739,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": [ { @@ -10384,7 +10759,7 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:55:21+00:00" + "time": "2026-05-23T16:05:06+00:00" }, { "name": "symfony/stopwatch", @@ -10594,37 +10969,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", @@ -10659,7 +11034,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": [ { @@ -10667,7 +11042,7 @@ "type": "github" } ], - "time": "2025-09-15T11:28:55+00:00" + "time": "2026-06-29T15:22:14+00:00" } ], "aliases": [], 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..ad481a8 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,205 @@ +parameters: + ignoreErrors: + - + message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' + identifier: empty.notAllowed + count: 2 + path: src/Api/Filter/ElasticSearch/BooleanFilter.php + + - + message: '#^Only booleans are allowed in a negated boolean, array\|null given\.$#' + identifier: booleanNot.exprNotBoolean + count: 1 + path: src/Api/Filter/ElasticSearch/BooleanFilter.php + + - + message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' + identifier: empty.notAllowed + count: 1 + path: src/Api/Filter/ElasticSearch/DateFilter.php + + - + message: '#^Only booleans are allowed in a negated boolean, array\|null given\.$#' + identifier: booleanNot.exprNotBoolean + count: 2 + path: src/Api/Filter/ElasticSearch/DateFilter.php + + - + message: '#^Class constant name in dynamic fetch can only be a string, int\|string\|null given\.$#' + identifier: classConstant.nameType + count: 1 + path: src/Api/Filter/ElasticSearch/DateRangeFilter.php + + - + message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' + identifier: empty.notAllowed + count: 1 + path: src/Api/Filter/ElasticSearch/DateRangeFilter.php + + - + message: '#^Only booleans are allowed in a negated boolean, array\|null given\.$#' + identifier: booleanNot.exprNotBoolean + count: 2 + path: src/Api/Filter/ElasticSearch/DateRangeFilter.php + + - + message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' + identifier: empty.notAllowed + count: 1 + path: src/Api/Filter/ElasticSearch/IdFilter.php + + - + message: '#^Only booleans are allowed in a negated boolean, array\|null given\.$#' + identifier: booleanNot.exprNotBoolean + count: 1 + path: src/Api/Filter/ElasticSearch/IdFilter.php + + - + message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' + identifier: empty.notAllowed + count: 1 + path: src/Api/Filter/ElasticSearch/MatchFilter.php + + - + message: '#^Only booleans are allowed in a negated boolean, array\|null given\.$#' + identifier: booleanNot.exprNotBoolean + count: 1 + path: src/Api/Filter/ElasticSearch/MatchFilter.php + + - + message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' + identifier: empty.notAllowed + count: 2 + path: src/Api/Filter/ElasticSearch/TagFilter.php + + - + message: '#^Only booleans are allowed in a negated boolean, array\|null given\.$#' + identifier: booleanNot.exprNotBoolean + count: 1 + path: src/Api/Filter/ElasticSearch/TagFilter.php + + - + message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' + identifier: empty.notAllowed + count: 1 + path: src/Api/State/AbstractProvider.php + + - + 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: '#^Call to function in_array\(\) requires parameter \#3 to be set\.$#' + identifier: function.strict + count: 1 + path: src/Command/FixturesLoadCommand.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 + + - + message: '#^Call to an undefined method Elastic\\Elasticsearch\\Response\\Elasticsearch\|Http\\Promise\\Promise\:\:getStatusCode\(\)\.$#' + identifier: method.notFound + count: 2 + path: src/Fixtures/FixtureLoader.php + + - + message: '#^Call to function in_array\(\) requires parameter \#3 to be set\.$#' + identifier: function.strict + count: 1 + path: src/Fixtures/FixtureLoader.php + + - + message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' + identifier: empty.notAllowed + count: 1 + path: src/Fixtures/FixtureLoader.php + + - + message: '#^Only booleans are allowed in an if condition, int\|false given\.$#' + identifier: if.condNotBoolean + count: 1 + path: src/Fixtures/FixtureLoader.php + + - + message: '#^Parameter \#1 \$json of function json_decode expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: src/Fixtures/FixtureLoader.php + + - + message: '#^Method App\\Security\\ApiUser\:\:getUserIdentifier\(\) should return non\-empty\-string but returns string\.$#' + identifier: return.type + count: 1 + path: src/Security/ApiUser.php + + - + message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' + identifier: empty.notAllowed + count: 1 + path: src/Service/ElasticSearch/ElasticSearchIndex.php + + - + message: '#^Parameter \#1 \$params of method Elastic\\Elasticsearch\\Client\:\:get\(\) expects array\{id\: string, index\: string, force_synthetic_source\?\: bool, stored_fields\?\: array\\|string, preference\?\: string, realtime\?\: bool, refresh\?\: bool, routing\?\: string, \.\.\.\}\|null, array\{index\: string, id\: int\|string\} given\.$#' + identifier: argument.type + count: 1 + path: src/Service/ElasticSearch/ElasticSearchIndex.php + + - + message: '#^Method App\\Service\\ElasticSearch\\ElasticSearchPaginator\:\:count\(\) should return int\<0, max\> but returns int\.$#' + identifier: return.type + count: 1 + path: src/Service/ElasticSearch/ElasticSearchPaginator.php + + - + message: '#^Short ternary operator is not allowed\. Use null coalesce operator if applicable or consider using long ternary\.$#' + identifier: ternary.shortNotAllowed + count: 1 + path: src/Service/ElasticSearch/ElasticSearchPaginator.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/src/Service/ElasticSearch/ElasticSearchIndex.php b/src/Service/ElasticSearch/ElasticSearchIndex.php index be130f4..6092718 100644 --- a/src/Service/ElasticSearch/ElasticSearchIndex.php +++ b/src/Service/ElasticSearch/ElasticSearchIndex.php @@ -41,9 +41,9 @@ 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 From 1aac5904e2dbbb541b63866001112582d262808e Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 7 Jul 2026 13:31:11 +0200 Subject: [PATCH 09/63] chore: pay down PHPStan baseline Fix the mechanical strict-rule findings so the baseline drops from 34 to 10 entries; the remaining 10 are genuine typing work left for a follow-up (offset-access null-safety on ES responses, provider return-type covariance, one dynamic enum-constant fetch, and a console suggestedValues closure). - Replace banned empty()/short-ternary/negated-array constructs with explicit comparisons (behaviour-preserving; filters skip unset/''/[] as before). - Pass the strict flag to in_array(). - Narrow the Elasticsearch client response with @var (as ElasticSearchIndex already does) and guard file_get_contents()/json_decode() in the fixture loader. - Annotate ApiUser::$username as non-empty-string; make the paginator count non-negative. phpstan (level 8 + strict), php-cs-fixer, and the API test suite (19 tests) all pass. --- CHANGELOG.md | 2 + phpstan-baseline.neon | 144 ------------------ .../Filter/ElasticSearch/BooleanFilter.php | 6 +- src/Api/Filter/ElasticSearch/DateFilter.php | 6 +- .../Filter/ElasticSearch/DateRangeFilter.php | 6 +- src/Api/Filter/ElasticSearch/IdFilter.php | 4 +- src/Api/Filter/ElasticSearch/MatchFilter.php | 4 +- src/Api/Filter/ElasticSearch/TagFilter.php | 6 +- src/Api/State/AbstractProvider.php | 2 +- src/Command/FixturesLoadCommand.php | 2 +- src/Fixtures/FixtureLoader.php | 14 +- src/Security/ApiUser.php | 3 + .../ElasticSearch/ElasticSearchIndex.php | 4 +- .../ElasticSearch/ElasticSearchPaginator.php | 6 +- 14 files changed, 39 insertions(+), 170 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 789fd8e..307096a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ad481a8..16d805c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,89 +1,11 @@ parameters: ignoreErrors: - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 2 - path: src/Api/Filter/ElasticSearch/BooleanFilter.php - - - - message: '#^Only booleans are allowed in a negated boolean, array\|null given\.$#' - identifier: booleanNot.exprNotBoolean - count: 1 - path: src/Api/Filter/ElasticSearch/BooleanFilter.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 1 - path: src/Api/Filter/ElasticSearch/DateFilter.php - - - - message: '#^Only booleans are allowed in a negated boolean, array\|null given\.$#' - identifier: booleanNot.exprNotBoolean - count: 2 - path: src/Api/Filter/ElasticSearch/DateFilter.php - - message: '#^Class constant name in dynamic fetch can only be a string, int\|string\|null given\.$#' identifier: classConstant.nameType count: 1 path: src/Api/Filter/ElasticSearch/DateRangeFilter.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 1 - path: src/Api/Filter/ElasticSearch/DateRangeFilter.php - - - - message: '#^Only booleans are allowed in a negated boolean, array\|null given\.$#' - identifier: booleanNot.exprNotBoolean - count: 2 - path: src/Api/Filter/ElasticSearch/DateRangeFilter.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 1 - path: src/Api/Filter/ElasticSearch/IdFilter.php - - - - message: '#^Only booleans are allowed in a negated boolean, array\|null given\.$#' - identifier: booleanNot.exprNotBoolean - count: 1 - path: src/Api/Filter/ElasticSearch/IdFilter.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 1 - path: src/Api/Filter/ElasticSearch/MatchFilter.php - - - - message: '#^Only booleans are allowed in a negated boolean, array\|null given\.$#' - identifier: booleanNot.exprNotBoolean - count: 1 - path: src/Api/Filter/ElasticSearch/MatchFilter.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 2 - path: src/Api/Filter/ElasticSearch/TagFilter.php - - - - message: '#^Only booleans are allowed in a negated boolean, array\|null given\.$#' - identifier: booleanNot.exprNotBoolean - count: 1 - path: src/Api/Filter/ElasticSearch/TagFilter.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 1 - path: src/Api/State/AbstractProvider.php - - message: '#^Offset ''_source'' might not exist on array\|null\.$#' identifier: offsetAccess.notFound @@ -132,74 +54,8 @@ parameters: count: 1 path: src/Api/State/OrganizationRepresentationProvider.php - - - message: '#^Call to function in_array\(\) requires parameter \#3 to be set\.$#' - identifier: function.strict - count: 1 - path: src/Command/FixturesLoadCommand.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 - - - - message: '#^Call to an undefined method Elastic\\Elasticsearch\\Response\\Elasticsearch\|Http\\Promise\\Promise\:\:getStatusCode\(\)\.$#' - identifier: method.notFound - count: 2 - path: src/Fixtures/FixtureLoader.php - - - - message: '#^Call to function in_array\(\) requires parameter \#3 to be set\.$#' - identifier: function.strict - count: 1 - path: src/Fixtures/FixtureLoader.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 1 - path: src/Fixtures/FixtureLoader.php - - - - message: '#^Only booleans are allowed in an if condition, int\|false given\.$#' - identifier: if.condNotBoolean - count: 1 - path: src/Fixtures/FixtureLoader.php - - - - message: '#^Parameter \#1 \$json of function json_decode expects string, string\|false given\.$#' - identifier: argument.type - count: 1 - path: src/Fixtures/FixtureLoader.php - - - - message: '#^Method App\\Security\\ApiUser\:\:getUserIdentifier\(\) should return non\-empty\-string but returns string\.$#' - identifier: return.type - count: 1 - path: src/Security/ApiUser.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 1 - path: src/Service/ElasticSearch/ElasticSearchIndex.php - - - - message: '#^Parameter \#1 \$params of method Elastic\\Elasticsearch\\Client\:\:get\(\) expects array\{id\: string, index\: string, force_synthetic_source\?\: bool, stored_fields\?\: array\\|string, preference\?\: string, realtime\?\: bool, refresh\?\: bool, routing\?\: string, \.\.\.\}\|null, array\{index\: string, id\: int\|string\} given\.$#' - identifier: argument.type - count: 1 - path: src/Service/ElasticSearch/ElasticSearchIndex.php - - - - message: '#^Method App\\Service\\ElasticSearch\\ElasticSearchPaginator\:\:count\(\) should return int\<0, max\> but returns int\.$#' - identifier: return.type - count: 1 - path: src/Service/ElasticSearch/ElasticSearchPaginator.php - - - - message: '#^Short ternary operator is not allowed\. Use null coalesce operator if applicable or consider using long ternary\.$#' - identifier: ternary.shortNotAllowed - count: 1 - path: src/Service/ElasticSearch/ElasticSearchPaginator.php diff --git a/src/Api/Filter/ElasticSearch/BooleanFilter.php b/src/Api/Filter/ElasticSearch/BooleanFilter.php index b74b778..20e3fdc 100644 --- a/src/Api/Filter/ElasticSearch/BooleanFilter.php +++ b/src/Api/Filter/ElasticSearch/BooleanFilter.php @@ -16,19 +16,19 @@ public function apply(array $clauseBody, string $resourceClass, ?Operation $oper /** @var string $property */ foreach ($properties as $property) { - if (empty($context['filters'][$property])) { + if (!isset($context['filters'][$property]) || '' === $context['filters'][$property] || [] === $context['filters'][$property]) { // If no value or empty value is set, skip it. continue; } $terms[$property] = explode(',', $context['filters'][$property]); } - return empty($terms) ? $terms : ['terms' => $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 []; } 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..cb6a0ef 100644 --- a/src/Api/Filter/ElasticSearch/DateRangeFilter.php +++ b/src/Api/Filter/ElasticSearch/DateRangeFilter.php @@ -47,12 +47,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]) { $ranges[] = $this->getElasticSearchQueryRanges($property, $context['filters'][$property]); } } @@ -62,7 +62,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/IdFilter.php b/src/Api/Filter/ElasticSearch/IdFilter.php index 073835b..abf5fed 100644 --- a/src/Api/Filter/ElasticSearch/IdFilter.php +++ b/src/Api/Filter/ElasticSearch/IdFilter.php @@ -16,7 +16,7 @@ public function apply(array $clauseBody, string $resourceClass, ?Operation $oper /** @var string $property */ foreach ($properties as $property) { - if (empty($context['filters'][$property])) { + if (!isset($context['filters'][$property]) || '' === $context['filters'][$property] || [] === $context['filters'][$property]) { // If no value or empty value is set, skip it. continue; } @@ -31,7 +31,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/MatchFilter.php b/src/Api/Filter/ElasticSearch/MatchFilter.php index 0f10323..0cedc10 100644 --- a/src/Api/Filter/ElasticSearch/MatchFilter.php +++ b/src/Api/Filter/ElasticSearch/MatchFilter.php @@ -19,7 +19,7 @@ public function apply(array $clauseBody, string $resourceClass, ?Operation $oper /** @var string $property */ foreach ($properties as $property) { - if (!empty($context['filters'][$property])) { + if (isset($context['filters'][$property]) && '' !== $context['filters'][$property] && [] !== $context['filters'][$property]) { $matches[] = ['match' => [$property => $context['filters'][$property]]]; } } @@ -29,7 +29,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/TagFilter.php b/src/Api/Filter/ElasticSearch/TagFilter.php index 196bef1..d67a6d6 100644 --- a/src/Api/Filter/ElasticSearch/TagFilter.php +++ b/src/Api/Filter/ElasticSearch/TagFilter.php @@ -16,19 +16,19 @@ public function apply(array $clauseBody, string $resourceClass, ?Operation $oper /** @var string $property */ foreach ($properties as $property) { - if (empty($context['filters'][$property])) { + if (!isset($context['filters'][$property]) || '' === $context['filters'][$property] || [] === $context['filters'][$property]) { // If no value or empty value is set, skip it. continue; } $terms[$property] = explode(',', $context['filters'][$property]); } - return empty($terms) ? $terms : ['terms' => $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 []; } 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/Command/FixturesLoadCommand.php b/src/Command/FixturesLoadCommand.php index 3257d27..6bf1aef 100644 --- a/src/Command/FixturesLoadCommand.php +++ b/src/Command/FixturesLoadCommand.php @@ -70,7 +70,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/Fixtures/FixtureLoader.php b/src/Fixtures/FixtureLoader.php index e079b29..8a66139 100644 --- a/src/Fixtures/FixtureLoader.php +++ b/src/Fixtures/FixtureLoader.php @@ -7,6 +7,7 @@ use Elastic\Elasticsearch\Exception\ClientResponseException; use Elastic\Elasticsearch\Exception\MissingParameterException; use Elastic\Elasticsearch\Exception\ServerResponseException; +use Elastic\Elasticsearch\Response\Elasticsearch; use Symfony\Component\HttpFoundation\Response; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; @@ -63,13 +64,17 @@ public function process(string $indexName, string $url): void private function download(string $url): array { // Load from local file if using "file" URL scheme. - if (preg_match('~^file://(?/.+)$~', $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 +113,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) { diff --git a/src/Security/ApiUser.php b/src/Security/ApiUser.php index c5cdee5..e95f0c3 100644 --- a/src/Security/ApiUser.php +++ b/src/Security/ApiUser.php @@ -6,6 +6,9 @@ class ApiUser implements UserInterface { + /** + * @param non-empty-string $username + */ public function __construct(public readonly string $username) { } diff --git a/src/Service/ElasticSearch/ElasticSearchIndex.php b/src/Service/ElasticSearch/ElasticSearchIndex.php index 6092718..0341676 100644 --- a/src/Service/ElasticSearch/ElasticSearchIndex.php +++ b/src/Service/ElasticSearch/ElasticSearchIndex.php @@ -50,7 +50,7 @@ private function getById(string $indexName, int|string $id): array { $params = [ 'index' => $indexName, - 'id' => $id, + 'id' => (string) $id, ]; try { @@ -163,7 +163,7 @@ private function buildParams(string $indexName, array $filters, int $from, int $ ]; $body = $this->buildBody($filters); - if (!empty($body)) { + if ([] !== $body) { $params['body']['query'] = $body; } diff --git a/src/Service/ElasticSearch/ElasticSearchPaginator.php b/src/Service/ElasticSearch/ElasticSearchPaginator.php index 4a9ec07..91d8a81 100644 --- a/src/Service/ElasticSearch/ElasticSearchPaginator.php +++ b/src/Service/ElasticSearch/ElasticSearchPaginator.php @@ -19,7 +19,7 @@ public function __construct( public function count(): int { - return $this->results->total; + return max(0, $this->results->total); } public function getLastPage(): float @@ -28,7 +28,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 From 16a06bbfdcde231f11b25614a847f44797ca51a2 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Fri, 22 May 2026 11:12:32 +0200 Subject: [PATCH 10/63] test: expand API contract test coverage Add tests covering all seven API Platform resources, the API key auth boundary, pagination semantics, and filter error handling. - New traits: GetItemTestTrait (200 + JSON-schema + 401 + 404), CollectionShapeTrait via extended GetEntitiesTestTrait. - New per-resource tests for Occurrence, DailyOccurrence, Location and Organization mirroring the EventsTest/EventsFilterTest pattern. - New TagsFilterTest, VocabulariesFilterTest. - New AuthenticationTest, PaginationTest (data-provider across all seven resources) and FilterErrorTest. - Wire EventsTest, TagsTest, VocabulariesTest to GetItemTestTrait. - Tighten EventsFilterTest: drop duplicate yield, add 'updated' cases, fix namespace. FilterErrorTest is skipped pending a fix for a contract bug: the DateRangeFilter throws PHP's \InvalidArgumentException, which is not in api_platform.yaml's exception_to_status map, so malformed date ranges currently return 500 instead of 400. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 + tests/ApiPlatform/AbstractApiTestCase.php | 6 + tests/ApiPlatform/AuthenticationTest.php | 61 ++++++++++ .../DailyOccurrencesFilterTest.php | 67 +++++++++++ tests/ApiPlatform/DailyOccurrencesTest.php | 19 +++ tests/ApiPlatform/EventsFilterTest.php | 30 ++--- tests/ApiPlatform/EventsTest.php | 7 ++ tests/ApiPlatform/FilterErrorTest.php | 66 +++++++++++ tests/ApiPlatform/LocationsFilterTest.php | 40 +++++++ tests/ApiPlatform/LocationsTest.php | 19 +++ tests/ApiPlatform/OccurrencesFilterTest.php | 109 ++++++++++++++++++ tests/ApiPlatform/OccurrencesTest.php | 19 +++ tests/ApiPlatform/OrganizationsFilterTest.php | 37 ++++++ tests/ApiPlatform/OrganizationsTest.php | 19 +++ tests/ApiPlatform/PaginationTest.php | 83 +++++++++++++ tests/ApiPlatform/TagsFilterTest.php | 40 +++++++ tests/ApiPlatform/TagsTest.php | 9 ++ .../Trait/GetEntitiesTestTrait.php | 8 ++ tests/ApiPlatform/Trait/GetItemTestTrait.php | 35 ++++++ tests/ApiPlatform/VocabulariesFilterTest.php | 40 +++++++ tests/ApiPlatform/VocabulariesTest.php | 9 ++ 21 files changed, 710 insertions(+), 15 deletions(-) create mode 100644 tests/ApiPlatform/AuthenticationTest.php create mode 100644 tests/ApiPlatform/DailyOccurrencesFilterTest.php create mode 100644 tests/ApiPlatform/DailyOccurrencesTest.php create mode 100644 tests/ApiPlatform/FilterErrorTest.php create mode 100644 tests/ApiPlatform/LocationsFilterTest.php create mode 100644 tests/ApiPlatform/LocationsTest.php create mode 100644 tests/ApiPlatform/OccurrencesFilterTest.php create mode 100644 tests/ApiPlatform/OccurrencesTest.php create mode 100644 tests/ApiPlatform/OrganizationsFilterTest.php create mode 100644 tests/ApiPlatform/OrganizationsTest.php create mode 100644 tests/ApiPlatform/PaginationTest.php create mode 100644 tests/ApiPlatform/TagsFilterTest.php create mode 100644 tests/ApiPlatform/Trait/GetItemTestTrait.php create mode 100644 tests/ApiPlatform/VocabulariesFilterTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 789fd8e..c6f1b2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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-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) diff --git a/tests/ApiPlatform/AbstractApiTestCase.php b/tests/ApiPlatform/AbstractApiTestCase.php index fa0ed9c..10d1342 100644 --- a/tests/ApiPlatform/AbstractApiTestCase.php +++ b/tests/ApiPlatform/AbstractApiTestCase.php @@ -12,6 +12,12 @@ abstract class AbstractApiTestCase extends ApiTestCase protected static string $requestPath; + protected static string $resourceClass; + + protected static int|string $itemId; + + protected static int|string $unknownItemId = 99999; + protected static function createAuthenticatedClient(): Client { return static::createClient(defaultOptions: [ diff --git a/tests/ApiPlatform/AuthenticationTest.php b/tests/ApiPlatform/AuthenticationTest.php new file mode 100644 index 0000000..fe773cf --- /dev/null +++ b/tests/ApiPlatform/AuthenticationTest.php @@ -0,0 +1,61 @@ +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/DailyOccurrencesFilterTest.php b/tests/ApiPlatform/DailyOccurrencesFilterTest.php new file mode 100644 index 0000000..79262d5 --- /dev/null +++ b/tests/ApiPlatform/DailyOccurrencesFilterTest.php @@ -0,0 +1,67 @@ +get($query); + + $data = $response->toArray(); + $this->assertArrayHasKey('hydra:member', $data, $message); + $this->assertCount($expectedCount, $data['hydra:member'], $message); + } + + public static function getDailyOccurrencesProvider(): iterable + { + // Unfiltered. + yield [[], 3]; + + // BooleanFilter on event.publicAccess (DailyOccurrence-specific). + yield [ + ['event.publicAccess' => 'true'], + 3, + 'All fixture events have publicAccess=true', + ]; + + // DateRangeFilter on start. + yield [ + ['start[between]' => static::formatDateTime('2024-12-01').'..'.static::formatDateTime('2024-12-31')], + 2, + ]; + + // MatchFilter on event.title (token-based — see OccurrencesFilterTest comment). + yield [ + ['event.title' => 'ITKDev'], + 3, + ]; + + // IdFilter on event.organizer.entityId. + yield [ + ['event.organizer.entityId' => 9], + 3, + ]; + + // TagFilter on event.tags. + yield [ + ['event.tags' => 'itkdev'], + 1, + ]; + + yield [ + ['event.tags' => 'unknown-tag'], + 0, + ]; + } +} diff --git a/tests/ApiPlatform/DailyOccurrencesTest.php b/tests/ApiPlatform/DailyOccurrencesTest.php new file mode 100644 index 0000000..6799229 --- /dev/null +++ b/tests/ApiPlatform/DailyOccurrencesTest.php @@ -0,0 +1,19 @@ + implode('..', [ - (new \DateTimeImmutable('2001-01-01'))->format(\DateTimeImmutable::ATOM), - (new \DateTimeImmutable('2100-01-01'))->format(\DateTimeImmutable::ATOM), - ])], + ['occurrences.start[between]' => static::formatDateTime('2001-01-01').'..'.static::formatDateTime('2100-01-01')], 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), - ])], + ['occurrences.start[between]' => static::formatDateTime('2026-01-01').'..'.static::formatDateTime('2026-12-31')], + 1, + 'Events in 2026', + ]; + + // Test DateRangeFilter on updated (default operator: gte). + yield [ + ['updated' => static::formatDateTime('2024-01-01')], 3, - 'Events in 21st century', + 'Events updated on or after 2024-01-01', ]; yield [ - ['occurrences.start[between]' => static::formatDateTime('2026-01-01').'..'.static::formatDateTime('2026-12-31')], - 1, - 'Events in 2026', + ['updated[gte]' => static::formatDateTime('2100-01-01')], + 0, + 'No events updated after 2100', ]; // Test IdFilter. diff --git a/tests/ApiPlatform/EventsTest.php b/tests/ApiPlatform/EventsTest.php index 8c2095f..4f6840e 100644 --- a/tests/ApiPlatform/EventsTest.php +++ b/tests/ApiPlatform/EventsTest.php @@ -2,7 +2,9 @@ namespace App\Tests\ApiPlatform; +use App\Api\Dto\Event; use App\Tests\ApiPlatform\Trait\GetEntitiesTestTrait; +use App\Tests\ApiPlatform\Trait\GetItemTestTrait; /** * Test that we can call the API. @@ -10,6 +12,11 @@ class EventsTest extends AbstractApiTestCase { use GetEntitiesTestTrait; + use GetItemTestTrait; protected static string $requestPath = '/api/v2/events'; + + protected static string $resourceClass = Event::class; + + protected static int|string $itemId = 7; } diff --git a/tests/ApiPlatform/FilterErrorTest.php b/tests/ApiPlatform/FilterErrorTest.php new file mode 100644 index 0000000..9ffe865 --- /dev/null +++ b/tests/ApiPlatform/FilterErrorTest.php @@ -0,0 +1,66 @@ + 400, or throw + // ApiPlatform\Exception\InvalidArgumentException. Once fixed, remove + // this skip — the assertions below already encode the desired contract. + $this->markTestSkipped('Known contract bug: malformed date range returns 5xx. See TODO.'); + + // @phpstan-ignore-next-line deadCode.unreachable + $response = $this->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('Invalid date range'). + 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', + ]; + + 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..8eb5144 --- /dev/null +++ b/tests/ApiPlatform/LocationsFilterTest.php @@ -0,0 +1,40 @@ +get($query); + + $data = $response->toArray(); + $this->assertArrayHasKey('hydra:member', $data, $message); + $this->assertCount($expectedCount, $data['hydra:member'], $message); + } + + public static function getLocationsProvider(): iterable + { + // Unfiltered. + yield [[], 2]; + + // MatchFilter on name. + yield [['name' => 'ITK Development'], 1, 'Location named "ITK Development"']; + yield [['name' => 'Somewhere'], 1, 'Location named "Somewhere"']; + yield [['name' => 'nonexistent'], 0]; + + // MatchFilter on postalCode. + yield [['postalCode' => '8000'], 1, 'Location with postal code 8000']; + yield [['postalCode' => '9999'], 0]; + } +} diff --git a/tests/ApiPlatform/LocationsTest.php b/tests/ApiPlatform/LocationsTest.php new file mode 100644 index 0000000..45d1c5b --- /dev/null +++ b/tests/ApiPlatform/LocationsTest.php @@ -0,0 +1,19 @@ +get($query); + + $data = $response->toArray(); + $this->assertArrayHasKey('hydra:member', $data, $message); + $this->assertCount($expectedCount, $data['hydra:member'], $message); + } + + public static function getOccurrencesProvider(): iterable + { + // Unfiltered. + yield [[], 3]; + + // DateRangeFilter on start. + yield [ + ['start[between]' => static::formatDateTime('2024-12-01').'..'.static::formatDateTime('2024-12-31')], + 2, + 'Occurrences starting in December 2024', + ]; + + yield [ + ['start[between]' => static::formatDateTime('2024-11-01').'..'.static::formatDateTime('2024-11-30')], + 1, + 'Occurrences starting in November 2024', + ]; + + // DateRangeFilter on end. + yield [ + ['end[between]' => static::formatDateTime('2024-12-07').'..'.static::formatDateTime('2024-12-09')], + 2, + 'Occurrences ending around 2024-12-08', + ]; + + // MatchFilter on event.title. MatchFilter is token-based (ES word match), + // so all three fixture events contain the shared tokens "ITKDev/test/event". + // To narrow we'd need a token unique to a single record. + yield [ + ['event.title' => 'ITKDev'], + 3, + 'All fixture events have "ITKDev" in the title', + ]; + + yield [ + ['event.title' => 'totallyuniqueword'], + 0, + 'Token absent from all titles returns no occurrences', + ]; + + // MatchFilter on event.organizer.name. + yield [ + ['event.organizer.name' => 'ITKDev'], + 3, + 'Occurrences organized by ITKDev', + ]; + + // MatchFilter on event.location.name. + yield [ + ['event.location.name' => 'ITK Development'], + 3, + 'Occurrences at ITK Development', + ]; + + // IdFilter on event.organizer.entityId. + yield [ + ['event.organizer.entityId' => 9], + 3, + ]; + + yield [ + ['event.organizer.entityId' => 99], + 0, + ]; + + // IdFilter on event.location.entityId. + yield [ + ['event.location.entityId' => 4], + 3, + ]; + + // TagFilter on event.tags. + yield [ + ['event.tags' => 'itkdev'], + 1, + 'Occurrences for events tagged "itkdev"', + ]; + + yield [ + ['event.tags' => 'unknown-tag'], + 0, + ]; + } +} diff --git a/tests/ApiPlatform/OccurrencesTest.php b/tests/ApiPlatform/OccurrencesTest.php new file mode 100644 index 0000000..9e66408 --- /dev/null +++ b/tests/ApiPlatform/OccurrencesTest.php @@ -0,0 +1,19 @@ +get($query); + + $data = $response->toArray(); + $this->assertArrayHasKey('hydra:member', $data, $message); + $this->assertCount($expectedCount, $data['hydra:member'], $message); + } + + public static function getOrganizationsProvider(): iterable + { + // Unfiltered. + yield [[], 3]; + + // MatchFilter on name. + yield [['name' => 'ITKDev'], 1, 'Organization named "ITKDev"']; + yield [['name' => 'Dokk1'], 1]; + yield [['name' => 'Aakb'], 1]; + yield [['name' => 'nonexistent'], 0]; + } +} diff --git a/tests/ApiPlatform/OrganizationsTest.php b/tests/ApiPlatform/OrganizationsTest.php new file mode 100644 index 0000000..f956e73 --- /dev/null +++ b/tests/ApiPlatform/OrganizationsTest.php @@ -0,0 +1,19 @@ +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 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/TagsFilterTest.php b/tests/ApiPlatform/TagsFilterTest.php new file mode 100644 index 0000000..55353bc --- /dev/null +++ b/tests/ApiPlatform/TagsFilterTest.php @@ -0,0 +1,40 @@ +get($query); + + $data = $response->toArray(); + $this->assertArrayHasKey('hydra:member', $data, $message); + $this->assertCount($expectedCount, $data['hydra:member'], $message); + } + + public static function getTagsProvider(): iterable + { + // Unfiltered. + yield [[], 5]; + + // MatchFilter on name. + yield [['name' => 'aros'], 1, 'Tag named "aros"']; + yield [['name' => 'Koncert'], 1]; + yield [['name' => 'nonexistent'], 0]; + + // MatchFilter on vocabulary. + yield [['vocabulary' => 'aarhusguiden'], 5, 'All fixture tags belong to the aarhusguiden vocabulary']; + yield [['vocabulary' => 'feeds'], 0]; + } +} diff --git a/tests/ApiPlatform/TagsTest.php b/tests/ApiPlatform/TagsTest.php index 7307969..6acd791 100644 --- a/tests/ApiPlatform/TagsTest.php +++ b/tests/ApiPlatform/TagsTest.php @@ -2,7 +2,9 @@ namespace App\Tests\ApiPlatform; +use App\Api\Dto\Tag; use App\Tests\ApiPlatform\Trait\GetEntitiesTestTrait; +use App\Tests\ApiPlatform\Trait\GetItemTestTrait; /** * Test that we can call the API. @@ -10,6 +12,13 @@ class TagsTest extends AbstractApiTestCase { use GetEntitiesTestTrait; + use GetItemTestTrait; protected static string $requestPath = '/api/v2/tags'; + + protected static string $resourceClass = Tag::class; + + protected static int|string $itemId = 'aros'; + + protected static int|string $unknownItemId = 'no-such-tag-slug'; } diff --git a/tests/ApiPlatform/Trait/GetEntitiesTestTrait.php b/tests/ApiPlatform/Trait/GetEntitiesTestTrait.php index 7bbe908..5699d0b 100644 --- a/tests/ApiPlatform/Trait/GetEntitiesTestTrait.php +++ b/tests/ApiPlatform/Trait/GetEntitiesTestTrait.php @@ -19,5 +19,13 @@ public function testGetEntities(): void $this->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)) { + $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..6c634ff --- /dev/null +++ b/tests/ApiPlatform/VocabulariesFilterTest.php @@ -0,0 +1,40 @@ +get($query); + + $data = $response->toArray(); + $this->assertArrayHasKey('hydra:member', $data, $message); + $this->assertCount($expectedCount, $data['hydra:member'], $message); + } + + public static function getVocabulariesProvider(): iterable + { + // Unfiltered. + yield [[], 2]; + + // MatchFilter on name. + yield [['name' => 'aarhusguiden'], 1]; + yield [['name' => 'feeds'], 1]; + yield [['name' => 'nonexistent'], 0]; + + // MatchFilter on tags. + yield [['tags' => 'aros'], 1, 'aarhusguiden vocabulary contains the "aros" tag']; + yield [['tags' => 'unknown-tag'], 0]; + } +} diff --git a/tests/ApiPlatform/VocabulariesTest.php b/tests/ApiPlatform/VocabulariesTest.php index 99b145f..973124f 100644 --- a/tests/ApiPlatform/VocabulariesTest.php +++ b/tests/ApiPlatform/VocabulariesTest.php @@ -2,7 +2,9 @@ namespace App\Tests\ApiPlatform; +use App\Api\Dto\Vocabulary; use App\Tests\ApiPlatform\Trait\GetEntitiesTestTrait; +use App\Tests\ApiPlatform\Trait\GetItemTestTrait; /** * Test that we can call the API. @@ -10,6 +12,13 @@ class VocabulariesTest extends AbstractApiTestCase { use GetEntitiesTestTrait; + use GetItemTestTrait; protected static string $requestPath = '/api/v2/vocabularies'; + + protected static string $resourceClass = Vocabulary::class; + + protected static int|string $itemId = 'aarhusguiden'; + + protected static int|string $unknownItemId = 'no-such-vocabulary-slug'; } From 45414cae28c86f2fa479e3f8e429deaf2f48fcca Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 7 Jul 2026 13:46:56 +0200 Subject: [PATCH 11/63] test: add JSON-LD contract tests to guard against API Platform BC changes Lock the response contract independently of API Platform's self-generated schema, so the upcoming upgrade cannot silently change it: - Collection envelope + exact per-resource member field sets. - Item shape, including the current asymmetry: Event/Occurrence/ DailyOccurrence/Location/Organization items return a hydra:Collection wrapper of one, while Tag/Vocabulary return a flat item. - Error shape (hydra:Error for 401/404). - Content negotiation: only application/ld+json is accepted (json/html -> 406). - Datetime serialization (ISO 8601 with timezone offset). --- .../Contract/CollectionContractTest.php | 92 +++++++++++++++++++ .../ContentNegotiationContractTest.php | 52 +++++++++++ .../Contract/DateFormatContractTest.php | 35 +++++++ .../Contract/ErrorContractTest.php | 48 ++++++++++ .../ApiPlatform/Contract/ItemContractTest.php | 64 +++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 tests/ApiPlatform/Contract/CollectionContractTest.php create mode 100644 tests/ApiPlatform/Contract/ContentNegotiationContractTest.php create mode 100644 tests/ApiPlatform/Contract/DateFormatContractTest.php create mode 100644 tests/ApiPlatform/Contract/ErrorContractTest.php create mode 100644 tests/ApiPlatform/Contract/ItemContractTest.php diff --git a/tests/ApiPlatform/Contract/CollectionContractTest.php b/tests/ApiPlatform/Contract/CollectionContractTest.php new file mode 100644 index 0000000..2263944 --- /dev/null +++ b/tests/ApiPlatform/Contract/CollectionContractTest.php @@ -0,0 +1,92 @@ +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..c3ed2c5 --- /dev/null +++ b/tests/ApiPlatform/Contract/ContentNegotiationContractTest.php @@ -0,0 +1,52 @@ +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/DateFormatContractTest.php b/tests/ApiPlatform/Contract/DateFormatContractTest.php new file mode 100644 index 0000000..5b25fef --- /dev/null +++ b/tests/ApiPlatform/Contract/DateFormatContractTest.php @@ -0,0 +1,35 @@ +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..a8fdcde --- /dev/null +++ b/tests/ApiPlatform/Contract/ErrorContractTest.php @@ -0,0 +1,48 @@ +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('title', $data); + $this->assertArrayHasKey('detail', $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']); + } +} diff --git a/tests/ApiPlatform/Contract/ItemContractTest.php b/tests/ApiPlatform/Contract/ItemContractTest.php new file mode 100644 index 0000000..ec1a8de --- /dev/null +++ b/tests/ApiPlatform/Contract/ItemContractTest.php @@ -0,0 +1,64 @@ +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']; + } +} From afb110d08e9a4c4f2ae013f696bfe951eb5d16fe Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 7 Jul 2026 14:21:09 +0200 Subject: [PATCH 12/63] docs: note item-as-collection quirk as a TODO for API versioning --- CHANGELOG.md | 2 ++ README.md | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 789fd8e..2f48877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [PR-34](https://github.com/itk-dev/event-database-api/pull/34) + Document the item-as-collection quirk in the README as a TODO for API versioning - [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) diff --git a/README.md b/README.md index 409440e..f46ec53 100644 --- a/README.md +++ b/README.md @@ -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 From 1c10945a7c2528869fc341ce23fb439b0bae5e3f Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 7 Jul 2026 14:23:37 +0200 Subject: [PATCH 13/63] docs: correct README fixtures wording (Elasticsearch, not Doctrine) --- CHANGELOG.md | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f48877..264c320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] - [PR-34](https://github.com/itk-dev/event-database-api/pull/34) - Document the item-as-collection quirk in the README as a TODO for API versioning + Document the item-as-collection quirk as a versioning TODO and fix the README fixtures wording (no Doctrine) - [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) diff --git a/README.md b/README.md index f46ec53..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 From 1e39e0caceb8757aee14b0af76fc333735b82872 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 08:57:31 +0200 Subject: [PATCH 14/63] test: apply production-parity Elasticsearch mappings in the test harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture loader created indexes with no mappings, so Elasticsearch dynamic mapping (analysed text + .keyword subfields) drove filter behaviour. Production indexes (event-database-imports) use `dynamic: strict` with explicit types and no normalizer, so several filter tests passed for semantics production does not have — most starkly tag filtering (`?tags=itkdev` matched under dynamic mapping but `tags` is an exact, case-sensitive `keyword` in production). - Add production-parity mappings at tests/resources/mappings/*.json (dynamic: strict), hand-kept copies of the importer's src/Model/Indexing/Mappings. - FixtureLoader::createIndex() applies the mapping and fails loudly if missing; drop the now-obsolete `ignore_error` from the fixture Taskfile targets. - Reconcile the tag filter expectations to keyword semantics (ITKDev→2, itkdev→0, for-boern→1 whole-value, boern→0); only these cases needed changing — the rest of the suite was already production-accurate. - Index-contract Stop hook now also watches tests/resources/mappings/; the filter-provider-reviewer brief checks the mapped field type. --- .claude/agents/filter-provider-reviewer.md | 12 +- CHANGELOG.md | 2 + CLAUDE.md | 6 + Taskfile.yml | 4 - config/services.yaml | 4 + scripts/claude-hook-check-index-contract.sh | 20 +- src/Fixtures/FixtureLoader.php | 43 +- .../DailyOccurrencesFilterTest.php | 4 +- tests/ApiPlatform/EventsFilterTest.php | 51 +-- tests/ApiPlatform/OccurrencesFilterTest.php | 11 +- .../resources/mappings/daily_occurrences.json | 419 ++++++++++++++++++ tests/resources/mappings/events.json | 378 ++++++++++++++++ tests/resources/mappings/locations.json | 103 +++++ tests/resources/mappings/occurrences.json | 419 ++++++++++++++++++ tests/resources/mappings/organizations.json | 49 ++ tests/resources/mappings/tags.json | 32 ++ tests/resources/mappings/vocabularies.json | 39 ++ 17 files changed, 1533 insertions(+), 63 deletions(-) create mode 100644 tests/resources/mappings/daily_occurrences.json create mode 100644 tests/resources/mappings/events.json create mode 100644 tests/resources/mappings/locations.json create mode 100644 tests/resources/mappings/occurrences.json create mode 100644 tests/resources/mappings/organizations.json create mode 100644 tests/resources/mappings/tags.json create mode 100644 tests/resources/mappings/vocabularies.json diff --git a/.claude/agents/filter-provider-reviewer.md b/.claude/agents/filter-provider-reviewer.md index 169ae04..2ca516a 100644 --- a/.claude/agents/filter-provider-reviewer.md +++ b/.claude/agents/filter-provider-reviewer.md @@ -18,10 +18,14 @@ Focus your review on: `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.** Field names/types referenced in the DSL must match what `event-database-imports` - writes (mappings live only there — this repo has none). A typo'd or renamed field yields empty/incorrect results - silently, not an error. Flag any field reference you can't confirm against the index contract and recommend - verifying against a live mapping (`docker compose exec -T phpfpm curl -s http://elasticsearch:9200//_mapping`). +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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bbee56..f11eddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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-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) diff --git a/CLAUDE.md b/CLAUDE.md index 7215c45..c1ae5a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,12 @@ Tests hit a real Elasticsearch (no mocking) — see `tests/ApiPlatform/AbstractA 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 on drift). + ### Lint / static analysis ```shell diff --git a/Taskfile.yml b/Taskfile.yml index db67805..f4c0a9a 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" 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/scripts/claude-hook-check-index-contract.sh b/scripts/claude-hook-check-index-contract.sh index deb2a98..f7aa402 100755 --- a/scripts/claude-hook-check-index-contract.sh +++ b/scripts/claude-hook-check-index-contract.sh @@ -4,23 +4,25 @@ # 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 -# only the index-name enum; the document mappings live solely in the importer. -# See CLAUDE.md -> "Works with event-database-imports". +# 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' || exit 0 +echo "$CHANGED" | grep -qE '^(src/Model/IndexName\.php|tests/resources/mappings/)' || exit 0 cat >&2 <<'MSG' -WARN: the Elasticsearch index-name enum changed (src/Model/IndexName.php). - event-database-imports writes these indices and duplicates the contract by - hand in its src/Model/Indexing/IndexNames.php. An index name that does not - match what the importer creates (and populates behind the queried alias) - yields an empty or failing API resource. Coordinate the change with - event-database-imports before deploying. +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/src/Fixtures/FixtureLoader.php b/src/Fixtures/FixtureLoader.php index 8a66139..73814a7 100644 --- a/src/Fixtures/FixtureLoader.php +++ b/src/Fixtures/FixtureLoader.php @@ -22,6 +22,7 @@ public function __construct( private readonly HttpClientInterface $httpClient, private readonly IndexInterface $index, private readonly Client $client, + private readonly string $mappingsDir, ) { } @@ -140,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/tests/ApiPlatform/DailyOccurrencesFilterTest.php b/tests/ApiPlatform/DailyOccurrencesFilterTest.php index 79262d5..e248b87 100644 --- a/tests/ApiPlatform/DailyOccurrencesFilterTest.php +++ b/tests/ApiPlatform/DailyOccurrencesFilterTest.php @@ -53,9 +53,9 @@ public static function getDailyOccurrencesProvider(): iterable 3, ]; - // TagFilter on event.tags. + // TagFilter on event.tags — keyword field, exact/case-sensitive match. yield [ - ['event.tags' => 'itkdev'], + ['event.tags' => 'ITKDev'], 1, ]; diff --git a/tests/ApiPlatform/EventsFilterTest.php b/tests/ApiPlatform/EventsFilterTest.php index 42683c0..947c946 100644 --- a/tests/ApiPlatform/EventsFilterTest.php +++ b/tests/ApiPlatform/EventsFilterTest.php @@ -90,55 +90,52 @@ public static function getEventsProvider(): iterable 0, ]; - // Test TagFilter. + // Test 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 [ - ['tags' => 'itkdev'], + ['tags' => 'ITKDev'], 2, - 'Events tagged with "itkdev"', + 'Events tagged with "ITKDev" (exact keyword match)', ]; yield [ - ['tags' => 'itkdevelopment'], + ['tags' => 'itkdev'], 0, - 'Events tagged with "itkdevelopment"', + 'Tag matching is case-sensitive: "itkdev" does not match "ITKDev"', ]; - // @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"', - // ]; + yield [ + ['tags' => 'aros'], + 3, + 'All fixture events are tagged "aros"', + ]; - // @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'], + ['tags' => 'for-boern'], 1, - 'Events tagged with "boern"', + 'A hyphenated tag matches as a whole value, not by token', ]; yield [ - ['tags' => 'for'], - 2, - 'Events tagged with "for"', + ['tags' => 'boern'], + 0, + 'No substring/token match on a keyword field', + ]; + + yield [ + ['tags' => 'itkdevelopment'], + 0, + 'Events tagged with "itkdevelopment"', ]; // Combined filters. yield [ [ 'occurrences.start[between]' => static::formatDateTime('2026-01-01').'..'.static::formatDateTime('2026-12-31'), - 'tags' => 'itkdev', + 'tags' => 'aros', ], 1, - 'Events in 2026 tagged with "itkdev"', + 'Events in 2026 also tagged "aros"', ]; } } diff --git a/tests/ApiPlatform/OccurrencesFilterTest.php b/tests/ApiPlatform/OccurrencesFilterTest.php index 8e657fb..99ffd56 100644 --- a/tests/ApiPlatform/OccurrencesFilterTest.php +++ b/tests/ApiPlatform/OccurrencesFilterTest.php @@ -48,8 +48,9 @@ public static function getOccurrencesProvider(): iterable 'Occurrences ending around 2024-12-08', ]; - // MatchFilter on event.title. MatchFilter is token-based (ES word match), - // so all three fixture events contain the shared tokens "ITKDev/test/event". + // 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 tokens "ITKDev/test/event". // To narrow we'd need a token unique to a single record. yield [ ['event.title' => 'ITKDev'], @@ -94,11 +95,11 @@ public static function getOccurrencesProvider(): iterable 3, ]; - // TagFilter on event.tags. + // TagFilter on event.tags — keyword field, exact/case-sensitive match. yield [ - ['event.tags' => 'itkdev'], + ['event.tags' => 'ITKDev'], 1, - 'Occurrences for events tagged "itkdev"', + 'Occurrences for events tagged "ITKDev"', ]; yield [ diff --git a/tests/resources/mappings/daily_occurrences.json b/tests/resources/mappings/daily_occurrences.json new file mode 100644 index 0000000..9f82202 --- /dev/null +++ b/tests/resources/mappings/daily_occurrences.json @@ -0,0 +1,419 @@ +{ + "dynamic": "strict", + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "start": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "end": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "ticketPriceRange": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "room": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "status": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "event": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "title": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "excerpt": { + "type": "text", + "index_options": "docs", + "index": false, + "norms": false + }, + "description": { + "type": "text", + "index_options": "docs", + "index": true, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "ticketUrl": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "imageUrls": { + "properties": { + "small": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "medium": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "large": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + } + } + }, + "publicAccess": { + "type": "boolean", + "index": true, + "doc_values": false + }, + "created": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "updated": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "tags": { + "type": "keyword", + "index_options": "docs", + "index": true, + "doc_values": false, + "norms": false + }, + "organizer": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "email": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "created": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "updated": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + } + } + }, + "partners": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "email": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "created": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "updated": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + } + } + }, + "location": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "image": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "telephone": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "disabilityAccess": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "mail": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "city": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "street": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "suite": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "region": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "postalCode": { + "type": "keyword", + "index_options": "docs", + "index": true, + "doc_values": false, + "norms": false + }, + "country": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "coordinates": { + "type": "geo_point" + } + } + }, + "occurrences": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "start": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "end": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "ticketPriceRange": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "room": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "status": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + } + } + }, + "dailyOccurrences": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "start": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "end": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "ticketPriceRange": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "room": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "status": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + } + } + } + } + } + } +} diff --git a/tests/resources/mappings/events.json b/tests/resources/mappings/events.json new file mode 100644 index 0000000..23aadda --- /dev/null +++ b/tests/resources/mappings/events.json @@ -0,0 +1,378 @@ +{ + "dynamic": "strict", + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "title": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "excerpt": { + "type": "text", + "index_options": "docs", + "index": false, + "norms": false + }, + "description": { + "type": "text", + "index_options": "docs", + "index": true, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "ticketUrl": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "imageUrls": { + "properties": { + "small": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "medium": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "large": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + } + } + }, + "publicAccess": { + "type": "boolean", + "index": true, + "doc_values": false + }, + "created": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "updated": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "tags": { + "type": "keyword", + "index_options": "docs", + "index": true, + "doc_values": false, + "norms": false + }, + "organizer": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "email": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "created": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "updated": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + } + } + }, + "partners": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "email": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "created": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "updated": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + } + } + }, + "location": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "image": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "telephone": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "disabilityAccess": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "mail": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "city": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "street": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "suite": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "region": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "postalCode": { + "type": "keyword", + "index_options": "docs", + "index": true, + "doc_values": false, + "norms": false + }, + "country": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "coordinates": { + "type": "geo_point" + } + } + }, + "occurrences": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "start": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "end": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "ticketPriceRange": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "room": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "status": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + } + } + }, + "dailyOccurrences": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "start": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "end": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "ticketPriceRange": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "room": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "status": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + } + } + } + } +} diff --git a/tests/resources/mappings/locations.json b/tests/resources/mappings/locations.json new file mode 100644 index 0000000..4073864 --- /dev/null +++ b/tests/resources/mappings/locations.json @@ -0,0 +1,103 @@ +{ + "dynamic": "strict", + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "image": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "telephone": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "disabilityAccess": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "mail": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "city": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "street": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "suite": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "region": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "postalCode": { + "type": "keyword", + "index_options": "docs", + "index": true, + "doc_values": false, + "norms": false + }, + "country": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "coordinates": { + "type": "geo_point" + } + } +} diff --git a/tests/resources/mappings/occurrences.json b/tests/resources/mappings/occurrences.json new file mode 100644 index 0000000..9f82202 --- /dev/null +++ b/tests/resources/mappings/occurrences.json @@ -0,0 +1,419 @@ +{ + "dynamic": "strict", + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "start": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "end": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "ticketPriceRange": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "room": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "status": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "event": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "title": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "excerpt": { + "type": "text", + "index_options": "docs", + "index": false, + "norms": false + }, + "description": { + "type": "text", + "index_options": "docs", + "index": true, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "ticketUrl": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "imageUrls": { + "properties": { + "small": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "medium": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "large": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + } + } + }, + "publicAccess": { + "type": "boolean", + "index": true, + "doc_values": false + }, + "created": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "updated": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "tags": { + "type": "keyword", + "index_options": "docs", + "index": true, + "doc_values": false, + "norms": false + }, + "organizer": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "email": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "created": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "updated": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + } + } + }, + "partners": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "email": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "created": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "updated": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + } + } + }, + "location": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "image": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "telephone": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "disabilityAccess": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "mail": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "city": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "street": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "suite": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "region": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "postalCode": { + "type": "keyword", + "index_options": "docs", + "index": true, + "doc_values": false, + "norms": false + }, + "country": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "coordinates": { + "type": "geo_point" + } + } + }, + "occurrences": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "start": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "end": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "ticketPriceRange": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "room": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "status": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + } + } + }, + "dailyOccurrences": { + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "start": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "end": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "ticketPriceRange": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "room": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "status": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + } + } + } + } + } + } +} diff --git a/tests/resources/mappings/organizations.json b/tests/resources/mappings/organizations.json new file mode 100644 index 0000000..e31d75b --- /dev/null +++ b/tests/resources/mappings/organizations.json @@ -0,0 +1,49 @@ +{ + "dynamic": "strict", + "properties": { + "entityId": { + "type": "integer", + "doc_values": false + }, + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "email": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "url": { + "type": "keyword", + "index_options": "docs", + "index": false, + "doc_values": false, + "norms": false + }, + "created": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + }, + "updated": { + "type": "date", + "format": "yyyy-MM-dd'T'HH:mm:ssz", + "index": false, + "doc_values": true + } + } +} diff --git a/tests/resources/mappings/tags.json b/tests/resources/mappings/tags.json new file mode 100644 index 0000000..23fcf3a --- /dev/null +++ b/tests/resources/mappings/tags.json @@ -0,0 +1,32 @@ +{ + "dynamic": "strict", + "properties": { + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "slug": { + "type": "keyword", + "index_options": "docs", + "index": true, + "norms": false + }, + "vocabulary": { + "type": "keyword", + "index_options": "docs", + "index": true, + "doc_values": false, + "norms": false + } + } +} diff --git a/tests/resources/mappings/vocabularies.json b/tests/resources/mappings/vocabularies.json new file mode 100644 index 0000000..f8ff41b --- /dev/null +++ b/tests/resources/mappings/vocabularies.json @@ -0,0 +1,39 @@ +{ + "dynamic": "strict", + "properties": { + "name": { + "type": "text", + "analyzer": "standard", + "search_analyzer": "standard", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "index_options": "docs", + "index": true, + "norms": false, + "fielddata": true + }, + "slug": { + "type": "keyword", + "index_options": "docs", + "index": true, + "doc_values": true, + "norms": false + }, + "description": { + "type": "text", + "index_options": "docs", + "index": false, + "norms": false + }, + "tags": { + "type": "keyword", + "index_options": "docs", + "index": true, + "doc_values": false, + "norms": false + } + } +} From 33a3d2df454d92f57ba9482497038c70e478a566 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 09:14:27 +0200 Subject: [PATCH 15/63] test: add unit tests for the Elasticsearch filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the custom filters' apply() query DSL and getDescription() descriptors directly, without Elasticsearch or the kernel — the filters are constructed with inert stubs for the AbstractFilter metadata factories (never called once `properties` is set). - MatchFilter/BooleanFilter/TagFilter/IdFilter: exact term/match clauses, boost shape, comma-splitting, single-vs-list return; "0" reaches ES (guarding the strict-guard behaviour). - DateRangeFilter: fallback/explicit operators, `between` → exclusive gt/lt, and the two current error leaks (malformed `between` → \InvalidArgumentException, unknown operator → native \Error) plus the dead `throwOnInvalid` flag, pinned as current behaviour. --- CHANGELOG.md | 2 + .../ElasticSearch/BooleanFilterTest.php | 53 +++++++ .../ElasticSearch/DateRangeFilterTest.php | 139 ++++++++++++++++++ .../ElasticSearch/FilterFactoryMockTrait.php | 33 +++++ .../Api/Filter/ElasticSearch/IdFilterTest.php | 68 +++++++++ .../Filter/ElasticSearch/MatchFilterTest.php | 74 ++++++++++ .../Filter/ElasticSearch/TagFilterTest.php | 52 +++++++ 7 files changed, 421 insertions(+) create mode 100644 tests/Unit/Api/Filter/ElasticSearch/BooleanFilterTest.php create mode 100644 tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php create mode 100644 tests/Unit/Api/Filter/ElasticSearch/FilterFactoryMockTrait.php create mode 100644 tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php create mode 100644 tests/Unit/Api/Filter/ElasticSearch/MatchFilterTest.php create mode 100644 tests/Unit/Api/Filter/ElasticSearch/TagFilterTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bbee56..2fb63a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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-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) diff --git a/tests/Unit/Api/Filter/ElasticSearch/BooleanFilterTest.php b/tests/Unit/Api/Filter/ElasticSearch/BooleanFilterTest.php new file mode 100644 index 0000000..6f5307f --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/BooleanFilterTest.php @@ -0,0 +1,53 @@ +filterDependencies(); + $filter = new BooleanFilter($names, $meta, $resolver, null, ['publicAccess' => null]); + + self::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); + + self::assertArrayHasKey('publicAccess', $description); + self::assertSame('bool', $description['publicAccess']['type']); + self::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..fb8cfff --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php @@ -0,0 +1,139 @@ +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]], + ); + + self::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: pin that a malformed `between` value is currently an uncaught throwable + // (the error-contract fix will turn this into a 4xx). + public function testMalformedBetweenThrows(): void + { + $filter = $this->newFilter( + ['updated' => 'gte'], + ['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => true]], + ); + + // No ".." separator → invalid range. Currently an uncaught \InvalidArgumentException + // (leaks as HTTP 500; the error-contract fix will map this to 4xx). + $this->expectException(\InvalidArgumentException::class); + $filter->apply([], self::RESOURCE, null, ['filters' => ['updated' => ['between' => '2024-01-01T00:00:00+00:00']]]); + } + + // Goal: pin the second, distinct error leak — an unknown operator hits a native + // \Error — so the error-contract fix addresses both paths. + public function testUnknownOperatorThrowsError(): void + { + $filter = $this->newFilter( + ['updated' => 'gte'], + ['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => true]], + ); + + // `updated[foo]=…` resolves DateLimit::{foo} → a native \Error (undefined enum + // case). Distinct from the malformed-between path; also leaks as HTTP 500 today. + $this->expectException(\Error::class); + $filter->apply([], self::RESOURCE, null, ['filters' => ['updated' => ['foo' => 'x']]]); + } + + // Goal: document that the `throwOnInvalid` config flag is dead — invalid input + // throws whether it is true or false. + #[DataProvider('throwOnInvalidProvider')] + public function testThrowOnInvalidConfigIsNotConsulted(bool $throwOnInvalid): void + { + // `throwOnInvalid` is stored in the config but never read: invalid input throws + // regardless of its value. This pins that the flag is currently dead. + $filter = $this->newFilter( + ['updated' => 'gte'], + ['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => $throwOnInvalid]], + ); + + $this->expectException(\InvalidArgumentException::class); + $filter->apply([], self::RESOURCE, null, ['filters' => ['updated' => ['between' => 'no-separator']]]); + } + + public static function throwOnInvalidProvider(): iterable + { + yield 'throwOnInvalid=true' => [true]; + yield 'throwOnInvalid=false' => [false]; + } + + // 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) { + self::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..0664ffa --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php @@ -0,0 +1,68 @@ +filterDependencies(); + $filter = new IdFilter($names, $meta, $resolver, null, $properties); + + self::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); + + self::assertArrayHasKey('organizer.entityId', $description); + self::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..ce5afd7 --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/MatchFilterTest.php @@ -0,0 +1,74 @@ + $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); + + self::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); + + self::assertArrayHasKey('title', $description); + self::assertSame('title', $description['title']['property']); + self::assertSame('string', $description['title']['type']); + self::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..6db4173 --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/TagFilterTest.php @@ -0,0 +1,52 @@ +filterDependencies(); + $filter = new TagFilter($names, $meta, $resolver, null, ['tags' => null]); + + self::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); + + self::assertArrayHasKey('tags', $description); + self::assertTrue($description['tags']['is_collection']); + } +} From 1bb6847ba2e5b75b841752ec3d8817785825c6ff Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 10:04:26 +0200 Subject: [PATCH 16/63] ci: upload test coverage to Codecov The README already links a Codecov badge but CI never uploaded a report. Generate a Clover report from the API test suite and upload it, matching the event-database-imports setup. - Add a `api:test:coverage` Task target running PHPUnit with XDEBUG_MODE=coverage and --coverage-clover=coverage/unit.xml. - Run it in the pr.yaml api-test job and upload via codecov/codecov-action@v5 (org-level CODECOV_TOKEN secret). - Ignore the generated /coverage directory. --- .github/workflows/pr.yaml | 10 +++++++++- .gitignore | 3 +++ CHANGELOG.md | 2 ++ Taskfile.yml | 7 +++++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 1c27f6e..5d2f1f5 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -27,7 +27,15 @@ jobs: - name: Run API tests run: | - task --yes api:test + task --yes api:test:coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./coverage/unit.xml + flags: unittests + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} code-analysis-phpstan: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e34bf05..6b0cf92 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ phpstan.neon ###> 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 3bbee56..42413b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [PR-37](https://github.com/itk-dev/event-database-api/pull/37) + Upload test coverage to Codecov in CI - [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) diff --git a/Taskfile.yml b/Taskfile.yml index db67805..381a462 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -186,6 +186,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: From 7dc5364eb8ebbedb27980fb6c8bb98031f76d010 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 10:06:33 +0200 Subject: [PATCH 17/63] ci: use codecov-action@v7 --- .github/workflows/pr.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 5d2f1f5..4ef94a1 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -30,7 +30,7 @@ jobs: task --yes api:test:coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: files: ./coverage/unit.xml flags: unittests From dee1e2797b62ea44122dabb33c2d8a274012ff8d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 10:10:53 +0200 Subject: [PATCH 18/63] refactor: extract SearchParamsBuilder from ElasticSearchIndex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Elasticsearch `search` request construction (query + pagination + sort) out of ElasticSearchIndex into a pure, dependency-free SearchParamsBuilder so it can be unit-tested without a live Elasticsearch. Behaviour is unchanged — the full ES-backed suite passes byte-identically. - New SearchParamsBuilder with buildParams(); ElasticSearchIndex delegates to it. - Remove the dead non-combined `else` branch in the old buildBody (the loop only runs when filters exist, so the combined path was always taken). - Unit tests: match_all default, bool/must wrapping, list-clause flattening, per-index sort; plus ElasticIndexException::parse400 message parsing. --- CHANGELOG.md | 2 + .../ElasticSearch/ElasticSearchIndex.php | 124 +----------------- .../ElasticSearch/SearchParamsBuilder.php | 100 ++++++++++++++ .../ElasticIndexExceptionTest.php | 37 ++++++ .../ElasticSearch/SearchParamsBuilderTest.php | 108 +++++++++++++++ 5 files changed, 249 insertions(+), 122 deletions(-) create mode 100644 src/Service/ElasticSearch/SearchParamsBuilder.php create mode 100644 tests/Unit/Service/ElasticSearch/ElasticIndexExceptionTest.php create mode 100644 tests/Unit/Service/ElasticSearch/SearchParamsBuilderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bbee56..accb38e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [PR-38](https://github.com/itk-dev/event-database-api/pull/38) + Extract SearchParamsBuilder from ElasticSearchIndex and unit-test the query DSL - [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) diff --git a/src/Service/ElasticSearch/ElasticSearchIndex.php b/src/Service/ElasticSearch/ElasticSearchIndex.php index 0341676..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, ) { } @@ -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 ([] !== $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/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/tests/Unit/Service/ElasticSearch/ElasticIndexExceptionTest.php b/tests/Unit/Service/ElasticSearch/ElasticIndexExceptionTest.php new file mode 100644 index 0000000..271df2e --- /dev/null +++ b/tests/Unit/Service/ElasticSearch/ElasticIndexExceptionTest.php @@ -0,0 +1,37 @@ +". + 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); + + self::assertSame( + 'Parse exception: failed to parse date field [2004-02-12T15:19:21+0000]', + $exception->getMessage(), + ); + self::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); + + self::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..c9eb3da --- /dev/null +++ b/tests/Unit/Service/ElasticSearch/SearchParamsBuilderTest.php @@ -0,0 +1,108 @@ +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); + + self::assertSame(IndexName::Events->value, $params['index']); + self::assertEquals(['match_all' => (object) []], $params['body']['query']); + self::assertSame(5, $params['body']['size']); + self::assertSame(20, $params['body']['from']); + self::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, + ); + + self::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, + ); + + self::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, + ); + + self::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); + + self::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']]; + } +} From 9ce645e118f677a3d3d909af7c37ca7a000838a9 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 10:28:44 +0200 Subject: [PATCH 19/63] ci: update GitHub Actions to latest versions Bump actions/checkout v5 -> v7 across every workflow, and go-task/setup-task v1 -> v2 in the repo-owned workflows (pr.yaml, api-spec.yml). The itkdev- templated workflows are ahead of the current devops_itkdev-docker template (checkout v6); the upstream bump to v7 is pending. --- .github/workflows/api-spec.yml | 8 ++++---- .github/workflows/build_release.yml | 2 +- .github/workflows/changelog.yaml | 2 +- .github/workflows/composer.yaml | 6 +++--- .github/workflows/markdown.yaml | 2 +- .github/workflows/php.yaml | 2 +- .github/workflows/pr.yaml | 8 ++++---- .github/workflows/twig.yaml | 2 +- .github/workflows/yaml.yaml | 2 +- CHANGELOG.md | 2 ++ 10 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.github/workflows/api-spec.yml b/.github/workflows/api-spec.yml index 4b31e7d..a9d5ab4 100644 --- a/.github/workflows/api-spec.yml +++ b/.github/workflows/api-spec.yml @@ -12,12 +12,12 @@ jobs: name: Ensure committed API specification is up to date 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 + - uses: go-task/setup-task@v2 - run: | docker network create frontend @@ -53,13 +53,13 @@ jobs: needs: [api-spec] steps: - name: Check out BASE rev - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ github.base_ref }} path: base - name: Check out HEAD rev - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ github.head_ref }} path: head diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index bb436c0..c31aa4f 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -16,7 +16,7 @@ jobs: APP_ENV: prod steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Composer install 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 3b43b64..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,7 +70,7 @@ jobs: strategy: fail-fast: false steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Create docker network run: | 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..d0eed9b 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -11,10 +11,10 @@ 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 + - uses: go-task/setup-task@v2 - name: Start docker compose setup and install site run: | @@ -33,10 +33,10 @@ jobs: 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 + - uses: go-task/setup-task@v2 - run: | docker network create frontend 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/CHANGELOG.md b/CHANGELOG.md index 574c735..835a35b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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-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) From 4103326c1e76609144fc44b90f0e56de90b47c4f Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 10:47:27 +0200 Subject: [PATCH 20/63] ci: improve the API-spec workflow (adopted from os2display) Adopt the stronger parts of os2display/display-api-service's apispec workflow, keeping our Task-based export and single public/spec.yaml: - Path-filtered trigger so the job only runs when the spec can actually change (src, config, composer, docker-compose, the spec itself). - Least-privilege `permissions:` (contents: read, pull-requests: write) on both jobs. - Cache vendor/ between runs. - Replace openapitools/openapi-diff with oasdiff (maintained, richer markdown changelog); post a single sticky PR comment that updates in place and resolves when the diff clears. --- .github/workflows/api-spec.yml | 155 +++++++++++++++++++++++++-------- CHANGELOG.md | 2 + 2 files changed, 119 insertions(+), 38 deletions(-) diff --git a/.github/workflows/api-spec.yml b/.github/workflows/api-spec.yml index 4b31e7d..c880795 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,12 +18,22 @@ 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 with: fetch-depth: 2 + - name: Cache vendor + uses: actions/cache@v5 + with: + path: vendor + key: vendor-php8.4-${{ hashFiles('composer.lock') }} + restore-keys: vendor-php8.4- + # https://taskfile.dev/installation/#github-actions - uses: go-task/setup-task@v1 @@ -51,64 +69,125 @@ 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 + - name: Checkout uses: actions/checkout@v5 - with: - ref: ${{ github.base_ref }} - path: base - - 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" - - name: Run OpenAPI Changed (from HEAD rev) - id: api-changed + # 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: Detect breaking changes + id: breaking continue-on-error: true - uses: docker://openapitools/openapi-diff:latest + uses: oasdiff/oasdiff-action/breaking@v0.0.44 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 - - 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.0.44 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 - - name: Comment PR with no changes - if: steps.api-changed.outcome == 'success' && steps.api-incompatible.outcome == 'success' - working-directory: head + - name: Determine whether the spec changed + id: changes + run: | + if [ -s changelog.md ] && ! grep -qi 'no 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: | - gh pr comment ${{ github.event.pull_request.number }} --body "✅ **No changes detected in API specification**" --create-if-none --edit-last - - - name: Comment PR with non-breaking changes - if: steps.api-changed.outcome == 'failure' && steps.api-incompatible.outcome == 'success' - working-directory: head + 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 - 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 }} + run: | + { + echo "" + echo "## ⚠️ API specification — non-breaking changes" + echo "" + cat changelog.md + } > 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 - breaking changes + if: steps.breaking.outcome == 'failure' env: GH_TOKEN: ${{ github.token }} + PREV_ID: ${{ steps.prev-comment.outputs.id }} 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 + cat changelog.md + else + echo "The breaking-changes check flagged incompatible changes. Review the workflow logs for details." + 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/CHANGELOG.md b/CHANGELOG.md index 651abee..b552db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [PR-40](https://github.com/itk-dev/event-database-api/pull/40) + Improve the API-spec workflow: path-filtered trigger, least-privilege permissions, vendor cache, and oasdiff breaking-change detection - [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) From 72f8ee626d4ef6cb992bbdaebaca7161ce5e1c8f Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 11:24:45 +0200 Subject: [PATCH 21/63] ci: run the Code Review workflow via docker compose (drop Task) Standardise the pr.yaml jobs on `docker compose` directly instead of Task, and harden against composer's dist-download rate limit, adapted from event-database-imports #98. - Cache vendor/ (actions/cache@v5, keyed on composer.lock) and pre-pull images (docker compose pull --quiet) in both jobs. - Replace `task site:update` with explicit up --wait + composer install; `task fixtures:load:test` with an inline index loop; `task api:test:coverage` and `task code-analysis:phpstan` with direct `docker compose exec` calls. The Taskfile targets remain for local development; only CI stops using them. --- .github/workflows/pr.yaml | 37 ++++++++++++++++++++++++++----------- CHANGELOG.md | 2 ++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 4ef94a1..a1675a7 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -13,21 +13,29 @@ jobs: steps: - uses: actions/checkout@v5 - # 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 + docker compose pull --quiet + docker compose up --detach --wait + 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:coverage + 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 @@ -43,13 +51,20 @@ jobs: steps: - uses: actions/checkout@v5 - # 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: Start docker compose setup and install site + run: | docker network create frontend - task --yes site:update + docker compose pull --quiet + docker compose up --detach --wait + docker compose exec -T phpfpm composer install --no-interaction - name: Run code analysis run: | - task --yes code-analysis:phpstan + docker compose exec -T phpfpm vendor/bin/phpstan analyse --no-progress diff --git a/CHANGELOG.md b/CHANGELOG.md index 651abee..a478acc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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-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) From 107873db39db603542e1467b1d44a85976afc1e8 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 12:00:43 +0200 Subject: [PATCH 22/63] fix: return HTTP 400 for malformed date-range filter input DateRangeFilter threw PHP's native \InvalidArgumentException for a malformed `between` value and a raw \Error for an unknown operator (DateLimit::{unknownKey}). Neither is mapped in exception_to_status, so both leaked as HTTP 500. Resolve the operator via a name lookup and throw ApiPlatform's InvalidArgumentException on both invalid paths, honouring the per-field `throwOnInvalid` flag (skip the clause when false). Fix the stale exception_to_status mapping to the current ApiPlatform\Metadata\Exception\InvalidArgumentException namespace so it maps to 400. Unskip FilterErrorTest and extend it with the unknown-operator and non-date-value (Elasticsearch-rejected) cases. --- config/packages/api_platform.yaml | 2 +- phpstan-baseline.neon | 6 --- .../Filter/ElasticSearch/DateRangeFilter.php | 40 +++++++++++++-- tests/ApiPlatform/FilterErrorTest.php | 35 ++++++++----- .../ElasticSearch/DateRangeFilterTest.php | 51 +++++++++---------- 5 files changed, 83 insertions(+), 51 deletions(-) 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/phpstan-baseline.neon b/phpstan-baseline.neon index 16d805c..28235fd 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,11 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Class constant name in dynamic fetch can only be a string, int\|string\|null given\.$#' - identifier: classConstant.nameType - count: 1 - path: src/Api/Filter/ElasticSearch/DateRangeFilter.php - - message: '#^Offset ''_source'' might not exist on array\|null\.$#' identifier: offsetAccess.notFound diff --git a/src/Api/Filter/ElasticSearch/DateRangeFilter.php b/src/Api/Filter/ElasticSearch/DateRangeFilter.php index cb6a0ef..2dde5b4 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; @@ -85,12 +86,17 @@ 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 (null === $operator) { + return []; + } $value = array_shift($filter); } @@ -99,7 +105,11 @@ private function getElasticSearchQueryRanges(string $property, string|array $fil $values = explode('..', $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/tests/ApiPlatform/FilterErrorTest.php b/tests/ApiPlatform/FilterErrorTest.php index 9ffe865..e16d05b 100644 --- a/tests/ApiPlatform/FilterErrorTest.php +++ b/tests/ApiPlatform/FilterErrorTest.php @@ -9,9 +9,11 @@ * * The DateRangeFilter on the Event, Occurrence, DailyOccurrence, Location and * Organization resources is configured with `throwOnInvalid: true`, so a - * malformed value must surface as a client error (4xx) rather than a 5xx leak, - * and the response body must follow RFC 7807 (problem+json) per - * `rfc_7807_compliant_errors: true` in api_platform.yaml. + * malformed value (unparseable `between`, unknown operator) surfaces as a + * client error (4xx) rather than a 5xx leak: the filter throws ApiPlatform's + * InvalidArgumentException, mapped to HTTP 400 via `exception_to_status`. A + * non-date value passes the filter but is rejected by Elasticsearch, coming + * back as an ElasticIndexException (also mapped to 400). */ class FilterErrorTest extends AbstractApiTestCase { @@ -20,15 +22,6 @@ class FilterErrorTest extends AbstractApiTestCase #[DataProvider('invalidDateRangeProvider')] public function testInvalidDateRangeReturnsClientError(string $path, array $query, string $message): void { - // TODO: DateRangeFilter::getElasticSearchQueryRanges() throws PHP's - // native \InvalidArgumentException, which is not in api_platform.yaml's - // exception_to_status map, so the framework returns 500 instead of 400. - // Either map \InvalidArgumentException -> 400, or throw - // ApiPlatform\Exception\InvalidArgumentException. Once fixed, remove - // this skip — the assertions below already encode the desired contract. - $this->markTestSkipped('Known contract bug: malformed date range returns 5xx. See TODO.'); - - // @phpstan-ignore-next-line deadCode.unreachable $response = $this->get($query, $path); $statusCode = $response->getStatusCode(); $this->assertGreaterThanOrEqual(400, $statusCode, $message.': '.$statusCode); @@ -37,7 +30,7 @@ public function testInvalidDateRangeReturnsClientError(string $path, array $quer public static function invalidDateRangeProvider(): iterable { - // Missing '..' separator → DateRangeFilter throws \InvalidArgumentException('Invalid date range'). + // Missing '..' separator → DateRangeFilter throws InvalidArgumentException. yield 'events: missing separator' => [ '/api/v2/events', ['occurrences.start[between]' => '2024-01-01T00:00:00+00:00'], @@ -51,6 +44,22 @@ public static function invalidDateRangeProvider(): iterable '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'], diff --git a/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php b/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php index fb8cfff..78cc677 100644 --- a/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php +++ b/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php @@ -2,16 +2,19 @@ namespace App\Tests\Unit\Api\Filter\ElasticSearch; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use App\Api\Filter\ElasticSearch\DateRangeFilter; use App\Model\DateLimit; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** - * Pins DateRangeFilter::apply() query DSL and its (currently un-mapped) error - * behaviour. `between` produces exclusive `gt`/`lt` bounds — a consumer-visible - * semantic. The malformed-input and unknown-operator cases document current - * behaviour: both surface as uncaught throwables (they leak as HTTP 500 today). + * Pins DateRangeFilter::apply() query DSL and its error behaviour. `between` + * produces exclusive `gt`/`lt` bounds — a consumer-visible semantic. Malformed + * and unknown-operator input, when the filter is configured with + * `throwOnInvalid: true`, throws ApiPlatform's InvalidArgumentException — mapped + * to HTTP 400 via `exception_to_status` — rather than leaking a native throwable + * as a 500. With `throwOnInvalid: false` the clause is skipped instead. */ class DateRangeFilterTest extends TestCase { @@ -69,8 +72,8 @@ public static function applyProvider(): iterable ]; } - // Goal: pin that a malformed `between` value is currently an uncaught throwable - // (the error-contract fix will turn this into a 4xx). + // 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( @@ -78,47 +81,41 @@ public function testMalformedBetweenThrows(): void ['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => true]], ); - // No ".." separator → invalid range. Currently an uncaught \InvalidArgumentException - // (leaks as HTTP 500; the error-contract fix will map this to 4xx). - $this->expectException(\InvalidArgumentException::class); + // No ".." separator → invalid range. + $this->expectException(InvalidArgumentException::class); $filter->apply([], self::RESOURCE, null, ['filters' => ['updated' => ['between' => '2024-01-01T00:00:00+00:00']]]); } - // Goal: pin the second, distinct error leak — an unknown operator hits a native - // \Error — so the error-contract fix addresses both paths. - public function testUnknownOperatorThrowsError(): void + // 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]], ); - // `updated[foo]=…` resolves DateLimit::{foo} → a native \Error (undefined enum - // case). Distinct from the malformed-between path; also leaks as HTTP 500 today. - $this->expectException(\Error::class); + $this->expectException(InvalidArgumentException::class); $filter->apply([], self::RESOURCE, null, ['filters' => ['updated' => ['foo' => 'x']]]); } - // Goal: document that the `throwOnInvalid` config flag is dead — invalid input - // throws whether it is true or false. - #[DataProvider('throwOnInvalidProvider')] - public function testThrowOnInvalidConfigIsNotConsulted(bool $throwOnInvalid): void + // 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 { - // `throwOnInvalid` is stored in the config but never read: invalid input throws - // regardless of its value. This pins that the flag is currently dead. $filter = $this->newFilter( ['updated' => 'gte'], - ['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => $throwOnInvalid]], + ['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => false]], ); - $this->expectException(\InvalidArgumentException::class); - $filter->apply([], self::RESOURCE, null, ['filters' => ['updated' => ['between' => 'no-separator']]]); + self::assertSame([], $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); } - public static function throwOnInvalidProvider(): iterable + public static function invalidInputProvider(): iterable { - yield 'throwOnInvalid=true' => [true]; - yield 'throwOnInvalid=false' => [false]; + yield 'malformed between' => [['updated' => ['between' => 'no-separator']]]; + yield 'unknown operator' => [['updated' => ['foo' => 'x']]]; } // Goal: getDescription() advertises the default parameter plus every From 2550dd3c9a44689a99eda79e1ef159f5ad20e1e7 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 12:02:07 +0200 Subject: [PATCH 23/63] docs: add CHANGELOG entry for date-range filter error contract fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d403998..6aefa07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) From c6ac73ce70183be209e43248e0cc9990e51fc87d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 12:50:59 +0200 Subject: [PATCH 24/63] test: assert filter identities, sort order, and pagination edges Convert the seven *FilterTest suites from count-based to identity-based assertions via a new AbstractApiTestCase::assertMemberIds() helper, so a filter returning the wrong records (not merely the wrong number) is caught. Add SortTest to pin ElasticSearchIndex::getSort() (events by title.keyword, occurrence endpoints by start, organizations by name.keyword) and pagination-edge cases: out-of-range page, totalItems under a filter, and single-item-page slice stability. Pin the non-nested occurrences semantic (F7): an event matches occurrences.start and occurrences.end bounds satisfied by different occurrences, so a future switch to a `nested` mapping is a conscious BC decision. --- tests/ApiPlatform/AbstractApiTestCase.php | 30 ++++ .../DailyOccurrencesFilterTest.php | 47 ++---- tests/ApiPlatform/EventsFilterTest.php | 148 +++++++----------- tests/ApiPlatform/LocationsFilterTest.php | 30 ++-- tests/ApiPlatform/OccurrencesFilterTest.php | 101 ++++-------- tests/ApiPlatform/OrganizationsFilterTest.php | 26 ++- tests/ApiPlatform/PaginationTest.php | 34 ++++ tests/ApiPlatform/SortTest.php | 57 +++++++ tests/ApiPlatform/TagsFilterTest.php | 33 ++-- tests/ApiPlatform/VocabulariesFilterTest.php | 31 ++-- 10 files changed, 282 insertions(+), 255 deletions(-) create mode 100644 tests/ApiPlatform/SortTest.php diff --git a/tests/ApiPlatform/AbstractApiTestCase.php b/tests/ApiPlatform/AbstractApiTestCase.php index 10d1342..fbb8b51 100644 --- a/tests/ApiPlatform/AbstractApiTestCase.php +++ b/tests/ApiPlatform/AbstractApiTestCase.php @@ -41,4 +41,34 @@ protected static function formatDateTime(string $datetime): string { return (new \DateTimeImmutable($datetime))->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(); + self::assertArrayHasKey('hydra:member', $data, $message); + + $actual = array_map( + static fn (array $member) => $member[$field] ?? null, + $data['hydra:member'] + ); + + if ($ordered) { + self::assertSame($expected, $actual, $message); + + return; + } + + sort($expected); + sort($actual); + self::assertSame($expected, $actual, $message); + } } diff --git a/tests/ApiPlatform/DailyOccurrencesFilterTest.php b/tests/ApiPlatform/DailyOccurrencesFilterTest.php index e248b87..5956bb7 100644 --- a/tests/ApiPlatform/DailyOccurrencesFilterTest.php +++ b/tests/ApiPlatform/DailyOccurrencesFilterTest.php @@ -5,63 +5,48 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * Test that filtering daily_occurrences work as expected. + * Test that filtering daily_occurrences works as expected. + * + * Assertions pin the exact set of matching `entityId`s. Fixture ids mirror the + * occurrences index: 10/11 belong to event 8, 12 to event 7. */ class DailyOccurrencesFilterTest extends AbstractApiTestCase { protected static string $requestPath = '/api/v2/daily_occurrences'; #[DataProvider('getDailyOccurrencesProvider')] - public function testGetDailyOccurrences(array $query, int $expectedCount, ?string $message = null): void + public function testGetDailyOccurrences(array $query, array $expectedIds, ?string $message = null): void { - $message ??= ''; - $response = $this->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 getDailyOccurrencesProvider(): iterable { - // Unfiltered. - yield [[], 3]; + yield 'unfiltered' => [[], [10, 11, 12]]; // BooleanFilter on event.publicAccess (DailyOccurrence-specific). - yield [ + yield 'event publicAccess=true' => [ ['event.publicAccess' => 'true'], - 3, - 'All fixture events have publicAccess=true', + [10, 11, 12], + 'All daily occurrences belong to public events', ]; // DateRangeFilter on start. - yield [ + yield 'start in December 2024' => [ ['start[between]' => static::formatDateTime('2024-12-01').'..'.static::formatDateTime('2024-12-31')], - 2, + [10, 12], ]; // MatchFilter on event.title (token-based — see OccurrencesFilterTest comment). - yield [ - ['event.title' => 'ITKDev'], - 3, - ]; + yield 'event title token ITKDev' => [['event.title' => 'ITKDev'], [10, 11, 12]]; // IdFilter on event.organizer.entityId. - yield [ - ['event.organizer.entityId' => 9], - 3, - ]; + yield 'event organizer 9' => [['event.organizer.entityId' => 9], [10, 11, 12]]; // TagFilter on event.tags — keyword field, exact/case-sensitive match. - yield [ - ['event.tags' => 'ITKDev'], - 1, - ]; - - yield [ - ['event.tags' => 'unknown-tag'], - 0, - ]; + yield 'event tag ITKDev' => [['event.tags' => 'ITKDev'], [12]]; + yield 'event tag unknown' => [['event.tags' => 'unknown-tag'], []]; } } diff --git a/tests/ApiPlatform/EventsFilterTest.php b/tests/ApiPlatform/EventsFilterTest.php index 947c946..1c55a2f 100644 --- a/tests/ApiPlatform/EventsFilterTest.php +++ b/tests/ApiPlatform/EventsFilterTest.php @@ -5,137 +5,97 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * Test that filtering events work as expected. + * Test that filtering events works as expected. + * + * Assertions pin the exact set of matching event `entityId`s (not just counts), + * so a filter regression that returns the wrong records — not merely the wrong + * number of them — is caught. Fixture ids: 7, 8, 9 (see tests/resources/events.json). */ class EventsFilterTest extends AbstractApiTestCase { protected static string $requestPath = '/api/v2/events'; #[DataProvider('getEventsProvider')] - public function testGetEvents(array $query, int $expectedCount, ?string $message = null): void + public function testGetEvents(array $query, array $expectedIds, ?string $message = null): void { - $message ??= ''; - $response = $this->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, - ]; + yield 'unfiltered' => [[], [7, 8, 9]]; - // Test BooleanFilter. - yield [ - ['publicAccess' => 'true'], - 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 [ - ['publicAccess' => 'false'], - 1, - ]; - - // Test DateRangeFilter on occurrences.start. - yield [ + // DateRangeFilter on occurrences.start. + yield 'occurrences in 21st century' => [ ['occurrences.start[between]' => static::formatDateTime('2001-01-01').'..'.static::formatDateTime('2100-01-01')], - 3, - 'Events in 21st century', + [7, 8, 9], + 'Every fixture event has an occurrence in the 21st century', ]; - - yield [ + yield 'occurrences in 2026' => [ ['occurrences.start[between]' => static::formatDateTime('2026-01-01').'..'.static::formatDateTime('2026-12-31')], - 1, - 'Events in 2026', + [9], + 'Only event 9 has an occurrence in 2026', ]; - // Test DateRangeFilter on updated (default operator: gte). - yield [ + // DateRangeFilter on updated (default operator: gte). + yield 'updated on or after 2024' => [ ['updated' => static::formatDateTime('2024-01-01')], - 3, - 'Events updated on or after 2024-01-01', + [7, 8, 9], + 'All events were updated on or after 2024-01-01', ]; - - yield [ + yield 'updated after 2100' => [ ['updated[gte]' => static::formatDateTime('2100-01-01')], - 0, + [], 'No events updated after 2100', ]; - // Test IdFilter. - yield [ - ['organizer.entityId' => 9], - 2, - ]; - - yield [ - ['organizer.entityId' => 11], - 1, + // 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]' => static::formatDateTime('2024-12-01'), + 'occurrences.end[lte]' => static::formatDateTime('2024-11-30'), + ], + [8], + 'Non-nested occurrences: event 8 matches although no single occurrence satisfies both bounds', ]; - // Test MatchFilter. - yield [ - ['location.name' => 'somewhere'], - 1, - 'An event somewhere', - ]; + // 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]]; - yield [ - ['location.name' => 'Another place'], - 0, - ]; + // 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'], []]; - // Test TagFilter. In production `tags` is a keyword field (see event-database-imports + // 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 [ - ['tags' => 'ITKDev'], - 2, - 'Events tagged with "ITKDev" (exact keyword match)', - ]; - - yield [ - ['tags' => 'itkdev'], - 0, - 'Tag matching is case-sensitive: "itkdev" does not match "ITKDev"', - ]; - - yield [ - ['tags' => 'aros'], - 3, - 'All fixture events are tagged "aros"', - ]; - - yield [ - ['tags' => 'for-boern'], - 1, - 'A hyphenated tag matches as a whole value, not by token', - ]; - - yield [ - ['tags' => 'boern'], - 0, - 'No substring/token match on a keyword field', - ]; - - yield [ - ['tags' => 'itkdevelopment'], - 0, - 'Events tagged with "itkdevelopment"', - ]; + 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' => 'aros', ], - 1, - 'Events in 2026 also tagged "aros"', + [9], + 'Event 9 is the only 2026 event also tagged "aros"', ]; } } diff --git a/tests/ApiPlatform/LocationsFilterTest.php b/tests/ApiPlatform/LocationsFilterTest.php index 8eb5144..087a614 100644 --- a/tests/ApiPlatform/LocationsFilterTest.php +++ b/tests/ApiPlatform/LocationsFilterTest.php @@ -5,36 +5,34 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * Test that filtering locations work as expected. + * Test that filtering locations works as expected. + * + * Assertions pin the exact set of matching `entityId`s. Fixture ids: 4 (ITK + * Development, postal 8000), 5 (Somewhere). See tests/resources/locations.json. */ class LocationsFilterTest extends AbstractApiTestCase { protected static string $requestPath = '/api/v2/locations'; #[DataProvider('getLocationsProvider')] - public function testGetLocations(array $query, int $expectedCount, ?string $message = null): void + public function testGetLocations(array $query, array $expectedIds, ?string $message = null): void { - $message ??= ''; - $response = $this->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 getLocationsProvider(): iterable { - // Unfiltered. - yield [[], 2]; + yield 'unfiltered' => [[], [4, 5]]; - // MatchFilter on name. - yield [['name' => 'ITK Development'], 1, 'Location named "ITK Development"']; - yield [['name' => 'Somewhere'], 1, 'Location named "Somewhere"']; - yield [['name' => 'nonexistent'], 0]; + // 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. - yield [['postalCode' => '8000'], 1, 'Location with postal code 8000']; - yield [['postalCode' => '9999'], 0]; + // 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/OccurrencesFilterTest.php b/tests/ApiPlatform/OccurrencesFilterTest.php index 99ffd56..3c92ec0 100644 --- a/tests/ApiPlatform/OccurrencesFilterTest.php +++ b/tests/ApiPlatform/OccurrencesFilterTest.php @@ -5,106 +5,69 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * Test that filtering occurrences work as expected. + * Test that filtering occurrences works as expected. + * + * Assertions pin the exact set of matching occurrence `entityId`s. Fixture ids: + * 10 (start 2024-12-07, event 8), 11 (start 2024-11-08, event 8), + * 12 (start 2024-12-08, event 7). See tests/resources/occurrences.json. */ class OccurrencesFilterTest extends AbstractApiTestCase { protected static string $requestPath = '/api/v2/occurrences'; #[DataProvider('getOccurrencesProvider')] - public function testGetOccurrences(array $query, int $expectedCount, ?string $message = null): void + public function testGetOccurrences(array $query, array $expectedIds, ?string $message = null): void { - $message ??= ''; - $response = $this->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 getOccurrencesProvider(): iterable { - // Unfiltered. - yield [[], 3]; + yield 'unfiltered' => [[], [10, 11, 12]]; // DateRangeFilter on start. - yield [ + yield 'start in December 2024' => [ ['start[between]' => static::formatDateTime('2024-12-01').'..'.static::formatDateTime('2024-12-31')], - 2, - 'Occurrences starting in December 2024', + [10, 12], + 'Occurrences 10 and 12 start in December 2024', ]; - - yield [ + yield 'start in November 2024' => [ ['start[between]' => static::formatDateTime('2024-11-01').'..'.static::formatDateTime('2024-11-30')], - 1, - 'Occurrences starting in November 2024', + [11], + 'Only occurrence 11 starts in November 2024', ]; // DateRangeFilter on end. - yield [ + yield 'end around 2024-12-08' => [ ['end[between]' => static::formatDateTime('2024-12-07').'..'.static::formatDateTime('2024-12-09')], - 2, - 'Occurrences ending around 2024-12-08', + [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 tokens "ITKDev/test/event". - // To narrow we'd need a token unique to a single record. - yield [ + // match — all three fixture events share the token "ITKDev". + yield 'event title token ITKDev' => [ ['event.title' => 'ITKDev'], - 3, + [10, 11, 12], 'All fixture events have "ITKDev" in the title', ]; + yield 'event title absent token' => [['event.title' => 'totallyuniqueword'], []]; - yield [ - ['event.title' => 'totallyuniqueword'], - 0, - 'Token absent from all titles returns no occurrences', - ]; - - // MatchFilter on event.organizer.name. - yield [ - ['event.organizer.name' => 'ITKDev'], - 3, - 'Occurrences organized by ITKDev', - ]; - - // MatchFilter on event.location.name. - yield [ - ['event.location.name' => 'ITK Development'], - 3, - 'Occurrences at ITK Development', - ]; + // 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. - yield [ - ['event.organizer.entityId' => 9], - 3, - ]; + // 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]]; - yield [ - ['event.organizer.entityId' => 99], - 0, - ]; - - // IdFilter on event.location.entityId. - yield [ - ['event.location.entityId' => 4], - 3, - ]; - - // TagFilter on event.tags — keyword field, exact/case-sensitive match. - yield [ - ['event.tags' => 'ITKDev'], - 1, - 'Occurrences for events tagged "ITKDev"', - ]; - - yield [ - ['event.tags' => 'unknown-tag'], - 0, - ]; + // 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/OrganizationsFilterTest.php b/tests/ApiPlatform/OrganizationsFilterTest.php index 0834092..af4667d 100644 --- a/tests/ApiPlatform/OrganizationsFilterTest.php +++ b/tests/ApiPlatform/OrganizationsFilterTest.php @@ -5,33 +5,31 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * Test that filtering organizations work as expected. + * Test that filtering organizations works as expected. + * + * Assertions pin the exact set of matching `entityId`s. Fixture ids: 9 (ITKDev), + * 10 (Aakb), 11 (Dokk1). See tests/resources/organizations.json. */ class OrganizationsFilterTest extends AbstractApiTestCase { protected static string $requestPath = '/api/v2/organizations'; #[DataProvider('getOrganizationsProvider')] - public function testGetOrganizations(array $query, int $expectedCount, ?string $message = null): void + public function testGetOrganizations(array $query, array $expectedIds, ?string $message = null): void { - $message ??= ''; - $response = $this->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 getOrganizationsProvider(): iterable { - // Unfiltered. - yield [[], 3]; + yield 'unfiltered' => [[], [9, 10, 11]]; - // MatchFilter on name. - yield [['name' => 'ITKDev'], 1, 'Organization named "ITKDev"']; - yield [['name' => 'Dokk1'], 1]; - yield [['name' => 'Aakb'], 1]; - yield [['name' => 'nonexistent'], 0]; + // 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/PaginationTest.php b/tests/ApiPlatform/PaginationTest.php index ee5491f..7360d6b 100644 --- a/tests/ApiPlatform/PaginationTest.php +++ b/tests/ApiPlatform/PaginationTest.php @@ -69,6 +69,40 @@ public function testPageNavigationLinksPresentWhenSliced(): void $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] diff --git a/tests/ApiPlatform/SortTest.php b/tests/ApiPlatform/SortTest.php new file mode 100644 index 0000000..61b9869 --- /dev/null +++ b/tests/ApiPlatform/SortTest.php @@ -0,0 +1,57 @@ +get([], '/api/v2/events')->toArray(); + $titles = array_column($data['hydra:member'], 'title'); + + $sorted = $titles; + sort($sorted, SORT_STRING); + + self::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); + self::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 index 55353bc..cae92a1 100644 --- a/tests/ApiPlatform/TagsFilterTest.php +++ b/tests/ApiPlatform/TagsFilterTest.php @@ -5,36 +5,39 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * Test that filtering tags work as expected. + * Test that filtering tags works as expected. + * + * Tag is a true API Platform resource identified by `slug`, so assertions pin + * the exact set of matching slugs. Fixture slugs: aros, theoceanraceaarhus, + * koncert, for-boern, itkdev (see tests/resources/tags.json). */ class TagsFilterTest extends AbstractApiTestCase { protected static string $requestPath = '/api/v2/tags'; #[DataProvider('getTagsProvider')] - public function testGetTags(array $query, int $expectedCount, ?string $message = null): void + public function testGetTags(array $query, array $expectedSlugs, ?string $message = null): void { - $message ??= ''; - $response = $this->get($query); - $data = $response->toArray(); - $this->assertArrayHasKey('hydra:member', $data, $message); - $this->assertCount($expectedCount, $data['hydra:member'], $message); + $this->assertMemberIds($expectedSlugs, $response, 'slug', message: $message ?? ''); } public static function getTagsProvider(): iterable { - // Unfiltered. - yield [[], 5]; + yield 'unfiltered' => [[], ['aros', 'theoceanraceaarhus', 'koncert', 'for-boern', 'itkdev']]; - // MatchFilter on name. - yield [['name' => 'aros'], 1, 'Tag named "aros"']; - yield [['name' => 'Koncert'], 1]; - yield [['name' => 'nonexistent'], 0]; + // 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'], 5, 'All fixture tags belong to the aarhusguiden vocabulary']; - yield [['vocabulary' => 'feeds'], 0]; + 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/VocabulariesFilterTest.php b/tests/ApiPlatform/VocabulariesFilterTest.php index 6c634ff..a91e2a2 100644 --- a/tests/ApiPlatform/VocabulariesFilterTest.php +++ b/tests/ApiPlatform/VocabulariesFilterTest.php @@ -5,36 +5,35 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * Test that filtering vocabularies work as expected. + * Test that filtering vocabularies works as expected. + * + * Vocabulary is a true API Platform resource identified by `slug`, so assertions + * pin the exact set of matching slugs. Fixture slugs: aarhusguiden, feeds + * (see tests/resources/vocabularies.json). */ class VocabulariesFilterTest extends AbstractApiTestCase { protected static string $requestPath = '/api/v2/vocabularies'; #[DataProvider('getVocabulariesProvider')] - public function testGetVocabularies(array $query, int $expectedCount, ?string $message = null): void + public function testGetVocabularies(array $query, array $expectedSlugs, ?string $message = null): void { - $message ??= ''; - $response = $this->get($query); - $data = $response->toArray(); - $this->assertArrayHasKey('hydra:member', $data, $message); - $this->assertCount($expectedCount, $data['hydra:member'], $message); + $this->assertMemberIds($expectedSlugs, $response, 'slug', message: $message ?? ''); } public static function getVocabulariesProvider(): iterable { - // Unfiltered. - yield [[], 2]; + yield 'unfiltered' => [[], ['aarhusguiden', 'feeds']]; - // MatchFilter on name. - yield [['name' => 'aarhusguiden'], 1]; - yield [['name' => 'feeds'], 1]; - yield [['name' => 'nonexistent'], 0]; + // 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. - yield [['tags' => 'aros'], 1, 'aarhusguiden vocabulary contains the "aros" tag']; - yield [['tags' => 'unknown-tag'], 0]; + // 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'], []]; } } From 2ddd39c3ccf82136d7bb07d3db851942d2a00dbe Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 12:52:16 +0200 Subject: [PATCH 25/63] docs: add CHANGELOG entry for filter identity and sort assertions --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d403998..efe140a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [PR-43](https://github.com/itk-dev/event-database-api/pull/43) + Assert filter identities (not counts), sort order, and pagination edge cases - [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) From d190b090413433ad814d75c3fdd813a0f61cdf42 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 13:28:14 +0200 Subject: [PATCH 26/63] test: validate deep payload schemas for every resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests/schemas/contract.schema.json — a hand-authored, additive-tolerant JSON Schema pinning the nested payload of every resource (organizer, location.coordinates, occurrences[], imageUrls, …) with types and the consumer-critical required fields. ContractSchemaTest validates both collection members and item endpoints against it, handling the D6 quirk where the five nested resources return a hydra:Collection with a single member on their item endpoint while Tag/Vocabulary return true items. This is the delta over CollectionContractTest (top-level field set only): a removed/renamed nested field or a changed type now fails CI, while adding a field passes. Adds justinrainbow/json-schema as a dev dependency. --- composer.json | 1 + composer.lock | 16 +-- .../Contract/ContractSchemaTest.php | 112 +++++++++++++++ tests/schemas/contract.schema.json | 129 ++++++++++++++++++ 4 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 tests/ApiPlatform/Contract/ContractSchemaTest.php create mode 100644 tests/schemas/contract.schema.json diff --git a/composer.json b/composer.json index 3363fac..9aae943 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "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", diff --git a/composer.lock b/composer.lock index 38d7273..45e99c3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "96179f4d3c4439728b30f58af22cf645", + "content-hash": "3e561e58a156313d3083e9359e0b480d", "packages": [ { "name": "api-platform/core", @@ -7412,16 +7412,16 @@ }, { "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": { @@ -7431,7 +7431,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", @@ -7481,9 +7481,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", diff --git a/tests/ApiPlatform/Contract/ContractSchemaTest.php b/tests/ApiPlatform/Contract/ContractSchemaTest.php new file mode 100644 index 0000000..c3f72d0 --- /dev/null +++ b/tests/ApiPlatform/Contract/ContractSchemaTest.php @@ -0,0 +1,112 @@ +get([], $path)->toArray(); + self::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) { + self::assertArrayHasKey('hydra:member', $data, $path.' should return a collection wrapper (D6)'); + self::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]); + + self::assertTrue( + $validator->isValid(), + $message.' failed contract schema "'.$definition.'": '.self::formatErrors($validator->getErrors()) + ); + } + + /** + * @param array $errors + */ + private static function formatErrors(array $errors): string + { + return implode('; ', array_map( + static fn (array $e) => trim(($e['property'] ?? '').' '.($e['message'] ?? '')), + $errors + )); + } +} diff --git a/tests/schemas/contract.schema.json b/tests/schemas/contract.schema.json new file mode 100644 index 0000000..324aa80 --- /dev/null +++ b/tests/schemas/contract.schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "internal://contract.schema.json", + "$comment": "Deep payload contract for every API resource member. Additive-tolerant (additionalProperties defaults to true): adding a field passes, removing/renaming a required field or changing a type fails. Derived from live payloads and event-database-imports mappings. See ContractSchemaTest.", + "definitions": { + "coordinates": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2 + }, + "imageUrls": { + "type": "object", + "required": ["small", "medium", "large"], + "properties": { + "small": {"type": "string"}, + "medium": {"type": "string"}, + "large": {"type": "string"} + } + }, + "party": { + "$comment": "Organizer / partner / Organization member — same shape.", + "type": "object", + "required": ["entityId", "name", "email", "url"], + "properties": { + "entityId": {"type": "integer"}, + "name": {"type": "string"}, + "email": {"type": "string"}, + "url": {"type": "string"}, + "created": {"type": "string"}, + "updated": {"type": "string"} + } + }, + "location": { + "type": "object", + "required": ["entityId", "name", "disabilityAccess", "coordinates"], + "properties": { + "entityId": {"type": "integer"}, + "name": {"type": "string"}, + "image": {"type": ["string", "null"]}, + "url": {"type": ["string", "null"]}, + "telephone": {"type": ["string", "null"]}, + "disabilityAccess": {"type": "boolean"}, + "mail": {"type": ["string", "null"]}, + "street": {"type": "string"}, + "suite": {"type": "string"}, + "region": {"type": "string"}, + "city": {"type": "string"}, + "country": {"type": "string"}, + "postalCode": {"type": "string"}, + "coordinates": {"$ref": "#/definitions/coordinates"} + } + }, + "occurrenceEmbedded": { + "$comment": "Occurrence as embedded inside an event (no back-reference to event).", + "type": "object", + "required": ["entityId", "start", "end"], + "properties": { + "entityId": {"type": "integer"}, + "start": {"type": "string"}, + "end": {"type": "string"}, + "ticketPriceRange": {"type": ["string", "null"]}, + "room": {"type": ["string", "null"]}, + "status": {"type": ["string", "null"]} + } + }, + "event": { + "type": "object", + "required": ["entityId", "title", "publicAccess", "organizer", "location", "occurrences", "tags", "imageUrls"], + "properties": { + "entityId": {"type": "integer"}, + "title": {"type": "string"}, + "excerpt": {"type": ["string", "null"]}, + "description": {"type": ["string", "null"]}, + "url": {"type": ["string", "null"]}, + "ticketUrl": {"type": ["string", "null"]}, + "publicAccess": {"type": "boolean"}, + "organizer": {"$ref": "#/definitions/party"}, + "partners": {"type": "array", "items": {"$ref": "#/definitions/party"}}, + "occurrences": {"type": "array", "items": {"$ref": "#/definitions/occurrenceEmbedded"}}, + "dailyOccurrences": {"type": "array", "items": {"$ref": "#/definitions/occurrenceEmbedded"}}, + "tags": {"type": "array", "items": {"type": "string"}}, + "imageUrls": {"$ref": "#/definitions/imageUrls"}, + "created": {"type": "string"}, + "updated": {"type": "string"}, + "location": {"$ref": "#/definitions/location"} + } + }, + "occurrence": { + "$comment": "Occurrence / DailyOccurrence resource member — carries the parent event.", + "type": "object", + "required": ["entityId", "start", "end", "event"], + "properties": { + "entityId": {"type": "integer"}, + "start": {"type": "string"}, + "end": {"type": "string"}, + "ticketPriceRange": {"type": ["string", "null"]}, + "room": {"type": ["string", "null"]}, + "status": {"type": ["string", "null"]}, + "event": {"$ref": "#/definitions/event"} + } + }, + "organization": { + "$ref": "#/definitions/party" + }, + "tag": { + "type": "object", + "required": ["@id", "@type", "slug", "name"], + "properties": { + "@id": {"type": "string"}, + "@type": {"type": "string"}, + "slug": {"type": "string"}, + "name": {"type": "string"} + } + }, + "vocabulary": { + "type": "object", + "required": ["@id", "@type", "slug", "name"], + "properties": { + "@id": {"type": "string"}, + "@type": {"type": "string"}, + "slug": {"type": "string"}, + "name": {"type": "string"}, + "description": {"type": ["string", "null"]}, + "tags": {"type": "array", "items": {"type": "string"}} + } + } + } +} From 826c50e67f316d495c3a5e2f742e595e2306b66d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 13:32:03 +0200 Subject: [PATCH 27/63] docs: add CHANGELOG entry for deep payload contract schemas --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5655e..c08fef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) From b876344ece3595e19e7e66835d49a2f11a8b4e1f Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 14:10:11 +0200 Subject: [PATCH 28/63] ci: check index mappings against event-database-imports Add a pull-request workflow that checks out event-database-imports at develop and diffs its committed resources/mappings/*.json (the source of truth) against this repo's tests/resources/mappings/*.json with keys canonicalised (jq -S). On drift it comments the per-index diff plus the re-sync command and fails; otherwise it is silent. This replaces the hand-copying of mappings with an enforced check, complementing the local-only index-contract Stop hook. --- .github/workflows/index-mapping-drift.yaml | 102 +++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 .github/workflows/index-mapping-drift.yaml 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 From 3ff98bb9e513739134a09a708e8834fa5aa889aa Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 14:10:50 +0200 Subject: [PATCH 29/63] docs: add CHANGELOG entry for index mapping drift check --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c08fef5..f990812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) From 6e1cab1a9e84be9b65062d6a7f520589e70ee5e4 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 14:24:43 +0200 Subject: [PATCH 30/63] chore: upgrade api-platform/core 4.1 to 4.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loosen the pin (~4.1.0 → ~4.3.0) and update. Runtime responses are backwards-compatible — the full contract suite passes — with one error-body change: under 4.3 (hydra_prefix: true) the hydra error uses `hydra:title`/`hydra:description` instead of the unprefixed `title`; `status`, `type` and `detail` are unchanged. ErrorContractTest is updated to pin the 4.3 shape. 4.3 also changed OpenAPI spec generation (collection schemas via allOf + a shared HydraCollectionBaseSchema, and wider error schemas), so public/spec.yaml is regenerated. The oasdiff gate reports these as breaking at the spec level even though runtime responses are unchanged. API Platform's self-generated collection schema now requires @id/@type on members; the five resources returning the raw Elasticsearch _source (no @id/@type — see D6) opt out of that assertion via $assertsGeneratedCollectionSchema, keeping it live for Tag/Vocabulary. --- composer.json | 2 +- composer.lock | 338 ++++---- config/reference.php | 59 +- public/spec.yaml | 741 ++++++------------ tests/ApiPlatform/AbstractApiTestCase.php | 11 + .../Contract/ErrorContractTest.php | 11 +- tests/ApiPlatform/DailyOccurrencesTest.php | 4 + tests/ApiPlatform/EventsTest.php | 4 + tests/ApiPlatform/LocationsTest.php | 4 + tests/ApiPlatform/OccurrencesTest.php | 4 + tests/ApiPlatform/OrganizationsTest.php | 4 + .../Trait/GetEntitiesTestTrait.php | 2 +- 12 files changed, 512 insertions(+), 672 deletions(-) diff --git a/composer.json b/composer.json index 9aae943..98a600d 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "php": ">=8.3", "ext-ctype": "*", "ext-iconv": "*", - "api-platform/core": "~4.1.0", + "api-platform/core": "~4.3.0", "elasticsearch/elasticsearch": "^8.13", "nelmio/cors-bundle": "^2.4", "phpdocumentor/reflection-docblock": "^5.6", diff --git a/composer.lock b/composer.lock index 45e99c3..2b423af 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": "3e561e58a156313d3083e9359e0b480d", + "content-hash": "48d72e29a5aeb508c05569314faf8904", "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": "2025-12-05T14:51:01+00:00" + "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" + }, + "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", @@ -5092,16 +5171,16 @@ }, { "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": { @@ -5172,7 +5251,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": [ { @@ -5192,7 +5271,7 @@ "type": "tidelift" } ], - "time": "2026-05-03T13:03:28+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/service-contracts", @@ -5374,16 +5453,16 @@ }, { "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": { @@ -5432,7 +5511,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": [ { @@ -5452,7 +5531,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/twig-bridge", @@ -5744,16 +5823,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": { @@ -5824,7 +5903,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": [ { @@ -5844,7 +5923,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T15:30:56+00:00" + "time": "2026-06-27T06:16:12+00:00" }, { "name": "symfony/var-dumper", @@ -6521,83 +6600,6 @@ ], "time": "2026-06-07T11:47:49+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" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - } - ], - "time": "2025-08-20T19:15:30+00:00" - }, { "name": "composer/xdebug-handler", "version": "3.0.5", diff --git a/config/reference.php b/config/reference.php index 1cb7eb3..452b3e6 100644 --- a/config/reference.php +++ b/config/reference.php @@ -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/public/spec.yaml b/public/spec.yaml index 8714964..24d71eb 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: + slug: + type: string + 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 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: + 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 - description: - type: string - tags: - type: array - items: - type: string + name: + 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/tests/ApiPlatform/AbstractApiTestCase.php b/tests/ApiPlatform/AbstractApiTestCase.php index fbb8b51..b8c71a6 100644 --- a/tests/ApiPlatform/AbstractApiTestCase.php +++ b/tests/ApiPlatform/AbstractApiTestCase.php @@ -18,6 +18,17 @@ abstract class AbstractApiTestCase extends ApiTestCase protected static int|string $unknownItemId = 99999; + /** + * Whether to assert the collection against API Platform's self-generated + * JSON schema. That schema requires `@id`/`@var` on every member; the five + * resources whose provider returns the raw Elasticsearch `_source` (Event, + * Occurrence, DailyOccurrence, Location, Organization) intentionally omit + * those (see README "Known limitations", D6), so they opt out. Tag and + * Vocabulary are true API Platform resources and keep the assertion — where + * it also serves as a tripwire for future API Platform upgrades. + */ + protected static bool $assertsGeneratedCollectionSchema = true; + protected static function createAuthenticatedClient(): Client { return static::createClient(defaultOptions: [ diff --git a/tests/ApiPlatform/Contract/ErrorContractTest.php b/tests/ApiPlatform/Contract/ErrorContractTest.php index a8fdcde..037d66e 100644 --- a/tests/ApiPlatform/Contract/ErrorContractTest.php +++ b/tests/ApiPlatform/Contract/ErrorContractTest.php @@ -8,7 +8,12 @@ /** * Locks the error-response contract. API Platform has changed error * serialization across versions (hydra:Error vs problem+json, field names), so - * the exact shape of 401/404 is worth pinning before the upgrade. + * the exact shape of 401/404 is worth pinning. + * + * Under API Platform 4.3 (with `hydra_prefix: true`) the human-readable fields + * are hydra-prefixed — `hydra:title` / `hydra:description` — while the + * machine-readable `status`, `type` and `detail` are unprefixed. (In 4.1 the + * title was emitted unprefixed as `title`.) * * Note: the body also carries a `trace` array in the test env (debug on); that * is environment-dependent and intentionally NOT asserted here. @@ -29,8 +34,10 @@ public function testUnauthenticatedErrorShape(): void $this->assertSame('hydra:Error', $data['@type']); $this->assertSame(401, $data['status']); $this->assertSame('/errors/401', $data['type']); - $this->assertArrayHasKey('title', $data); $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 diff --git a/tests/ApiPlatform/DailyOccurrencesTest.php b/tests/ApiPlatform/DailyOccurrencesTest.php index 6799229..14c3163 100644 --- a/tests/ApiPlatform/DailyOccurrencesTest.php +++ b/tests/ApiPlatform/DailyOccurrencesTest.php @@ -15,5 +15,9 @@ class DailyOccurrencesTest extends AbstractApiTestCase protected static string $resourceClass = DailyOccurrence::class; + // Members are the raw Elasticsearch _source with no @id/@type, so API + // Platform's self-generated collection schema does not apply (see D6). + protected static bool $assertsGeneratedCollectionSchema = false; + protected static int|string $itemId = 10; } diff --git a/tests/ApiPlatform/EventsTest.php b/tests/ApiPlatform/EventsTest.php index 4f6840e..9929897 100644 --- a/tests/ApiPlatform/EventsTest.php +++ b/tests/ApiPlatform/EventsTest.php @@ -18,5 +18,9 @@ class EventsTest extends AbstractApiTestCase protected static string $resourceClass = Event::class; + // Members are the raw Elasticsearch _source with no @id/@type, so API + // Platform's self-generated collection schema does not apply (see D6). + protected static bool $assertsGeneratedCollectionSchema = false; + protected static int|string $itemId = 7; } diff --git a/tests/ApiPlatform/LocationsTest.php b/tests/ApiPlatform/LocationsTest.php index 45d1c5b..445eaef 100644 --- a/tests/ApiPlatform/LocationsTest.php +++ b/tests/ApiPlatform/LocationsTest.php @@ -15,5 +15,9 @@ class LocationsTest extends AbstractApiTestCase protected static string $resourceClass = Location::class; + // Members are the raw Elasticsearch _source with no @id/@type, so API + // Platform's self-generated collection schema does not apply (see D6). + protected static bool $assertsGeneratedCollectionSchema = false; + protected static int|string $itemId = 4; } diff --git a/tests/ApiPlatform/OccurrencesTest.php b/tests/ApiPlatform/OccurrencesTest.php index 9e66408..af7f48e 100644 --- a/tests/ApiPlatform/OccurrencesTest.php +++ b/tests/ApiPlatform/OccurrencesTest.php @@ -15,5 +15,9 @@ class OccurrencesTest extends AbstractApiTestCase protected static string $resourceClass = Occurrence::class; + // Members are the raw Elasticsearch _source with no @id/@type, so API + // Platform's self-generated collection schema does not apply (see D6). + protected static bool $assertsGeneratedCollectionSchema = false; + protected static int|string $itemId = 10; } diff --git a/tests/ApiPlatform/OrganizationsTest.php b/tests/ApiPlatform/OrganizationsTest.php index f956e73..1114083 100644 --- a/tests/ApiPlatform/OrganizationsTest.php +++ b/tests/ApiPlatform/OrganizationsTest.php @@ -15,5 +15,9 @@ class OrganizationsTest extends AbstractApiTestCase protected static string $resourceClass = Organization::class; + // Members are the raw Elasticsearch _source with no @id/@type, so API + // Platform's self-generated collection schema does not apply (see D6). + protected static bool $assertsGeneratedCollectionSchema = false; + protected static int|string $itemId = 9; } diff --git a/tests/ApiPlatform/Trait/GetEntitiesTestTrait.php b/tests/ApiPlatform/Trait/GetEntitiesTestTrait.php index 5699d0b..eeb1880 100644 --- a/tests/ApiPlatform/Trait/GetEntitiesTestTrait.php +++ b/tests/ApiPlatform/Trait/GetEntitiesTestTrait.php @@ -24,7 +24,7 @@ public function testGetEntities(): void $this->assertArrayHasKey('hydra:member', $data); $this->assertArrayHasKey('hydra:totalItems', $data); - if (isset(static::$resourceClass)) { + if (isset(static::$resourceClass) && static::$assertsGeneratedCollectionSchema) { $this->assertMatchesResourceCollectionJsonSchema(static::$resourceClass); } } From 9c8f55e9c328ba29139fa0f50fcc11a486bc5342 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 14:26:37 +0200 Subject: [PATCH 31/63] docs: add CHANGELOG entry for api-platform 4.3 upgrade --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f990812..64c47f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) From 97b066f109cf926642300eb34ebbb1368f38f1cc Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 14:56:50 +0200 Subject: [PATCH 32/63] test: add aarhusguiden consumer contract suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encode the exact request shapes aarhusguiden.dk issues (verified from its live traffic): daily_occurrences collection filtered by event.tags (comma = match-any), event.title, event.publicAccess and start/end with pagination, plus the events/{id} item endpoint. The item-endpoint test pins the D6 collection-wrapper the site's detail pages depend on (hydra:member[0]), so a future switch to a plain single-item response is caught as a consumer-breaking change. Runtime-contract only — passes on both api-platform 4.1 and 4.3. --- .../Consumer/AarhusguidenContractTest.php | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/ApiPlatform/Consumer/AarhusguidenContractTest.php diff --git a/tests/ApiPlatform/Consumer/AarhusguidenContractTest.php b/tests/ApiPlatform/Consumer/AarhusguidenContractTest.php new file mode 100644 index 0000000..20b04f8 --- /dev/null +++ b/tests/ApiPlatform/Consumer/AarhusguidenContractTest.php @@ -0,0 +1,105 @@ +get([ + 'event.tags' => 'ITKDev', + 'event.publicAccess' => 'true', + 'start' => static::formatDateTime('2024-01-01'), + 'end' => static::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); + } + } +} From 2d7e1de8ccf4a946162d4b002dc4b5ba85e3a83d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 14:57:56 +0200 Subject: [PATCH 33/63] docs: add CHANGELOG entry for aarhusguiden consumer contract suite --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f990812..6179b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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-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) From 2e2967daf976d10707da64d6979fdaecc4107a74 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 15:11:11 +0200 Subject: [PATCH 34/63] test: add os2display consumer contract suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encode the request shapes os2display's EventDatabaseApiV2FeedType + EventDatabaseApiV2Helper issue against this API: occurrences collection with IdFilter/TagFilter and end[gt], the occurrences/{id} and events/{id} D6 collection wrappers (single poster / entity config), events search (title + occurrences.end[gt] + location filter), and the paginated tags/organizations/locations option collections whose hydra:totalItems drives the helper's paging loop. Also pins that a missing item returns a 404 status — os2display unpublishes the slide off the status alone and never reads the error body, so the hydra error-field change is moot. Its React admin/client apps do not call this API directly (they consume the transformed poster output), so the contract is fully server-side. Runtime-contract only — passes on api-platform 4.1 and 4.3. --- .../Consumer/Os2displayContractTest.php | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/ApiPlatform/Consumer/Os2displayContractTest.php diff --git a/tests/ApiPlatform/Consumer/Os2displayContractTest.php b/tests/ApiPlatform/Consumer/Os2displayContractTest.php new file mode 100644 index 0000000..62df9a6 --- /dev/null +++ b/tests/ApiPlatform/Consumer/Os2displayContractTest.php @@ -0,0 +1,137 @@ +get([ + 'event.tags' => 'Koncert', + 'event.location.entityId' => 4, + 'end' => ['gt' => static::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' => static::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()); + } +} From 8d7503ea67a1531a16619d348aad067468521e82 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 15:12:01 +0200 Subject: [PATCH 35/63] docs: add CHANGELOG entry for os2display consumer contract suite --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f990812..9b79f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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-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) From 8880aacfe895645936a451cfcc487bdee800d825 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 15:27:16 +0200 Subject: [PATCH 36/63] ci: bump oasdiff-action from v0.0.44 to v0.1.5 Update both the breaking and changelog steps in the API-spec workflow. Input-compatible (base/revision/fail-on/format/output-to-file unchanged) and behaviour-neutral (fail-on stays ERR, flatten-allof default stays false), so the gate result is unchanged. v0.1.5 additionally exposes flatten-allof and err-ignore/warn-ignore inputs. --- .github/workflows/api-spec.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/api-spec.yml b/.github/workflows/api-spec.yml index 611fc4a..2ee1d8b 100644 --- a/.github/workflows/api-spec.yml +++ b/.github/workflows/api-spec.yml @@ -93,7 +93,7 @@ jobs: - name: Detect breaking changes id: breaking continue-on-error: true - uses: oasdiff/oasdiff-action/breaking@v0.0.44 + uses: oasdiff/oasdiff-action/breaking@v0.1.5 with: base: .api-spec-tmp/base.yaml revision: .api-spec-tmp/revision.yaml @@ -102,7 +102,7 @@ jobs: - name: Generate changelog id: changelog continue-on-error: true - uses: oasdiff/oasdiff-action/changelog@v0.0.44 + uses: oasdiff/oasdiff-action/changelog@v0.1.5 with: base: .api-spec-tmp/base.yaml revision: .api-spec-tmp/revision.yaml From 191e1ed6d9c1b7f839a5417df68ec1468aa3c79e Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 15:27:54 +0200 Subject: [PATCH 37/63] docs: add CHANGELOG entry for oasdiff-action bump --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eded622..c6233cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) From a7483dc3cef4c5d0544a94129202c66de6701eaf Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 15:52:44 +0200 Subject: [PATCH 38/63] ci: fold oasdiff summary and review link into one PR comment Keep the oasdiff.com upload (review: true) for the side-by-side review, but suppress the action's standalone link-only comment (github-token: "") and instead post a single sticky comment that carries the changelog inline in a collapsed
plus the review link (from the review_url output). Codecov-style: the diff summary is visible on the PR without leaving GitHub, and the full side-by-side review is one click away. --- .github/workflows/api-spec.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/api-spec.yml b/.github/workflows/api-spec.yml index 2ee1d8b..8204cf2 100644 --- a/.github/workflows/api-spec.yml +++ b/.github/workflows/api-spec.yml @@ -98,6 +98,11 @@ jobs: base: .api-spec-tmp/base.yaml revision: .api-spec-tmp/revision.yaml fail-on: ERR + # 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: Generate changelog id: changelog @@ -108,6 +113,9 @@ jobs: revision: .api-spec-tmp/revision.yaml format: markdown output-to-file: changelog.md + # As above: keep the upload/review_url output, suppress the auto-comment. + review: true + github-token: "" - name: Determine whether the spec changed id: changes @@ -135,12 +143,20 @@ jobs: env: GH_TOKEN: ${{ github.token }} PREV_ID: ${{ steps.prev-comment.outputs.id }} + REVIEW_URL: ${{ steps.changelog.outputs.review_url }} run: | { 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 \ @@ -154,16 +170,24 @@ jobs: env: GH_TOKEN: ${{ github.token }} PREV_ID: ${{ steps.prev-comment.outputs.id }} + REVIEW_URL: ${{ steps.breaking.outputs.review_url }} run: | { 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 \ From 9da1a20cb1146a7c2bbb79d3537c3a46190c92f0 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 15:54:09 +0200 Subject: [PATCH 39/63] docs: add CHANGELOG entry for inline API-spec diff comment --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6233cd..24eaa0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) From 947c090a3b34bdee6e9509bb934f2f5c2678599c Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 16:12:41 +0200 Subject: [PATCH 40/63] test: pin the problem+json error/negotiation contract Add two ErrorContractTest cases for Accept: application/problem+json: an unauthenticated request is served as RFC 7807 problem+json (401, unprefixed title/detail/status/type), while requesting a resource item in problem+json yields 406 Not Acceptable (the resources only produce ld+json) with a problem+json error body. Closes the last untested corner of the error contract and documents that problem+json is not a resource representation. --- .../Contract/ErrorContractTest.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/ApiPlatform/Contract/ErrorContractTest.php b/tests/ApiPlatform/Contract/ErrorContractTest.php index 037d66e..4bf73b4 100644 --- a/tests/ApiPlatform/Contract/ErrorContractTest.php +++ b/tests/ApiPlatform/Contract/ErrorContractTest.php @@ -52,4 +52,51 @@ public function testNotFoundErrorShape(): void $this->assertSame(404, $data['status']); $this->assertSame('/errors/404', $data['type']); } + + /** + * When a client asks for problem+json, auth errors are served as RFC 7807 + * problem+json (unprefixed title/detail/status/type) rather than hydra. + * The 401 short-circuits before resource content negotiation, so it is + * available in the requested media type. + */ + 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 resources themselves only produce ld+json, so requesting an item with + * `Accept: application/problem+json` is not satisfiable — 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. + */ + 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']); + } } From e5c9d61dfc15647dedcdc94bda3ae5f1163c714a Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 16:13:20 +0200 Subject: [PATCH 41/63] docs: add CHANGELOG entry for problem+json error contract test --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d3236..878fd75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) From d088d3910ced2d9b0d6dfc54da3160907dae353e Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 16:16:09 +0200 Subject: [PATCH 42/63] ci: compare effective schemas in oasdiff (flatten-allof) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set flatten-allof: true on both oasdiff steps so an allOf/$ref restructure of the generated spec is compared as its effective schema rather than reported as removed properties. This is forward-looking hygiene: it makes the gate robust to future API Platform schema restructures like the 4.1→4.3 HydraCollectionBaseSchema change, and it never masks a real diff. It does not "clean up" the already-merged 4.1→4.3 diff (that error-schema widening is real and now behind us). --- .github/workflows/api-spec.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/api-spec.yml b/.github/workflows/api-spec.yml index 8204cf2..0e5bdd8 100644 --- a/.github/workflows/api-spec.yml +++ b/.github/workflows/api-spec.yml @@ -98,6 +98,9 @@ jobs: 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. @@ -113,6 +116,7 @@ jobs: 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: "" From ef1d46f03c88f8c8f6c9445662bb7ef73fe9629a Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 16:16:33 +0200 Subject: [PATCH 43/63] docs: add CHANGELOG entry for oasdiff flatten-allof --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d3236..9e20adf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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-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) From 7fa98e3117870e33b1de9fc9a0c19fbaca838bc2 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 16:27:29 +0200 Subject: [PATCH 44/63] docs: cite the formats/error config behind the problem+json contract --- .../Contract/ErrorContractTest.php | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/ApiPlatform/Contract/ErrorContractTest.php b/tests/ApiPlatform/Contract/ErrorContractTest.php index 4bf73b4..5c39069 100644 --- a/tests/ApiPlatform/Contract/ErrorContractTest.php +++ b/tests/ApiPlatform/Contract/ErrorContractTest.php @@ -54,10 +54,13 @@ public function testNotFoundErrorShape(): void } /** - * When a client asks for problem+json, auth errors are served as RFC 7807 - * problem+json (unprefixed title/detail/status/type) rather than hydra. - * The 401 short-circuits before resource content negotiation, so it is - * available in the requested media 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 { @@ -77,11 +80,15 @@ public function testUnauthenticatedErrorIsAvailableAsProblemJson(): void } /** - * The resources themselves only produce ld+json, so requesting an item with - * `Accept: application/problem+json` is not satisfiable — 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. + * 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 { From 701598b0f26bcfa7863e17725ef71750d14c6c5d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 16:40:20 +0200 Subject: [PATCH 45/63] chore: add Rector with the relevant sets and apply across the codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add rector/rector with a config aligned to this project's stack and the PHPStan level-8 + strict-rules setup: PHP-version sets, composer-based Symfony/PHPUnit rules, attribute sets, and the dead-code, code-quality, type-declaration, privatization, instanceof, early-return and Symfony/PHPUnit code-quality prepared sets. The Doctrine and Carbon sets are omitted — there is no domain Doctrine here (entities live in event-database-imports) and Carbon is not used. Coding style and imports remain owned by php-cs-fixer. Apply the resulting changes across src/ and tests/ (declare(strict_types), constructor promotion, typed constants, readonly classes, arrow-function return types, self:: → $this-> in tests, final test classes, …). The full suite (252), PHPStan (level 8 + strict + baseline) and php-cs-fixer all stay green. Wire it into `task code-analysis` (+ `code-analysis:rector[:apply]`) and add a Rector CI job to the Code Review workflow that fails on pending suggestions — the CI step event-database-imports omitted. --- .github/workflows/pr.yaml | 24 +++++++ Taskfile.yml | 17 +++++ composer.json | 1 + composer.lock | 62 ++++++++++++++++++- rector.php | 34 ++++++++++ src/Api/Dto/DailyOccurrence.php | 2 + src/Api/Dto/Event.php | 2 + src/Api/Dto/Location.php | 2 + src/Api/Dto/Occurrence.php | 2 + src/Api/Dto/Organization.php | 2 + src/Api/Dto/Tag.php | 16 +++-- src/Api/Dto/Vocabulary.php | 28 ++++----- .../Filter/ElasticSearch/BooleanFilter.php | 16 ++++- .../Filter/ElasticSearch/DateRangeFilter.php | 6 +- src/Api/Filter/ElasticSearch/IdFilter.php | 16 ++++- src/Api/Filter/ElasticSearch/MatchFilter.php | 4 +- src/Api/Filter/ElasticSearch/TagFilter.php | 16 ++++- src/Api/State/TagRepresentationProvider.php | 2 +- .../VocabularyRepresentationProvider.php | 2 +- src/Command/FixturesLoadCommand.php | 4 +- src/Controller/RootController.php | 5 +- src/Exception/AppException.php | 2 + src/Exception/IndexException.php | 2 + src/Kernel.php | 2 + src/Model/DateLimit.php | 2 + src/Model/FilterType.php | 2 + src/Model/IndexName.php | 2 + src/Model/SearchResults.php | 8 ++- src/Security/ApiKeyAuthenticator.php | 2 + src/Security/ApiUser.php | 2 + .../ElasticSearch/ElasticIndexException.php | 2 +- .../ElasticSearch/ElasticSearchPaginator.php | 2 + src/Service/IndexInterface.php | 2 + tests/ApiPlatform/AbstractApiTestCase.php | 8 ++- tests/ApiPlatform/ApiTest.php | 6 +- tests/ApiPlatform/AuthenticationTest.php | 4 +- .../Consumer/AarhusguidenContractTest.php | 8 ++- .../Consumer/Os2displayContractTest.php | 8 ++- .../Contract/CollectionContractTest.php | 4 +- .../ContentNegotiationContractTest.php | 4 +- .../Contract/ContractSchemaTest.php | 23 ++++--- .../Contract/DateFormatContractTest.php | 6 +- .../Contract/ErrorContractTest.php | 4 +- .../ApiPlatform/Contract/ItemContractTest.php | 4 +- .../DailyOccurrencesFilterTest.php | 6 +- tests/ApiPlatform/DailyOccurrencesTest.php | 4 +- tests/ApiPlatform/EventsFilterTest.php | 18 +++--- tests/ApiPlatform/EventsTest.php | 4 +- tests/ApiPlatform/FilterErrorTest.php | 4 +- tests/ApiPlatform/LocationsFilterTest.php | 4 +- tests/ApiPlatform/LocationsTest.php | 4 +- tests/ApiPlatform/OccurrencesFilterTest.php | 10 +-- tests/ApiPlatform/OccurrencesTest.php | 4 +- tests/ApiPlatform/OrganizationsFilterTest.php | 4 +- tests/ApiPlatform/OrganizationsTest.php | 4 +- tests/ApiPlatform/PaginationTest.php | 4 +- tests/ApiPlatform/SortTest.php | 8 ++- tests/ApiPlatform/TagsFilterTest.php | 4 +- tests/ApiPlatform/TagsTest.php | 4 +- tests/ApiPlatform/VocabulariesFilterTest.php | 4 +- tests/ApiPlatform/VocabulariesTest.php | 4 +- .../ElasticSearch/BooleanFilterTest.php | 14 +++-- .../ElasticSearch/DateRangeFilterTest.php | 12 ++-- .../Api/Filter/ElasticSearch/IdFilterTest.php | 12 ++-- .../Filter/ElasticSearch/MatchFilterTest.php | 16 ++--- .../Filter/ElasticSearch/TagFilterTest.php | 12 ++-- .../ElasticIndexExceptionTest.php | 13 ++-- .../ElasticSearch/SearchParamsBuilderTest.php | 25 ++++---- tests/bootstrap.php | 2 + 69 files changed, 413 insertions(+), 159 deletions(-) create mode 100644 rector.php diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 94f8ba0..e6b184a 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -68,3 +68,27 @@ jobs: - name: Run code analysis run: | docker compose exec -T 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: Start docker compose setup and install site + run: | + docker network create frontend + docker compose pull --quiet + docker compose up --detach --wait + docker compose exec -T phpfpm composer install --no-interaction + + - name: Check for Rector suggestions + run: | + docker compose exec -T phpfpm vendor/bin/rector process --dry-run --no-progress-bar diff --git a/Taskfile.yml b/Taskfile.yml index 2cd8f36..b63cd08 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -165,6 +165,7 @@ tasks: desc: "Run code analysis" cmds: - task: code-analysis:phpstan + - task: code-analysis:rector silent: true code-analysis:phpstan: @@ -175,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: diff --git a/composer.json b/composer.json index 98a600d..c6eaeed 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "phpstan/phpstan-strict-rules": "^2.0", "phpstan/phpstan-symfony": "^2.0", "phpunit/phpunit": "^13", + "rector/rector": "^2.5", "symfony/browser-kit": "~7.4.0", "symfony/css-selector": "~7.4.0", "symfony/maker-bundle": "^1.52", diff --git a/composer.lock b/composer.lock index 2b423af..dbd433c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "48d72e29a5aeb508c05569314faf8904", + "content-hash": "0de130ed061bef0747c1862bd4a9072a", "packages": [ { "name": "api-platform/core", @@ -9155,6 +9155,66 @@ ], "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": "5.0.0", 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/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 20e3fdc..e7fbe2a 100644 --- a/src/Api/Filter/ElasticSearch/BooleanFilter.php +++ b/src/Api/Filter/ElasticSearch/BooleanFilter.php @@ -1,5 +1,7 @@ $terms + ['boost' => 1.0]]; @@ -33,7 +43,7 @@ public function getDescription(string $resourceClass): array } $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/DateRangeFilter.php b/src/Api/Filter/ElasticSearch/DateRangeFilter.php index 2dde5b4..9ba4657 100644 --- a/src/Api/Filter/ElasticSearch/DateRangeFilter.php +++ b/src/Api/Filter/ElasticSearch/DateRangeFilter.php @@ -52,7 +52,7 @@ public function apply(array $clauseBody, string $resourceClass, ?Operation $oper return $ranges; } - foreach ($this->properties as $property => $value) { + 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]); } @@ -94,7 +94,7 @@ private function getElasticSearchQueryRanges(string $property, string|array $fil $value = $filter; } else { $operator = $this->resolveOperator((string) array_key_first($filter), $throwOnInvalid); - if (null === $operator) { + if (!$operator instanceof DateLimit) { return []; } $value = array_shift($filter); @@ -102,7 +102,7 @@ private function getElasticSearchQueryRanges(string $property, string|array $fil switch ($operator) { case DateLimit::between: - $values = explode('..', $value); + $values = explode('..', (string) $value); if (2 !== count($values)) { if ($throwOnInvalid) { diff --git a/src/Api/Filter/ElasticSearch/IdFilter.php b/src/Api/Filter/ElasticSearch/IdFilter.php index abf5fed..02858a2 100644 --- a/src/Api/Filter/ElasticSearch/IdFilter.php +++ b/src/Api/Filter/ElasticSearch/IdFilter.php @@ -1,5 +1,7 @@ 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 0cedc10..b6a4d02 100644 --- a/src/Api/Filter/ElasticSearch/MatchFilter.php +++ b/src/Api/Filter/ElasticSearch/MatchFilter.php @@ -1,5 +1,7 @@ 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 d67a6d6..b31f4d2 100644 --- a/src/Api/Filter/ElasticSearch/TagFilter.php +++ b/src/Api/Filter/ElasticSearch/TagFilter.php @@ -1,5 +1,7 @@ $terms + ['boost' => 1.0]]; @@ -33,7 +43,7 @@ public function getDescription(string $resourceClass): array } $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/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 6bf1aef..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'); } 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 @@ 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/ElasticSearchPaginator.php b/src/Service/ElasticSearch/ElasticSearchPaginator.php index 91d8a81..f23ccc5 100644 --- a/src/Service/ElasticSearch/ElasticSearchPaginator.php +++ b/src/Service/ElasticSearch/ElasticSearchPaginator.php @@ -1,5 +1,7 @@ toArray(); - self::assertArrayHasKey('hydra:member', $data, $message); + $this->assertArrayHasKey('hydra:member', $data, $message); $actual = array_map( static fn (array $member) => $member[$field] ?? null, @@ -73,13 +75,13 @@ protected function assertMemberIds(array $expected, ResponseInterface $response, ); if ($ordered) { - self::assertSame($expected, $actual, $message); + $this->assertSame($expected, $actual, $message); return; } sort($expected); sort($actual); - self::assertSame($expected, $actual, $message); + $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 index fe773cf..17c391e 100644 --- a/tests/ApiPlatform/AuthenticationTest.php +++ b/tests/ApiPlatform/AuthenticationTest.php @@ -1,5 +1,7 @@ get([ 'event.tags' => 'ITKDev', 'event.publicAccess' => 'true', - 'start' => static::formatDateTime('2024-01-01'), - 'end' => static::formatDateTime('2024-12-31'), + 'start' => self::formatDateTime('2024-01-01'), + 'end' => self::formatDateTime('2024-12-31'), 'page' => 1, 'itemsPerPage' => 6, ]); diff --git a/tests/ApiPlatform/Consumer/Os2displayContractTest.php b/tests/ApiPlatform/Consumer/Os2displayContractTest.php index 62df9a6..5811f6b 100644 --- a/tests/ApiPlatform/Consumer/Os2displayContractTest.php +++ b/tests/ApiPlatform/Consumer/Os2displayContractTest.php @@ -1,5 +1,7 @@ get([ 'event.tags' => 'Koncert', 'event.location.entityId' => 4, - 'end' => ['gt' => static::formatDateTime('2024-12-01')], + 'end' => ['gt' => self::formatDateTime('2024-12-01')], 'itemsPerPage' => 20, 'page' => 1, ]); @@ -79,7 +81,7 @@ public function testEventSearchQuery(): void $response = $this->get([ 'title' => 'ITKDev', 'location.entityId' => 4, - 'occurrences.end' => ['gt' => static::formatDateTime('2024-01-01')], + 'occurrences.end' => ['gt' => self::formatDateTime('2024-01-01')], 'itemsPerPage' => 10, ], '/api/v2/events'); diff --git a/tests/ApiPlatform/Contract/CollectionContractTest.php b/tests/ApiPlatform/Contract/CollectionContractTest.php index 2263944..7c65a30 100644 --- a/tests/ApiPlatform/Contract/CollectionContractTest.php +++ b/tests/ApiPlatform/Contract/CollectionContractTest.php @@ -1,5 +1,7 @@ get([], $path)->toArray(); - self::assertNotEmpty($data['hydra:member'], $path.' needs at least one fixture member'); + $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); @@ -45,8 +47,8 @@ public function testItemMatchesSchema(string $path, string $definition, bool $co $data = $this->get([], $path)->toArray(); if ($collectionWrapped) { - self::assertArrayHasKey('hydra:member', $data, $path.' should return a collection wrapper (D6)'); - self::assertNotEmpty($data['hydra:member']); + $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'); } @@ -93,19 +95,16 @@ private function assertMatchesDefinition(array $payload, string $definition, str $validator->validate($data, (object) ['$ref' => self::SCHEMA_URI.'#/definitions/'.$definition]); - self::assertTrue( - $validator->isValid(), - $message.' failed contract schema "'.$definition.'": '.self::formatErrors($validator->getErrors()) - ); + $this->assertTrue($validator->isValid(), $message.' failed contract schema "'.$definition.'": '.$this->formatErrors($validator->getErrors())); } /** * @param array $errors */ - private static function formatErrors(array $errors): string + private function formatErrors(array $errors): string { return implode('; ', array_map( - static fn (array $e) => trim(($e['property'] ?? '').' '.($e['message'] ?? '')), + 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 index 5b25fef..eb49f13 100644 --- a/tests/ApiPlatform/Contract/DateFormatContractTest.php +++ b/tests/ApiPlatform/Contract/DateFormatContractTest.php @@ -1,5 +1,7 @@ [ - ['start[between]' => static::formatDateTime('2024-12-01').'..'.static::formatDateTime('2024-12-31')], + ['start[between]' => self::formatDateTime('2024-12-01').'..'.self::formatDateTime('2024-12-31')], [10, 12], ]; diff --git a/tests/ApiPlatform/DailyOccurrencesTest.php b/tests/ApiPlatform/DailyOccurrencesTest.php index 14c3163..c7a7883 100644 --- a/tests/ApiPlatform/DailyOccurrencesTest.php +++ b/tests/ApiPlatform/DailyOccurrencesTest.php @@ -1,12 +1,14 @@ [ - ['occurrences.start[between]' => static::formatDateTime('2001-01-01').'..'.static::formatDateTime('2100-01-01')], + ['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', ]; yield 'occurrences in 2026' => [ - ['occurrences.start[between]' => static::formatDateTime('2026-01-01').'..'.static::formatDateTime('2026-12-31')], + ['occurrences.start[between]' => self::formatDateTime('2026-01-01').'..'.self::formatDateTime('2026-12-31')], [9], 'Only event 9 has an occurrence in 2026', ]; // DateRangeFilter on updated (default operator: gte). yield 'updated on or after 2024' => [ - ['updated' => static::formatDateTime('2024-01-01')], + ['updated' => self::formatDateTime('2024-01-01')], [7, 8, 9], 'All events were updated on or after 2024-01-01', ]; yield 'updated after 2100' => [ - ['updated[gte]' => static::formatDateTime('2100-01-01')], + ['updated[gte]' => self::formatDateTime('2100-01-01')], [], 'No events updated after 2100', ]; @@ -64,8 +66,8 @@ public static function getEventsProvider(): iterable // a conscious, BC-visible decision. yield 'F7: start/end satisfied by different occurrences' => [ [ - 'occurrences.start[gte]' => static::formatDateTime('2024-12-01'), - 'occurrences.end[lte]' => static::formatDateTime('2024-11-30'), + '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', @@ -91,7 +93,7 @@ public static function getEventsProvider(): iterable // Combined filters. yield 'occurrences in 2026 AND tagged aros' => [ [ - 'occurrences.start[between]' => static::formatDateTime('2026-01-01').'..'.static::formatDateTime('2026-12-31'), + 'occurrences.start[between]' => self::formatDateTime('2026-01-01').'..'.self::formatDateTime('2026-12-31'), 'tags' => 'aros', ], [9], diff --git a/tests/ApiPlatform/EventsTest.php b/tests/ApiPlatform/EventsTest.php index 9929897..6b0f0d4 100644 --- a/tests/ApiPlatform/EventsTest.php +++ b/tests/ApiPlatform/EventsTest.php @@ -1,5 +1,7 @@ [ - ['start[between]' => static::formatDateTime('2024-12-01').'..'.static::formatDateTime('2024-12-31')], + ['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]' => static::formatDateTime('2024-11-01').'..'.static::formatDateTime('2024-11-30')], + ['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]' => static::formatDateTime('2024-12-07').'..'.static::formatDateTime('2024-12-09')], + ['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', ]; diff --git a/tests/ApiPlatform/OccurrencesTest.php b/tests/ApiPlatform/OccurrencesTest.php index af7f48e..b516b84 100644 --- a/tests/ApiPlatform/OccurrencesTest.php +++ b/tests/ApiPlatform/OccurrencesTest.php @@ -1,12 +1,14 @@ assertSame($sorted, $titles, 'Events must be ordered by title.keyword ascending'); } public function testOccurrencesAreSortedByStartAscending(): void @@ -37,7 +39,7 @@ public function testOccurrencesAreSortedByStartAscending(): void $starts = array_column($response->toArray()['hydra:member'], 'start'); $sorted = $starts; sort($sorted, SORT_STRING); - self::assertSame($sorted, $starts, 'start values must be ascending'); + $this->assertSame($sorted, $starts, 'start values must be ascending'); } public function testDailyOccurrencesAreSortedByStartAscending(): void diff --git a/tests/ApiPlatform/TagsFilterTest.php b/tests/ApiPlatform/TagsFilterTest.php index cae92a1..f1a6d74 100644 --- a/tests/ApiPlatform/TagsFilterTest.php +++ b/tests/ApiPlatform/TagsFilterTest.php @@ -1,5 +1,7 @@ filterDependencies(); $filter = new BooleanFilter($names, $meta, $resolver, null, ['publicAccess' => null]); - self::assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + $this->assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); } public static function applyProvider(): iterable @@ -46,8 +48,8 @@ public function testGetDescriptionShape(): void $description = $filter->getDescription(self::RESOURCE); - self::assertArrayHasKey('publicAccess', $description); - self::assertSame('bool', $description['publicAccess']['type']); - self::assertFalse($description['publicAccess']['required']); + $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 index 78cc677..3ea4dc9 100644 --- a/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php +++ b/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php @@ -1,5 +1,7 @@ ['limit' => DateLimit::gte, 'throwOnInvalid' => true]], ); - self::assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + $this->assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); } public static function applyProvider(): iterable @@ -109,7 +111,7 @@ public function testThrowOnInvalidFalseSkipsInvalidInput(array $filters): void ['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => false]], ); - self::assertSame([], $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + $this->assertSame([], $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); } public static function invalidInputProvider(): iterable @@ -130,7 +132,7 @@ public function testGetDescriptionExposesEveryOperatorVariant(): void $description = $filter->getDescription(self::RESOURCE); foreach (['updated', 'updated[between]', 'updated[gt]', 'updated[gte]', 'updated[lt]', 'updated[lte]'] as $key) { - self::assertArrayHasKey($key, $description, $key.' should be described'); + $this->assertArrayHasKey($key, $description, $key.' should be described'); } } } diff --git a/tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php b/tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php index 0664ffa..29ca841 100644 --- a/tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php +++ b/tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php @@ -1,5 +1,7 @@ filterDependencies(); $filter = new IdFilter($names, $meta, $resolver, null, $properties); - self::assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + $this->assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); } public static function applyProvider(): iterable @@ -62,7 +64,7 @@ public function testGetDescriptionShape(): void $description = $filter->getDescription(self::RESOURCE); - self::assertArrayHasKey('organizer.entityId', $description); - self::assertTrue($description['organizer.entityId']['is_collection']); + $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 index ce5afd7..2fe70ef 100644 --- a/tests/Unit/Api/Filter/ElasticSearch/MatchFilterTest.php +++ b/tests/Unit/Api/Filter/ElasticSearch/MatchFilterTest.php @@ -1,5 +1,7 @@ filterDependencies(); $filter = new MatchFilter($names, $meta, $resolver, null, $properties); - self::assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + $this->assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); } public static function applyProvider(): iterable @@ -66,9 +68,9 @@ public function testGetDescriptionShape(): void $description = $filter->getDescription(self::RESOURCE); - self::assertArrayHasKey('title', $description); - self::assertSame('title', $description['title']['property']); - self::assertSame('string', $description['title']['type']); - self::assertFalse($description['title']['required']); + $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 index 6db4173..73b685b 100644 --- a/tests/Unit/Api/Filter/ElasticSearch/TagFilterTest.php +++ b/tests/Unit/Api/Filter/ElasticSearch/TagFilterTest.php @@ -1,5 +1,7 @@ filterDependencies(); $filter = new TagFilter($names, $meta, $resolver, null, ['tags' => null]); - self::assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + $this->assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); } public static function applyProvider(): iterable @@ -46,7 +48,7 @@ public function testGetDescriptionShape(): void $description = $filter->getDescription(self::RESOURCE); - self::assertArrayHasKey('tags', $description); - self::assertTrue($description['tags']['is_collection']); + $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 index 271df2e..82d7300 100644 --- a/tests/Unit/Service/ElasticSearch/ElasticIndexExceptionTest.php +++ b/tests/Unit/Service/ElasticSearch/ElasticIndexExceptionTest.php @@ -1,5 +1,7 @@ ". public function test400ParsesElasticErrorMessage(): void @@ -20,11 +22,8 @@ public function test400ParsesElasticErrorMessage(): void $exception = new ElasticIndexException($raw, 400); - self::assertSame( - 'Parse exception: failed to parse date field [2004-02-12T15:19:21+0000]', - $exception->getMessage(), - ); - self::assertSame(400, $exception->getCode()); + $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). @@ -32,6 +31,6 @@ public function testNon400CollapsesToBadRequest(): void { $exception = new ElasticIndexException('500 Internal Server Error: something', 500); - self::assertSame('Bad Request', $exception->getMessage()); + $this->assertSame('Bad Request', $exception->getMessage()); } } diff --git a/tests/Unit/Service/ElasticSearch/SearchParamsBuilderTest.php b/tests/Unit/Service/ElasticSearch/SearchParamsBuilderTest.php index c9eb3da..7ba3c20 100644 --- a/tests/Unit/Service/ElasticSearch/SearchParamsBuilderTest.php +++ b/tests/Unit/Service/ElasticSearch/SearchParamsBuilderTest.php @@ -1,5 +1,7 @@ buildParams(IndexName::Events->value, $this->noFilters(), 20, 5); - self::assertSame(IndexName::Events->value, $params['index']); - self::assertEquals(['match_all' => (object) []], $params['body']['query']); - self::assertSame(5, $params['body']['size']); - self::assertSame(20, $params['body']['from']); - self::assertArrayHasKey('sort', $params['body']); + $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. @@ -50,7 +52,7 @@ public function testSingleClauseWrappedInBoolMust(): void 10, ); - self::assertSame(['bool' => ['must' => [['match' => ['title' => 'x']]]]], $params['body']['query']); + $this->assertSame(['bool' => ['must' => [['match' => ['title' => 'x']]]]], $params['body']['query']); } // Goal: a list-shaped clause (e.g. IdFilter output) is flattened into must, not @@ -64,10 +66,7 @@ public function testListClauseIsFlattenedIntoMust(): void 10, ); - self::assertSame( - ['bool' => ['must' => [['terms' => ['organizer.entityId' => ['9'], 'boost' => 1.0]]]]], - $params['body']['query'], - ); + $this->assertSame(['bool' => ['must' => [['terms' => ['organizer.entityId' => ['9'], 'boost' => 1.0]]]]], $params['body']['query']); } // Goal: multiple clauses accumulate under a single bool/must. @@ -83,7 +82,7 @@ public function testMultipleClausesCombine(): void 10, ); - self::assertSame([ + $this->assertSame([ ['match' => ['title' => 'x']], ['terms' => ['tags' => ['aros'], 'boost' => 1.0]], ], $params['body']['query']['bool']['must']); @@ -95,7 +94,7 @@ public function testSortPerIndex(string $index, array $expectedSort): void { $params = (new SearchParamsBuilder())->buildParams($index, $this->noFilters(), 0, 10); - self::assertSame($expectedSort, $params['body']['sort']); + $this->assertSame($expectedSort, $params['body']['sort']); } public static function sortProvider(): iterable 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 @@ Date: Wed, 8 Jul 2026 16:41:26 +0200 Subject: [PATCH 46/63] docs: add CHANGELOG entry for Rector --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d3236..cb2455b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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-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) From c2534e36992c2bdf4ccac90f2d5307b664e4edcc Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 16:46:19 +0200 Subject: [PATCH 47/63] chore: re-export API spec after Rector's constructor promotion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rector promoted the Tag/Vocabulary DTO constructors, so the generated schema now lists properties in constructor-parameter order (name, slug). Cosmetic property reorder only — not a breaking change. --- public/spec.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/public/spec.yaml b/public/spec.yaml index 24d71eb..f3707c6 100644 --- a/public/spec.yaml +++ b/public/spec.yaml @@ -1780,10 +1780,10 @@ components: - type: object properties: - slug: - type: string name: type: string + slug: + type: string Vocabulary.jsonld: allOf: - @@ -1791,10 +1791,10 @@ components: - type: object properties: - slug: - type: string name: type: string + slug: + type: string description: type: string tags: From 47ba1d613dffd8192dd2ef38578617f7721c84bf Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 16:57:51 +0200 Subject: [PATCH 48/63] ci: self-heal the "spec up to date" PR comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The api-spec job posted a 🛑 "spec not up to date" comment on failure but never cleared it once the spec was fixed, leaving a stale failure comment on the PR. Give that comment a sticky marker and, mirroring the breaking-changes comment, edit it to ✅ when the committed spec matches the export again (find previous → upsert on drift → resolve on success). --- .github/workflows/api-spec.yml | 48 ++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/.github/workflows/api-spec.yml b/.github/workflows/api-spec.yml index 0e5bdd8..21e643d 100644 --- a/.github/workflows/api-spec.yml +++ b/.github/workflows/api-spec.yml @@ -50,15 +50,53 @@ jobs: 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' From d339a6a188b6d800d3433d3eb99eb7acea8168b9 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 16:58:48 +0200 Subject: [PATCH 49/63] docs: add CHANGELOG entry for api-spec comment self-heal --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b7891..fce37cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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-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) From 98b14d03de01f1005dd8efe36dc7b5b5115bde6c Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 17:15:32 +0200 Subject: [PATCH 50/63] ci: scope image pulls to the services each job needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every job pulled and started the whole compose stack (mariadb, phpfpm, nginx, mail, rabbit, elasticsearch, …). Scope each to its real deps: - api-spec export, PHPStan, Rector → phpfpm only, as one-off `docker compose run --rm --no-deps phpfpm …` (no lingering container, matching the templated composer/php/twig workflows) - API test → phpfpm + elasticsearch via `up --wait --no-deps` (ES must be a running service the tests connect to) Pull only the needed images; --no-deps stops phpfpm's mariadb/rabbit dependencies from dragging the rest of the stack in. The api-spec job now drives docker compose directly, dropping the Task setup that ran `site:update` (= pull/up everything). Verified: the full suite passes with mariadb and rabbit stopped; tests reference no Doctrine/DB and ApiTestCase uses the in-process kernel, so nginx is not needed either. --- .github/workflows/api-spec.yml | 13 +++++++------ .github/workflows/pr.yaml | 26 ++++++++++++++------------ 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/.github/workflows/api-spec.yml b/.github/workflows/api-spec.yml index 0e5bdd8..6880126 100644 --- a/.github/workflows/api-spec.yml +++ b/.github/workflows/api-spec.yml @@ -34,16 +34,17 @@ jobs: key: vendor-php8.4-${{ hashFiles('composer.lock') }} restore-keys: vendor-php8.4- - # https://taskfile.dev/installation/#github-actions - - uses: go-task/setup-task@v2 - - - 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 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index e6b184a..b83d147 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -23,8 +23,10 @@ jobs: - name: Start docker compose setup and install site run: | docker network create frontend - docker compose pull --quiet - docker compose up --detach --wait + # 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 @@ -58,16 +60,16 @@ jobs: key: vendor-php8.4-${{ hashFiles('composer.lock') }} restore-keys: vendor-php8.4- - - name: Start docker compose setup and install site + - name: Install dependencies run: | docker network create frontend - docker compose pull --quiet - docker compose up --detach --wait - docker compose exec -T phpfpm composer install --no-interaction + # 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: | - docker compose exec -T phpfpm vendor/bin/phpstan analyse --no-progress + docker compose run --rm --no-deps phpfpm vendor/bin/phpstan analyse --no-progress code-analysis-rector: runs-on: ubuntu-latest @@ -82,13 +84,13 @@ jobs: key: vendor-php8.4-${{ hashFiles('composer.lock') }} restore-keys: vendor-php8.4- - - name: Start docker compose setup and install site + - name: Install dependencies run: | docker network create frontend - docker compose pull --quiet - docker compose up --detach --wait - docker compose exec -T phpfpm composer install --no-interaction + # 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 exec -T phpfpm vendor/bin/rector process --dry-run --no-progress-bar + docker compose run --rm --no-deps phpfpm vendor/bin/rector process --dry-run --no-progress-bar From 08a712d0796ac07e71dccbe24165ac5c49c33a08 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 17:19:15 +0200 Subject: [PATCH 51/63] docs: add CHANGELOG entry for scoped CI image pulls --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2138a30..d8c91db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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-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) From 9fcdcf2baa0ffe60876ba461ce471ab7101071f1 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 9 Jul 2026 08:41:58 +0200 Subject: [PATCH 52/63] chore: run Rector from the Claude Code setup Mirror event-database-imports#107's Claude integration for Rector (the CI gate and Taskfile targets already landed in the Rector PR): - PostToolUse hook runs `rector process` on the edited src/tests PHP file before php-cs-fixer, so single-file edits are modernized then formatted. - pr-readiness agent gains a Rector (dry-run) check mirroring the CI gate. - CLAUDE.md notes Rector in the PostToolUse list. --- .claude/agents/pr-readiness.md | 12 +++++++----- .claude/settings.json | 5 +++++ CLAUDE.md | 5 +++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.claude/agents/pr-readiness.md b/.claude/agents/pr-readiness.md index 52b9ee3..8d75b37 100644 --- a/.claude/agents/pr-readiness.md +++ b/.claude/agents/pr-readiness.md @@ -17,11 +17,13 @@ JS/CSS to lint). Stop early if a critical check fails. 5. **YAML coding standards**: `task coding-standards:yaml:check` 6. **Markdown coding standards**: `task coding-standards:markdown:check` 7. **PHPStan (level 6)**: `task code-analysis:phpstan` -8. **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. -9. **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. -10. **CHANGELOG updated**: `git diff develop -- CHANGELOG.md` should show at least one entry under `## [Unreleased]`. +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 diff --git a/.claude/settings.json b/.claude/settings.json index 98d3cae..e552f70 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -117,6 +117,11 @@ { "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", diff --git a/CLAUDE.md b/CLAUDE.md index c1ae5a2..479935f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,8 +152,9 @@ cluster** — no shared database, no HTTP call between them. run tooling **inside the `phpfpm` container**. - **Hooks** — `SessionStart` boots the Docker stack and checks host prerequisites; `PostToolUse` auto-runs - 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 + 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`). From cad1111c0668353e5bcd57666abe3c6c7e07287b Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 9 Jul 2026 08:42:31 +0200 Subject: [PATCH 53/63] docs: add CHANGELOG entry for Rector Claude Code hook --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4d74b..91ee831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [PR-57](https://github.com/itk-dev/event-database-api/pull/57) + Run Rector from the Claude Code setup (PostToolUse hook + pr-readiness step) - [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) From 63839c8af9637ab68476dad4e842b0d7d0bab8a9 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 9 Jul 2026 08:52:11 +0200 Subject: [PATCH 54/63] ci: drop redundant root user from the release workflow Mirror devops_itkdev-docker#116: the phpfpm service runs as ${COMPOSE_USER:-deploy}, so the release job's `--user=root` (and the `COMPOSER_ALLOW_SUPERUSER` that paired with it) are redundant. Set `COMPOSE_USER: runner` at the workflow level and drop both, keeping APP_ENV inline via `-e APP_ENV=prod`. --- .github/workflows/build_release.yml | 10 +++++----- CHANGELOG.md | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index c31aa4f..547097a 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -8,12 +8,12 @@ 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@v7 @@ -21,8 +21,8 @@ jobs: - 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/CHANGELOG.md b/CHANGELOG.md index 91ee831..42870ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] - [PR-57](https://github.com/itk-dev/event-database-api/pull/57) - Run Rector from the Claude Code setup (PostToolUse hook + pr-readiness step) + 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) From de52bf3e2547670e50dd56a451f63fe252bd9253 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 9 Jul 2026 09:01:30 +0200 Subject: [PATCH 55/63] chore: update dependencies (in-constraint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refresh composer.lock to the latest in-constraint releases — the Symfony 7.4.8→7.4.14 patch train plus dev tooling (phpunit 13.2.4, php-cs-fixer, phpstan-symfony, phpdoc-parser, composer-normalize, flex). No constraint changes. Regenerate config/reference.php (a framework-bundle psalm-type annotation moved with the Symfony bump). Full suite, PHPStan, Rector, php-cs-fixer green; public/spec.yaml unchanged. --- composer.lock | 451 ++++++++++++++++++++++--------------------- config/reference.php | 2 +- 2 files changed, 228 insertions(+), 225 deletions(-) diff --git a/composer.lock b/composer.lock index dbd433c..adfaf2e 100644 --- a/composer.lock +++ b/composer.lock @@ -556,22 +556,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.13.2", + "version": "7.14.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "bcd989ad36c92d42a3715379af91f2defee5b8dd" + "reference": "aef242412e13128b5049864867bb49fc37dd39de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/bcd989ad36c92d42a3715379af91f2defee5b8dd", - "reference": "bcd989ad36c92d42a3715379af91f2defee5b8dd", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aef242412e13128b5049864867bb49fc37dd39de", + "reference": "aef242412e13128b5049864867bb49fc37dd39de", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.3", + "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.5 || ^3.0", @@ -664,7 +664,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.13.2" + "source": "https://github.com/guzzle/guzzle/tree/7.14.0" }, "funding": [ { @@ -680,20 +680,20 @@ "type": "tidelift" } ], - "time": "2026-07-05T19:00:11+00:00" + "time": "2026-07-08T22:54:09+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { @@ -748,7 +748,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -764,20 +764,20 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:23:43+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.12.3", + "version": "2.12.4", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" + "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", + "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", "shasum": "" }, "require": { @@ -867,7 +867,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.3" + "source": "https://github.com/guzzle/psr7/tree/2.12.4" }, "funding": [ { @@ -883,7 +883,7 @@ "type": "tidelift" } ], - "time": "2026-06-23T15:21:08+00:00" + "time": "2026-07-08T15:56:20+00:00" }, { "name": "nelmio/cors-bundle", @@ -952,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": { @@ -971,7 +971,7 @@ "symfony/polyfill-php82": "^1.26" }, "conflict": { - "open-telemetry/sdk": "<=1.0.8" + "open-telemetry/sdk": "<=1.11" }, "type": "library", "extra": { @@ -1018,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": { @@ -1073,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", @@ -1386,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": { @@ -1438,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": { @@ -1485,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", @@ -2074,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": { @@ -2154,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": [ { @@ -2174,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": { @@ -2234,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": [ { @@ -2254,7 +2254,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T15:33:14+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/clock", @@ -2336,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": { @@ -2391,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": [ { @@ -2411,7 +2411,7 @@ "type": "tidelift" } ], - "time": "2026-05-03T14:20:49+00:00" + "time": "2026-06-09T07:51:57+00:00" }, { "name": "symfony/console", @@ -2513,16 +2513,16 @@ }, { "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": { @@ -2573,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": [ { @@ -2593,7 +2593,7 @@ "type": "tidelift" } ], - "time": "2026-05-06T11:55:30+00:00" + "time": "2026-06-24T07:41:05+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2668,16 +2668,16 @@ }, { "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": { @@ -2722,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": [ { @@ -2742,7 +2742,7 @@ "type": "tidelift" } ], - "time": "2026-05-11T13:02:51+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "symfony/error-handler", @@ -2993,16 +2993,16 @@ }, { "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": { @@ -3037,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": [ { @@ -3057,7 +3057,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "symfony/filesystem", @@ -3199,16 +3199,16 @@ }, { "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": { @@ -3221,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", @@ -3248,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": [ { @@ -3268,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": { @@ -3331,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": { @@ -3375,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" @@ -3406,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": [ { @@ -3426,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": { @@ -3507,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": [ { @@ -3527,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": { @@ -3589,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": [ { @@ -3609,7 +3609,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:50+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/http-foundation", @@ -4142,16 +4142,16 @@ }, { "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": { @@ -4198,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": [ { @@ -4218,7 +4218,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-05-26T12:45:58+00:00" }, { "name": "symfony/polyfill-php83", @@ -4718,16 +4718,16 @@ }, { "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": { @@ -4735,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", @@ -4778,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": [ { @@ -4798,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": { @@ -4890,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": [ { @@ -4910,7 +4911,7 @@ "type": "tidelift" } ], - "time": "2026-05-15T07:14:02+00:00" + "time": "2026-06-16T15:54:05+00:00" }, { "name": "symfony/security-core", @@ -5535,16 +5536,16 @@ }, { "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": { @@ -5626,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": [ { @@ -5646,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": { @@ -5716,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": [ { @@ -5736,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", @@ -6014,16 +6015,16 @@ }, { "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": { @@ -6071,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": [ { @@ -6091,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", @@ -6182,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": { @@ -6234,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": [ { @@ -6254,7 +6255,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "twig/twig", @@ -6737,16 +6738,16 @@ }, { "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": { @@ -6760,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": { @@ -6817,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", @@ -6976,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": { @@ -7045,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", @@ -7309,16 +7312,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.11", + "version": "v3.95.12", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "35f98e1293283397824d7f349ce5afb8747c3cd5" + "reference": "b1b9055997a98dce3c2338e884626e718a25a923" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/35f98e1293283397824d7f349ce5afb8747c3cd5", - "reference": "35f98e1293283397824d7f349ce5afb8747c3cd5", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b1b9055997a98dce3c2338e884626e718a25a923", + "reference": "b1b9055997a98dce3c2338e884626e718a25a923", "shasum": "" }, "require": { @@ -7358,7 +7361,7 @@ "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.34 || ^10.5.63 || ^11.5.55", + "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" @@ -7402,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.95.11" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.12" }, "funding": [ { @@ -7410,7 +7413,7 @@ "type": "github" } ], - "time": "2026-06-25T14:17:04+00:00" + "time": "2026-07-07T13:29:36+00:00" }, { "name": "justinrainbow/json-schema", @@ -7617,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": { @@ -7634,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": { @@ -7678,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", @@ -8082,16 +8085,16 @@ }, { "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": { @@ -8150,9 +8153,9 @@ ], "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", @@ -8539,16 +8542,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.2.3", + "version": "13.2.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54" + "reference": "8f5180f4627fc1978be2f61d8d9979dbe37e0c10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d76d0e24225e587d6a5f0c6f6d9fef0d90712b54", - "reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8f5180f4627fc1978be2f61d8d9979dbe37e0c10", + "reference": "8f5180f4627fc1978be2f61d8d9979dbe37e0c10", "shasum": "" }, "require": { @@ -8562,7 +8565,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.2.2", + "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", @@ -8619,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/13.2.3" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.4" }, "funding": [ { @@ -8627,7 +8630,7 @@ "type": "other" } ], - "time": "2026-07-06T14:55:56+00:00" + "time": "2026-07-08T08:36:51+00:00" }, { "name": "react/cache", @@ -10376,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": { @@ -10425,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": [ { @@ -10445,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", @@ -10891,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": { @@ -10957,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": [ { @@ -10977,7 +10980,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/reference.php b/config/reference.php index 452b3e6..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 From 5251e6723a7308b622dd2bd09c4e12fc44fe1742 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 9 Jul 2026 09:02:15 +0200 Subject: [PATCH 56/63] docs: add CHANGELOG entry for in-constraint dependency update --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42870ff..8522112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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`) From 24717d6c251c0bfa71815ff4c80c346e459f7eca Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 9 Jul 2026 09:12:50 +0200 Subject: [PATCH 57/63] docs: refresh CLAUDE.md and agent docs for current tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited the docs against the code. Corrections: - Stack: API Platform 4.1 → 4.3, image itkdev/php8.3-fpm → php8.4-fpm. - PHPStan level 6 → 8 (pr-readiness agent + the code-analysis comment, which also now notes Rector). - Drop the stale `ignore_error: true` fixtures rationale from the reload-fixtures agent (removed from the Taskfile in the mappings PR). - Note the CI-enforced index-mapping-drift gate (not just the local Stop hook) and document the test-suite layout (Contract/Consumer/Unit + tests/schemas) that has grown since CLAUDE.md was written. README is accurate (pins no versions) and left as-is. --- .claude/agents/pr-readiness.md | 3 ++- .claude/agents/reload-fixtures.md | 2 +- CLAUDE.md | 15 +++++++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.claude/agents/pr-readiness.md b/.claude/agents/pr-readiness.md index 8d75b37..c5c7205 100644 --- a/.claude/agents/pr-readiness.md +++ b/.claude/agents/pr-readiness.md @@ -16,13 +16,14 @@ JS/CSS to lint). Stop early if a critical check fails. 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 6)**: `task code-analysis:phpstan` +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 diff --git a/.claude/agents/reload-fixtures.md b/.claude/agents/reload-fixtures.md index 3f516b2..1008ce2 100644 --- a/.claude/agents/reload-fixtures.md +++ b/.claude/agents/reload-fixtures.md @@ -22,4 +22,4 @@ Two flavours of fixtures exist (see `src/Model/IndexName.php` for the seven inde - 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 (the Taskfile sets `ignore_error: true` because some fixtures emit a benign `Warning: Undefined array key "entityId"`). +5. Report which indices were loaded and any non-fatal warnings. diff --git a/CLAUDE.md b/CLAUDE.md index 479935f..505c2e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,8 @@ API platform front-end for the Danish event database used by the municipality of `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.1, Elasticsearch 8.x. Runs entirely in Docker via -`itkdev/php8.3-fpm` + nginx. +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 @@ -46,14 +46,21 @@ The test harness creates each index with a **production-parity mapping** (`dynam `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 on drift). +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). ### 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 6 +task code-analysis # PHPStan (level 8) + Rector ``` CI (GitHub Actions `pr.yaml`) runs all of these — run them locally before opening a PR. From 7dc5c25518929b16504455dd0699dcd2c2f0212f Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 9 Jul 2026 09:15:36 +0200 Subject: [PATCH 58/63] docs: add CHANGELOG entry for docs refresh --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8522112..6ca2381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) From 4da346f1c4c8e267ce984b9b5d4b83abab93ee1e Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 9 Jul 2026 10:47:33 +0200 Subject: [PATCH 59/63] chore: upgrade Elasticsearch to 8.19.18 Bump the local Elasticsearch dev image 8.13.0 -> 8.19.18 (latest 8.x) and tighten the elasticsearch/elasticsearch client constraint ^8.13 -> ^8.19 (client already resolves to 8.19.0; composer.lock changes only its content-hash). Coordinated with the shared cluster (event-database-services) and the write side (event-database-imports). PHPStan clean. --- CHANGELOG.md | 2 ++ composer.json | 2 +- composer.lock | 2 +- docker-compose.override.yml | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ca2381..e7869f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) diff --git a/composer.json b/composer.json index c6eaeed..ffeefda 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "ext-ctype": "*", "ext-iconv": "*", "api-platform/core": "~4.3.0", - "elasticsearch/elasticsearch": "^8.13", + "elasticsearch/elasticsearch": "^8.19", "nelmio/cors-bundle": "^2.4", "phpdocumentor/reflection-docblock": "^5.6", "phpstan/phpdoc-parser": "^2.2", diff --git a/composer.lock b/composer.lock index adfaf2e..014ecc7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0de130ed061bef0747c1862bd4a9072a", + "content-hash": "747cb423309e517227c3648b1003fc5a", "packages": [ { "name": "api-platform/core", 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 From 5ce580885ce57727d3da3aecf95586ce02991130 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 9 Jul 2026 10:56:07 +0200 Subject: [PATCH 60/63] ci: fix api-spec no-change detection for oasdiff v0.1.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changelog step's "spec changed?" guard grepped for the literal "no changes", but oasdiff v0.1.x emits "No changelog changes" when the spec is unchanged — the substring never matched, so has_changes was always true and a "⚠️ non-breaking changes" comment posted (body: "No changelog changes") even for PRs that don't touch the spec (e.g. the Elasticsearch bump #60). Match both wordings with a regex so the no-change path is taken and the sticky comment resolves to ✅. --- .github/workflows/api-spec.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/api-spec.yml b/.github/workflows/api-spec.yml index ba00c4a..7dd51eb 100644 --- a/.github/workflows/api-spec.yml +++ b/.github/workflows/api-spec.yml @@ -163,7 +163,9 @@ jobs: - name: Determine whether the spec changed id: changes run: | - if [ -s changelog.md ] && ! grep -qi 'no changes' changelog.md; then + # 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" From 4e3dcfc8e26ba3401e8344247c125fb738190453 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 9 Jul 2026 10:56:37 +0200 Subject: [PATCH 61/63] docs: add CHANGELOG entry for api-spec no-change detection fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7869f9..f63ea1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [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) From 1fbdb8726d6ea5e5a8882b28a788ca7b2749229f Mon Sep 17 00:00:00 2001 From: turegjorup Date: Fri, 10 Jul 2026 09:36:55 +0200 Subject: [PATCH 62/63] docs: document per-resource API test structure in CLAUDE.md --- CLAUDE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 505c2e2..d94f409 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,13 @@ The suite is split into `tests/ApiPlatform/Contract/` (JSON-LD envelope + deep p `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 From 9ed7d8539896d640ed9a4cd1fb92d86bc8952092 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Fri, 10 Jul 2026 11:11:17 +0200 Subject: [PATCH 63/63] chore: release 1.3.0 --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f63ea1c..21f3433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ 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) @@ -155,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