Skip to content

Commit 6234209

Browse files
authored
Merge pull request #27 from itk-dev/feature/claude-code-setup
chore: add Claude Code setup adapted to this repo
2 parents 9d863fc + e7cf497 commit 6234209

7 files changed

Lines changed: 366 additions & 0 deletions

File tree

.claude/agents/pr-readiness.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
name: pr-readiness
3+
description: Run all CI-equivalent checks locally before creating a PR
4+
model: haiku
5+
---
6+
7+
Mirror what `.github/workflows/*.yaml` runs on a PR. Every step executes inside Docker (the project
8+
ships only its `phpfpm` / `prettier` / `markdownlint` services — there is no `node` service and no
9+
JS/CSS to lint). Stop early if a critical check fails.
10+
11+
## Checks
12+
13+
1. **Composer validate**: `docker compose exec -T phpfpm composer validate --strict`
14+
2. **Composer normalize (dry-run)**: `docker compose exec -T phpfpm composer normalize --dry-run`
15+
3. **PHP coding standards**: `task coding-standards:php:check`
16+
4. **Twig coding standards**: `task coding-standards:twig:check`
17+
5. **YAML coding standards**: `task coding-standards:yaml:check`
18+
6. **Markdown coding standards**: `task coding-standards:markdown:check`
19+
7. **PHPStan (level 6)**: `task code-analysis:phpstan`
20+
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.
21+
9. **API spec up to date** (mirrors `.github/workflows/api-spec.yml`):
22+
- `task api:spec:export`
23+
- `git diff --exit-code public/spec.yaml` — must be clean.
24+
10. **CHANGELOG updated**: `git diff develop -- CHANGELOG.md` should show at least one entry under `## [Unreleased]`.
25+
26+
## Output
27+
28+
Report a summary table with columns: Check Name, Status (pass/fail), and error output for failures.

.claude/agents/reload-fixtures.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
name: reload-fixtures
3+
description: Reload Elasticsearch fixtures and recover from a not-ready cluster
4+
model: haiku
5+
---
6+
7+
This project's "data layer" is Elasticsearch — there is no Doctrine database to migrate. After changing
8+
API resources, filters, or anything that affects search behavior you generally want to reload fixtures
9+
so subsequent manual / test runs see a consistent dataset.
10+
11+
Two flavours of fixtures exist (see `src/Model/IndexName.php` for the seven index names):
12+
13+
- **Dev fixtures** — pulled from the `event-database-imports` repo on GitHub: `task fixtures:load`
14+
- **Test fixtures** — read from `tests/resources/*.json` and used by the PHPUnit suite: `task fixtures:load:test --yes`
15+
16+
## Steps
17+
18+
1. Confirm with the user (or take it from their prompt) whether to load **dev** or **test** fixtures.
19+
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.
20+
3. Run the load command (`task fixtures:load` or `task fixtures:load:test --yes`). The Taskfile prompts for confirmation unless `--yes` is passed.
21+
4. If the command fails with "No alive nodes" or any Elasticsearch connection error:
22+
- Poll the cluster health endpoint until it returns HTTP 200:
23+
`docker compose exec elasticsearch curl 'http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=5s' --verbose`
24+
- Re-run the fixture load command.
25+
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"`).

.claude/settings.json

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
{
2+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
3+
"env": {
4+
"COMPOSE_USER": "deploy"
5+
},
6+
"permissions": {
7+
"allow": [
8+
"Bash(cat:*)",
9+
"Bash(diff:*)",
10+
"Bash(echo:*)",
11+
"Bash(find:*)",
12+
"Bash(gh:*)",
13+
"Bash(git:*)",
14+
"Bash(grep:*)",
15+
"Bash(head:*)",
16+
"Bash(ls:*)",
17+
"Bash(pwd)",
18+
"Bash(tail:*)",
19+
"Bash(task:*)",
20+
"Bash(tree:*)",
21+
"Bash(wc:*)",
22+
"Bash(which:*)",
23+
"Bash(docker compose exec:*)",
24+
"Bash(docker compose run:*)",
25+
"Bash(docker compose up:*)",
26+
"Bash(docker compose ps:*)",
27+
"Bash(docker compose logs:*)",
28+
"Bash(docker compose top:*)",
29+
"Bash(docker compose config:*)",
30+
"Bash(docker compose pull:*)",
31+
"Bash(docker compose images:*)",
32+
"Bash(docker network:*)"
33+
],
34+
"deny": [
35+
"Bash(rm -rf:*)",
36+
"Bash(gh issue delete:*)",
37+
"Bash(gh release delete:*)",
38+
"Bash(gh repo delete:*)",
39+
"Bash(gh label delete:*)",
40+
"Read(./.env.local)",
41+
"Read(./.env.local.*)",
42+
"Read(./config/secrets/*)"
43+
],
44+
"ask": [
45+
"Bash(docker compose down:*)",
46+
"Bash(docker compose stop:*)",
47+
"Bash(docker compose rm:*)",
48+
"Bash(docker compose restart:*)",
49+
"Bash(gh issue create:*)",
50+
"Bash(gh issue close:*)",
51+
"Bash(gh issue edit:*)",
52+
"Bash(gh issue comment:*)",
53+
"Bash(gh pr create:*)",
54+
"Bash(gh pr close:*)",
55+
"Bash(gh pr merge:*)",
56+
"Bash(gh pr edit:*)",
57+
"Bash(gh pr comment:*)",
58+
"Bash(gh pr review:*)",
59+
"Bash(gh release create:*)",
60+
"Bash(gh release edit:*)",
61+
"Bash(gh repo create:*)",
62+
"Bash(gh label create:*)",
63+
"Bash(gh label edit:*)",
64+
"Bash(git push:*)",
65+
"Bash(git branch -d:*)",
66+
"Bash(git branch -D:*)",
67+
"Bash(git tag -d:*)",
68+
"Bash(git tag -a:*)",
69+
"Bash(git tag :*)",
70+
"Bash(git reset:*)",
71+
"Bash(git rebase:*)",
72+
"Bash(git merge:*)",
73+
"Bash(git stash drop:*)",
74+
"Bash(git clean:*)",
75+
"Bash(git checkout -- :*)",
76+
"Bash(git restore:*)",
77+
"Bash(git commit:*)"
78+
]
79+
},
80+
"hooks": {
81+
"SessionStart": [
82+
{
83+
"matcher": "startup",
84+
"hooks": [
85+
{
86+
"type": "command",
87+
"command": "docker compose up --detach --quiet-pull 2>/dev/null || true",
88+
"timeout": 60,
89+
"statusMessage": "Starting Docker services..."
90+
}
91+
]
92+
}
93+
],
94+
"PreToolUse": [
95+
{
96+
"matcher": "Edit|Write",
97+
"hooks": [
98+
{
99+
"type": "command",
100+
"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"
101+
}
102+
]
103+
}
104+
],
105+
"PostToolUse": [
106+
{
107+
"matcher": "Write|Edit",
108+
"hooks": [
109+
{
110+
"type": "command",
111+
"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",
112+
"timeout": 30
113+
},
114+
{
115+
"type": "command",
116+
"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",
117+
"timeout": 30
118+
},
119+
{
120+
"type": "command",
121+
"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",
122+
"timeout": 15
123+
},
124+
{
125+
"type": "command",
126+
"command": "case \"$CLAUDE_FILE_PATH\" in */composer.json) docker compose exec -T phpfpm composer normalize --quiet 2>/dev/null || true ;; esac",
127+
"timeout": 30
128+
},
129+
{
130+
"type": "command",
131+
"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",
132+
"timeout": 15
133+
},
134+
{
135+
"type": "command",
136+
"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",
137+
"timeout": 15
138+
}
139+
]
140+
}
141+
],
142+
"Stop": [
143+
{
144+
"hooks": [
145+
{
146+
"type": "command",
147+
"command": "docker compose exec -T phpfpm bin/console lint:container 2>/dev/null || true",
148+
"timeout": 30,
149+
"statusMessage": "Validating Symfony DI container..."
150+
}
151+
]
152+
}
153+
]
154+
},
155+
"enabledPlugins": {
156+
"php-lsp@claude-plugins-official": true,
157+
"code-simplifier@claude-plugins-official": true,
158+
"context7@claude-plugins-official": true,
159+
"code-review@claude-plugins-official": true,
160+
"security-guidance@claude-plugins-official": true,
161+
"playwright@claude-plugins-official": true,
162+
"feature-dev@claude-plugins-official": true,
163+
"itkdev-skills@itkdev-marketplace": true
164+
},
165+
"enabledMcpjsonServers": [
166+
"context7"
167+
],
168+
"enableAllProjectMcpServers": true
169+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
name: update-api-spec
3+
description: Regenerate and stage the OpenAPI spec after API resource changes
4+
user-invocable: true
5+
---
6+
7+
The committed OpenAPI spec lives at `public/spec.yaml` (single YAML file — there is no separate JSON
8+
export). `.github/workflows/api-spec.yml` fails the PR if it drifts from the resources defined under
9+
`src/Api/`, and additionally diffs the spec against the base branch to flag breaking changes.
10+
11+
After touching anything under `src/Api/Dto/`, `src/Api/State/`, or `src/Api/Filter/`:
12+
13+
1. Regenerate the spec: `task api:spec:export`
14+
(This is shorthand for `bin/console api:openapi:export --yaml --output=public/spec.yaml --no-interaction`.)
15+
2. Inspect changes: `git diff public/spec.yaml`
16+
3. If the diff is non-empty, stage the file: `git add public/spec.yaml`
17+
4. Summarise what changed — new/removed operations, modified parameters, schema diffs — so the human
18+
reviewer can sanity-check for unintended breaking changes before pushing.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@
3232
###> phpstan/phpstan ###
3333
phpstan.neon
3434
###< phpstan/phpstan ###
35+
36+
###> claude-code ###
37+
/.claude/settings.local.json
38+
###< claude-code ###

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ See [keep a changelog] for information about writing changes to this log.
88

99
## [Unreleased]
1010

11+
- [PR-27](https://github.com/itk-dev/event-database-api/pull/27)
12+
Add Claude Code project setup (CLAUDE.md, agents, skills)
13+
1114
## [1.2.2] - 2026-05-22
1215

1316
- [PR-26](https://github.com/itk-dev/event-database-api/pull/26)

CLAUDE.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## What this project is
6+
7+
API platform front-end for the Danish event database used by the municipality of Aarhus. This repo serves a
8+
**read-only** REST/Hydra API (`/api/v2/...`) backed by **Elasticsearch** — data is *indexed* by a separate project,
9+
[`itk-dev/event-database-imports`](https://github.com/itk-dev/event-database-imports). The MariaDB service in
10+
`docker-compose.yml` is part of the standard ITK Dev Symfony image but is **not used for domain data** (the
11+
`migrations/` and `src/Entity/` directories are empty).
12+
13+
Stack: PHP 8.3+, Symfony 7.4, API Platform 4.1, Elasticsearch 8.x. Runs entirely in Docker via
14+
`itkdev/php8.3-fpm` + nginx.
15+
16+
## Common commands
17+
18+
All commands are wrapped in `Taskfile.yml` (run via [Task](https://taskfile.dev)). Most are just
19+
`docker compose exec phpfpm …` underneath — useful to know when `task --dry <name>` to see the actual command.
20+
21+
### Setup / running
22+
23+
```shell
24+
docker compose pull
25+
docker compose up --detach --wait # --wait is important: Elasticsearch is slow to become ready
26+
docker compose exec phpfpm composer install
27+
task fixtures:load # loads demo data from event-database-imports into Elasticsearch
28+
```
29+
30+
The site is reachable at `http://$(docker compose port nginx 8080)`. Every API call needs an `X-Api-Key` header
31+
matching one of the entries in `APP_API_KEYS` (JSON array in `.env.local`).
32+
33+
### Tests
34+
35+
```shell
36+
task fixtures:load:test --yes # loads tests/resources/*.json into Elasticsearch — REQUIRED before api:test
37+
task api:test # phpunit
38+
task api:test -- --filter EventsFilter # single test class / pattern
39+
```
40+
41+
Tests hit a real Elasticsearch (no mocking) — see `tests/ApiPlatform/AbstractApiTestCase.php`. The hardcoded test
42+
API key is `test_api_key`. If a test run dies with "No alive nodes", run `docker compose up --detach --wait` and
43+
reload fixtures.
44+
45+
### Lint / static analysis
46+
47+
```shell
48+
task coding-standards:check # markdown + php-cs-fixer + twig-cs-fixer + prettier (yaml)
49+
task coding-standards:apply # auto-fix all of the above
50+
task code-analysis # PHPStan, level 6
51+
```
52+
53+
CI (GitHub Actions `pr.yaml`) runs all of these — run them locally before opening a PR.
54+
55+
### API spec
56+
57+
`public/spec.yaml` is the committed OpenAPI export and is checked in CI. Regenerate after changing any API resource:
58+
59+
```shell
60+
task api:spec:export
61+
```
62+
63+
## Architecture
64+
65+
### Request flow
66+
67+
API Platform resources are **DTOs**, not Doctrine entities. Each resource follows the same pattern — `Event` is the
68+
canonical example:
69+
70+
1. `src/Api/Dto/<Resource>.php``#[ApiResource]` class declaring operations, pagination, and `#[ApiFilter]`
71+
attributes. The `$id` property is identifier-only; real data is filled in by the provider.
72+
2. `src/Api/State/<Resource>RepresentationProvider.php` — implements `ProviderInterface`. Receives the operation +
73+
context, asks `AbstractProvider::getFilters()` to compile the declared `#[ApiFilter]` attributes into
74+
Elasticsearch query fragments, then calls `IndexInterface::search()` and returns a `SearchResults` (collection) or
75+
array (single item).
76+
3. `src/Service/ElasticSearch/ElasticSearchIndex.php` — the only `IndexInterface` implementation. Talks to the
77+
Elasticsearch cluster (`INDEX_URL` env var). Paginated results come back via `ElasticSearchPaginator`.
78+
79+
The seven indices are enumerated in `src/Model/IndexName.php` (`events`, `organizations`, `occurrences`,
80+
`daily_occurrences`, `tags`, `vocabularies`, `locations`). Each index has a matching DTO and provider.
81+
82+
### Filters
83+
84+
Custom Elasticsearch filters live in `src/Api/Filter/ElasticSearch/` (`MatchFilter`, `IdFilter`, `BooleanFilter`,
85+
`DateRangeFilter`, `DateFilter`, `TagFilter`). They implement API Platform's `FilterInterface` but `apply()` returns
86+
ES query DSL rather than mutating a Doctrine queryBuilder. `AbstractProvider::getFilters()` separates filter clauses
87+
from sort clauses via the `SortFilterInterface` marker.
88+
89+
When adding a filter to a resource, attach it with `#[ApiFilter(SomeFilter::class, properties: […])]` on the DTO —
90+
the provider picks them up automatically through `api_platform.filter_locator` (injected via
91+
`config/services.yaml`).
92+
93+
### Auth
94+
95+
Stateless. `src/Security/ApiKeyAuthenticator.php` reads `X-Api-Key`; `ApiUserProvider` validates against the
96+
JSON-encoded `APP_API_KEYS` env var. `/api/v2/docs` is public; everything else under `/api` requires a valid key
97+
(see `config/packages/security.yaml`). There is no role model — any valid key gets full read access.
98+
99+
### What lives where
100+
101+
- `src/Api/Dto/` + `src/Api/State/` + `src/Api/Filter/ElasticSearch/` — the API layer (see above).
102+
- `src/Service/ElasticSearch/` — the only data access. If you find yourself adding a new persistence concern, this
103+
is where it goes.
104+
- `src/Command/FixturesLoadCommand.php` — loads JSON dumps into Elasticsearch (used by `task fixtures:load*`).
105+
- `src/Model/` — value objects / enums (`IndexName`, `FilterType`, `DateLimit`, `SearchResults`, `DateFilterConfig`).
106+
- `config/reference.php` — auto-generated, excluded from PHP-CS-Fixer (see `.php-cs-fixer.dist.php`).
107+
- `tests/resources/*.json` — fixtures used by the test suite; loaded via
108+
`--url=file:///app/tests/resources/<index>.json`.
109+
110+
## Conventions worth knowing
111+
112+
- Don't add Doctrine entities or migrations — domain data is owned by `event-database-imports`. New resources mean
113+
new DTOs + providers, not entities.
114+
- `paginationMaximumItemsPerPage` on a resource caps client-requested page size.
115+
`AbstractProvider::MAX_PAGE_SIZE_FALLBACK = 20` is the safety net when the resource doesn't declare one.
116+
- `failOnDeprecation`, `failOnNotice`, `failOnWarning` are all `true` in `phpunit.dist.xml` — a deprecation warning
117+
will fail the suite.
118+
- PHPStan errors about `App\Api\Dto\*::$id` being unused are intentionally ignored (API Platform writes the property
119+
reflectively).

0 commit comments

Comments
 (0)