From 5ad6a08b721e15af606c8502dde15768026e753f Mon Sep 17 00:00:00 2001 From: nitrix Date: Wed, 4 Mar 2026 18:04:55 +0100 Subject: [PATCH] T-5: set up API-first tooling with OpenAPI spec as single source of truth Backend: openapi-generator-maven-plugin generates Spring interfaces and DTOs from the spec. Frontend: openapi-typescript + openapi-fetch provide type-safe API access. Both sides get compile-time contract enforcement from a single api.yaml file. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + backend/pom.xml | 56 ++++ backend/spotbugs-exclude.xml | 10 + .../src/main/resources/application.properties | 1 + backend/src/main/resources/openapi/api.yaml | 37 +++ frontend/package-lock.json | 266 ++++++++++++++++++ frontend/package.json | 7 +- frontend/src/api/client.ts | 4 + spec/setup-tasks.md | 16 +- 9 files changed, 388 insertions(+), 10 deletions(-) create mode 100644 backend/spotbugs-exclude.xml create mode 100644 backend/src/main/resources/openapi/api.yaml create mode 100644 frontend/src/api/client.ts diff --git a/.gitignore b/.gitignore index 97f8603..f93f64b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ replay_pid* node_modules/ dist/ build/ +frontend/src/api/schema.d.ts vite.config.js.timestamp-* vite.config.ts.timestamp-* npm-debug.log* diff --git a/backend/pom.xml b/backend/pom.xml index 99bd545..a830f38 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -32,6 +32,11 @@ spring-boot-starter-actuator + + org.springframework.boot + spring-boot-starter-validation + + org.springframework.boot spring-boot-starter-test @@ -96,6 +101,7 @@ Low true true + spotbugs-exclude.xml @@ -105,6 +111,56 @@ + + org.openapitools + openapi-generator-maven-plugin + 7.20.0 + + + + generate + + + ${project.basedir}/src/main/resources/openapi/api.yaml + spring + de.fete.adapter.in.web.api + de.fete.adapter.in.web.model + true + ApiUtil.java + + true + true + true + true + false + true + true + none + none + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-openapi-sources + generate-sources + + add-source + + + + ${project.build.directory}/generated-sources/openapi/src/main/java + + + + + org.springframework.boot spring-boot-maven-plugin diff --git a/backend/spotbugs-exclude.xml b/backend/spotbugs-exclude.xml new file mode 100644 index 0000000..5fa9938 --- /dev/null +++ b/backend/spotbugs-exclude.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index ea9dbcd..b6ea848 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,4 +1,5 @@ spring.application.name=fete +server.servlet.context-path=/api management.endpoints.web.exposure.include=health management.endpoint.health.show-details=never diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml new file mode 100644 index 0000000..5841564 --- /dev/null +++ b/backend/src/main/resources/openapi/api.yaml @@ -0,0 +1,37 @@ +openapi: 3.1.0 +info: + title: fete API + description: Privacy-focused event announcements and RSVPs + version: 0.1.0 + license: + name: GPL-3.0-or-later + identifier: GPL-3.0-or-later + +servers: + - url: /api + +paths: + /health: + get: + operationId: getHealth + summary: Health check + tags: + - health + responses: + "200": + description: Service is healthy + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + +components: + schemas: + HealthResponse: + type: object + required: + - status + properties: + status: + type: string + example: UP diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 824377a..f0ce2e2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "openapi-fetch": "^0.17.0", "vue": "^3.5.29", "vue-router": "^5.0.3" }, @@ -27,6 +28,7 @@ "jiti": "^2.6.1", "jsdom": "^28.1.0", "npm-run-all2": "^8.0.4", + "openapi-typescript": "^7.13.0", "oxlint": "~1.50.0", "prettier": "3.8.1", "typescript": "~5.9.3", @@ -1778,6 +1780,89 @@ "dev": true, "license": "MIT" }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.10", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.10.tgz", + "integrity": "sha512-XCBR/9WHJ0cpezuunHMZjuFMl4KqUo7eiFwzrQrvm7lTXt0EBd3No8UY+9OyzXpDfreGEMMtxmaLZ+ksVw378g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", @@ -3046,6 +3131,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -3082,6 +3177,13 @@ "node": ">=14" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3280,6 +3382,13 @@ "node": ">=18" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -3315,6 +3424,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -4230,6 +4346,19 @@ "node": ">=0.8.19" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -4403,6 +4532,16 @@ "node": ">=14" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4410,6 +4549,19 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "28.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", @@ -4926,6 +5078,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5028,6 +5216,24 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -5147,6 +5353,16 @@ "pathe": "^2.0.3" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -5629,6 +5845,19 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -5803,6 +6032,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -5959,6 +6201,13 @@ "punycode": "^2.1.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6673,6 +6922,23 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2f9d636..cddac1a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,8 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", - "build": "run-p type-check \"build-only {@}\" --", + "generate:api": "openapi-typescript ../backend/src/main/resources/openapi/api.yaml -o src/api/schema.d.ts", + "dev": "npm run generate:api && vite", + "build": "npm run generate:api && run-p type-check \"build-only {@}\" --", "preview": "vite preview", "test:unit": "vitest", "build-only": "vite build", @@ -16,6 +17,7 @@ "format": "prettier --write --experimental-cli src/" }, "dependencies": { + "openapi-fetch": "^0.17.0", "vue": "^3.5.29", "vue-router": "^5.0.3" }, @@ -35,6 +37,7 @@ "jiti": "^2.6.1", "jsdom": "^28.1.0", "npm-run-all2": "^8.0.4", + "openapi-typescript": "^7.13.0", "oxlint": "~1.50.0", "prettier": "3.8.1", "typescript": "~5.9.3", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..52bc2df --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,4 @@ +import createClient from "openapi-fetch"; +import type { paths } from "./schema"; + +export const api = createClient({ baseUrl: "/api" }); diff --git a/spec/setup-tasks.md b/spec/setup-tasks.md index 7f4284b..6ecc6a7 100644 --- a/spec/setup-tasks.md +++ b/spec/setup-tasks.md @@ -60,14 +60,14 @@ **Description:** Set up the API-first development workflow. The OpenAPI spec is the single source of truth for the REST API contract. Backend server interfaces and frontend TypeScript types are generated from it. This task scaffolds the tooling and creates a minimal initial spec — the spec itself is a living document that grows with each user story. **Acceptance Criteria:** -- [ ] `openapi-generator-maven-plugin` (v7.20.x, `spring` generator, `interfaceOnly: true`) is configured in `backend/pom.xml` -- [ ] A minimal OpenAPI 3.1 spec exists at `backend/src/main/resources/openapi/api.yaml` (info block, placeholder path, or health-only — enough for the generator to run) -- [ ] `mvnw compile` generates Java interfaces and model classes into `target/generated-sources/openapi/` with packages `de.fete.adapter.in.web.api` and `de.fete.adapter.in.web.model` -- [ ] `openapi-typescript` (devDependency) and `openapi-fetch` (dependency) are installed in the frontend -- [ ] `npm run generate:api` generates TypeScript types from the spec into `frontend/src/api/schema.d.ts` -- [ ] Frontend `dev` and `build` scripts include type generation as a pre-step -- [ ] A minimal API client (`frontend/src/api/client.ts`) using `openapi-fetch` with `createClient()` exists -- [ ] Both generation steps succeed and the project compiles cleanly (backend + frontend) +- [x] `openapi-generator-maven-plugin` (v7.20.x, `spring` generator, `interfaceOnly: true`) is configured in `backend/pom.xml` +- [x] A minimal OpenAPI 3.1 spec exists at `backend/src/main/resources/openapi/api.yaml` (info block, placeholder path, or health-only — enough for the generator to run) +- [x] `mvnw compile` generates Java interfaces and model classes into `target/generated-sources/openapi/` with packages `de.fete.adapter.in.web.api` and `de.fete.adapter.in.web.model` +- [x] `openapi-typescript` (devDependency) and `openapi-fetch` (dependency) are installed in the frontend +- [x] `npm run generate:api` generates TypeScript types from the spec into `frontend/src/api/schema.d.ts` +- [x] Frontend `dev` and `build` scripts include type generation as a pre-step +- [x] A minimal API client (`frontend/src/api/client.ts`) using `openapi-fetch` with `createClient()` exists +- [x] Both generation steps succeed and the project compiles cleanly (backend + frontend) **Dependencies:** T-1