From e3ca727ee9858153623ab311e56e78bb5657df7d Mon Sep 17 00:00:00 2001 From: Seth Etter Date: Fri, 3 Jul 2026 13:23:35 -0500 Subject: [PATCH] api: add openapi spec for events output --- main.ts | 2 +- package-lock.json | 101 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + src/api/api.ts | 62 +++++++++++++++++++++++++--- 4 files changed, 160 insertions(+), 6 deletions(-) diff --git a/main.ts b/main.ts index f14afd2..ef87b2d 100644 --- a/main.ts +++ b/main.ts @@ -35,7 +35,7 @@ async function run(): Promise { try { // Serve the HTTP API while the notifier poll loop runs in the background. // Both shut down together on SIGINT/SIGTERM. - await Promise.all([startApi(cfg, aborter), notifier.start()]); + await Promise.all([startApi(cfg, { aborter }), notifier.start()]); } catch (err) { console.error("fatal:", err instanceof Error ? err.message : err); process.exit(1); diff --git a/package-lock.json b/package-lock.json index e2fda0b..1c2e975 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "devict-events", "dependencies": { + "@fastify/swagger": "^9.7.0", "fastify": "^5.8.5" }, "devDependencies": { @@ -130,6 +131,29 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/swagger": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.7.0.tgz", + "integrity": "sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "json-schema-resolver": "^3.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.2" + } + }, "node_modules/@oxfmt/binding-android-arm-eabi": { "version": "0.54.0", "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.54.0.tgz", @@ -921,6 +945,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1024,6 +1065,22 @@ "toad-cache": "^3.7.0" } }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -1075,6 +1132,23 @@ "dequal": "^2.0.3" } }, + "node_modules/json-schema-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", + "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-uri": "^3.0.5", + "rfdc": "^1.1.4" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1118,6 +1192,12 @@ ], "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -1127,6 +1207,12 @@ "node": ">=14.0.0" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, "node_modules/oxfmt": { "version": "0.54.0", "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.54.0.tgz", @@ -1470,6 +1556,21 @@ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/package.json b/package.json index 46817d5..b901793 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "fmt:check": "oxfmt --check" }, "dependencies": { + "@fastify/swagger": "^9.7.0", "fastify": "^5.8.5" }, "devDependencies": { diff --git a/src/api/api.ts b/src/api/api.ts index 1330784..2a8e3be 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,17 +1,69 @@ -// Minimal HTTP API: serves upcoming OpenMeet events as JSON at GET /events. +// HTTP API: serves upcoming OpenMeet events at GET /api/upcoming and the +// generated OpenAPI spec at GET /api/openapi.json. import Fastify from "fastify"; +import swagger from "@fastify/swagger"; import type { Config } from "../config.ts"; -import { getOpenMeetClient } from "../openmeet/openmeet.ts"; +import { getOpenMeetClient, type OpenMeetClient } from "../openmeet/openmeet.ts"; -export async function startApi(cfg: Config, aborter?: AbortController): Promise { +// Mirrors OpenMeetEvent. Declaring it on the route's `response` schema makes +// Fastify serialize only these fields, dropping the rest of the OpenMeet +// payload, and lets @fastify/swagger describe the endpoint. +const upcomingEventsResponse = { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + name: { type: "string" }, + slug: { type: "string" }, + location: { tpe: "string" }, + lat: { tpe: "string" }, + lon: { tpe: "string" }, + startDate: { type: "string", format: "date-time" }, + endDate: { type: "string", format: "date-time" }, + series: { + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + }, + }, + image: { + type: "object", + properties: { + path: { type: "string" }, + }, + }, + }, + required: ["name", "slug", "location", "startDate", "endDate"], + }, +} as const; + +export async function startApi( + cfg: Config, + opts: { aborter?: AbortController; omClient?: OpenMeetClient } = {}, +): Promise { + const { aborter, omClient } = opts; const app = Fastify({ logger: true }); - const om = await getOpenMeetClient(cfg); - app.get("/events", async () => { + await app.register(swagger, { + openapi: { + info: { + title: "devict-events API", + version: "0.1.0", + }, + }, + }); + + const om = omClient ?? (await getOpenMeetClient(cfg)); + + app.get("/api/upcoming", { schema: { response: { 200: upcomingEventsResponse } } }, async () => { return om.getUpcomingEvents(cfg); }); + app.get("/api/openapi.json", async () => app.swagger()); + const port = Number(process.env.API_PORT ?? 3000); const host = process.env.API_HOST ?? "0.0.0.0"; -- knotmirror 0.0.1