diff --git a/AGENTS.md b/AGENTS.md index dd7fa86..3d2a9ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,3 +10,11 @@ 1. **Compose instance is the app under test** — e2e always runs against the docker-compose build behind Traefik (`https://flight.dev.kkhost.pl`), never a local `next dev` on :3000 (dev builds don't render for non-loopback clients, and a second instance splits state). 2. **Update before testing** — deploy current code first: `docker compose up -d --build`. 3. **Remote browsers** — tests run through the shared Playwright server at `192.168.1.4:3000` (Docker image tag must match this repo's @playwright/test version); plain `npx playwright test` handles deploy + readiness + remote connect (see `playwright.config.ts`). + +## API +1. **Live base URL** — `https://flight.dev.kkhost.pl` (the docker-compose build behind Traefik). +2. **Machine-readable spec** — OpenAPI 3.x at `https://flight.dev.kkhost.pl/api/openapi`; current app version at `/api/version`. +3. **Spec lives in code** — generated from `lib/openapi.ts`, served by `app/api/openapi/route.ts`; must be updated whenever an endpoint changes. + +## Other +1. For longer terminal tasks you should use pty_* tools. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4301c0e..a8fd93f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.5.0 — 2026-09-17 10:15 +- New `GET /api/version` (`app/api/version/route.ts`): returns `{ "version": "" }`, version read from package.json via a server-side import (same single-source-of-truth pattern as the header tag) +- OpenAPI 3.1 spec in code: `lib/openapi.ts` exports an object documenting exactly the two public endpoints — `GET /api/airports` (GeoJSON FeatureCollection, with component schemas for feature collection/feature/point geometry/properties) and `GET /api/version`; `info.version` is imported from package.json so it stays in sync automatically; `servers: [{ url: "" }]` means "current origin" +- New `GET /api/openapi` (`app/api/openapi/route.ts`) serves that spec as JSON — machine-readable API docs for AI agents and external tooling (no interactive docs page, no new dependencies) +- AGENTS.md gains a short "## API" section pointing agents at the live base URL and the spec at `https://flight.dev.kkhost.pl/api/openapi`, noting the spec is generated from `lib/openapi.ts` and must be updated whenever endpoints change; this commit also includes the pending local note in AGENTS.md's "## Other" section (use pty_* tools for longer terminal tasks) +- Tests: unit — new `app/api/version/route.test.ts` (200 + JSON version equals package.json's, exact body shape) and `lib/openapi.test.ts` (both paths present, info.version in sync with package.json, empty server url, airports schema refs the FeatureCollection/Feature/Properties component schemas with name/icao/lat/lon); e2e — new `e2e/api.spec.ts`: fetches `/api/version` from the live origin and asserts 200 + version matching package.json read at test time; fetches `/api/openapi` and checks content-type, title, info.version and both paths + ## v0.4.1 — 2026-09-17 08:50 - Map-asset pack (~100 MB, 780 files) removed from git and from the Docker image: `git rm -r --cached public/{styles,fonts,sprites}` (files kept on disk), `.gitignore` now excludes exactly the generated paths (`public/styles/`, `public/fonts/`, `public/sprites/`, `public/map-assets-manifest.json`) — not all of `public/`; `.dockerignore` gets the same four entries; the v0.4.0 runtime-stage `COPY --from=build /app/public ./public` line is gone from the Dockerfile - The pack now reaches the container via a docker-compose volume mount: `./public:/app/public` on the `flight` service — host dir present with assets → served locally; absent/empty → 404s → runtime fallback (below). Establishes the pattern for the future multi-GB tile file (v0.5.0) that must never go into git diff --git a/app/api/openapi/route.ts b/app/api/openapi/route.ts new file mode 100644 index 0000000..4fdbda4 --- /dev/null +++ b/app/api/openapi/route.ts @@ -0,0 +1,5 @@ +import { openApiSpec } from '@/lib/openapi'; + +export async function GET() { + return Response.json(openApiSpec); +} diff --git a/app/api/version/route.test.ts b/app/api/version/route.test.ts new file mode 100644 index 0000000..957bc25 --- /dev/null +++ b/app/api/version/route.test.ts @@ -0,0 +1,18 @@ +import pkg from '@/package.json'; +import { describe, expect, it } from 'vitest'; +import { GET } from './route'; + +describe('GET /api/version', () => { + it('returns 200 with the version sourced from package.json', async () => { + const res = await GET(); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + const body = (await res.json()) as { version: string }; + expect(body.version).toBe(pkg.version); + }); + + it('returns exactly the version field', async () => { + const res = await GET(); + expect(await res.json()).toEqual({ version: pkg.version }); + }); +}); diff --git a/app/api/version/route.ts b/app/api/version/route.ts new file mode 100644 index 0000000..50bafe8 --- /dev/null +++ b/app/api/version/route.ts @@ -0,0 +1,5 @@ +import pkg from '@/package.json'; + +export async function GET() { + return Response.json({ version: pkg.version }); +} diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts new file mode 100644 index 0000000..b985a82 --- /dev/null +++ b/e2e/api.spec.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { test, expect } from '@playwright/test'; + +// Read at test time so the assertions track package.json automatically. +const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string }; + +test('GET /api/version returns the app version from package.json', async ({ page }) => { + const res = await page.request.get('/api/version'); + expect(res.status()).toBe(200); + expect(res.headers()['content-type']).toContain('application/json'); + const body = (await res.json()) as { version: string }; + expect(body.version).toBe(pkg.version); +}); + +test('GET /api/openapi serves the spec with both endpoints and the package.json version', async ({ page }) => { + const res = await page.request.get('/api/openapi'); + expect(res.status()).toBe(200); + expect(res.headers()['content-type']).toContain('application/json'); + const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record }; + expect(spec.info.title).toBe('Flight Ops API'); + expect(spec.info.version).toBe(pkg.version); + expect(Object.keys(spec.paths).sort()).toEqual(['/api/airports', '/api/version']); +}); diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts new file mode 100644 index 0000000..81247cc --- /dev/null +++ b/lib/openapi.test.ts @@ -0,0 +1,49 @@ +import pkg from '@/package.json'; +import { describe, expect, it } from 'vitest'; +import { openApiSpec } from './openapi'; + +describe('OpenAPI spec', () => { + it('documents exactly the two public endpoints', () => { + expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/airports', '/api/version']); + }); + + it('keeps info.version in sync with package.json', () => { + expect(openApiSpec.info.title).toBe('Flight Ops API'); + expect(openApiSpec.info.version).toBe(pkg.version); + }); + + it('uses an empty server url so "try it" targets the current origin', () => { + expect(openApiSpec.servers).toEqual([{ url: '' }]); + }); + + it('describes /api/airports as a GeoJSON FeatureCollection of airport features', () => { + const schema = openApiSpec.paths['/api/airports'].get.responses[200].content['application/json'].schema; + expect(schema.$ref).toBe('#/components/schemas/AirportFeatureCollection'); + + const fc = openApiSpec.components.schemas.AirportFeatureCollection.properties; + expect(fc.features.items.$ref).toBe('#/components/schemas/AirportFeature'); + + const feature = openApiSpec.components.schemas.AirportFeature.properties; + expect(feature.geometry.$ref).toBe('#/components/schemas/PointGeometry'); + expect(feature.properties.$ref).toBe('#/components/schemas/AirportProperties'); + + const props = openApiSpec.components.schemas.AirportProperties.properties; + expect(Object.keys(props).sort()).toEqual(['icao', 'lat', 'lon', 'name']); + expect(props.name.type).toBe('string'); + expect(props.icao.type).toBe('string'); + expect(props.lat.type).toBe('number'); + expect(props.lon.type).toBe('number'); + + const coords = openApiSpec.components.schemas.PointGeometry.properties.coordinates; + expect(coords.items.type).toBe('number'); + }); + + it('describes /api/version as { version } with a string version', () => { + const schema = openApiSpec.paths['/api/version'].get.responses[200].content['application/json'].schema; + expect(schema.$ref).toBe('#/components/schemas/AppVersion'); + + const props = openApiSpec.components.schemas.AppVersion.properties; + expect(Object.keys(props)).toEqual(['version']); + expect(props.version.type).toBe('string'); + }); +}); diff --git a/lib/openapi.ts b/lib/openapi.ts new file mode 100644 index 0000000..570850d --- /dev/null +++ b/lib/openapi.ts @@ -0,0 +1,109 @@ +import pkg from '@/package.json'; + +// OpenAPI 3.1 document for the app's public JSON API, kept in code so it can be +// served machine-readable at /api/openapi (see AGENTS.md "## API"). Must be updated +// whenever an endpoint changes. +export const openApiSpec = { + openapi: '3.1.0', + info: { + title: 'Flight Ops API', + description: + 'JSON API for Flight Ops — EU airport data for the map, plus app metadata.', + // Sourced from package.json so it stays in sync with every version bump. + version: pkg.version, + }, + // Empty url = current origin (the spec is served from the same host that runs the API). + servers: [{ url: '' }], + paths: { + '/api/airports': { + get: { + summary: 'List EU airports', + description: + 'All fixed-wing EU airports (large, medium and seaplane bases) as a GeoJSON FeatureCollection.', + operationId: 'listAirports', + responses: { + 200: { + description: 'GeoJSON FeatureCollection of EU airports', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/AirportFeatureCollection' }, + }, + }, + }, + }, + }, + }, + '/api/version': { + get: { + summary: 'App version', + description: 'The running app version, read from package.json.', + operationId: 'getVersion', + responses: { + 200: { + description: 'Current app version', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/AppVersion' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + AppVersion: { + type: 'object', + properties: { + version: { type: 'string', description: 'Semver of the running build' }, + }, + required: ['version'], + }, + AirportFeatureCollection: { + type: 'object', + properties: { + type: { const: 'FeatureCollection' }, + features: { + type: 'array', + items: { $ref: '#/components/schemas/AirportFeature' }, + }, + }, + required: ['type', 'features'], + }, + AirportFeature: { + type: 'object', + properties: { + type: { const: 'Feature' }, + geometry: { $ref: '#/components/schemas/PointGeometry' }, + properties: { $ref: '#/components/schemas/AirportProperties' }, + }, + required: ['type', 'geometry', 'properties'], + }, + PointGeometry: { + type: 'object', + properties: { + type: { const: 'Point' }, + coordinates: { + type: 'array', + description: '[longitude, latitude]', + items: { type: 'number' }, + minItems: 2, + maxItems: 2, + }, + }, + required: ['type', 'coordinates'], + }, + AirportProperties: { + type: 'object', + properties: { + name: { type: 'string' }, + icao: { type: 'string' }, + lat: { type: 'number' }, + lon: { type: 'number' }, + }, + required: ['name', 'icao', 'lat', 'lon'], + }, + }, + }, +}; diff --git a/package.json b/package.json index b5c9cc9..c0e81aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.4.1", + "version": "0.5.0", "private": true, "scripts": { "dev": "next dev -p 3000",