From 6973b0c24c731ad13fb2cbd99a3334626975f815 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Thu, 12 Mar 2026 23:59:52 +0100 Subject: [PATCH] feat: auto-generate discoverable OpenAPI spec from route tree Adds a script that scans src/routes/api/ for exported HTTP methods and generates an OpenAPI 3.1 spec at /api/openapi.json. Runs automatically before each build. Co-Authored-By: Claude Opus 4.6 --- package.json | 3 +- scripts/generate-openapi.ts | 147 +++++++ static/api/openapi.json | 796 ++++++++++++++++++++++++++++++++++++ 3 files changed, 945 insertions(+), 1 deletion(-) create mode 100644 scripts/generate-openapi.ts create mode 100644 static/api/openapi.json diff --git a/package.json b/package.json index d3eb056..c40a699 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ }, "scripts": { "dev": "vite dev", - "build": "vite build", + "build": "tsx scripts/generate-openapi.ts && vite build", + "openapi": "tsx scripts/generate-openapi.ts", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts new file mode 100644 index 0000000..fcc4b0a --- /dev/null +++ b/scripts/generate-openapi.ts @@ -0,0 +1,147 @@ +/** + * Auto-generates an OpenAPI spec from the SvelteKit route tree. + * + * Scans all +server.ts files under src/routes/api/ for exported HTTP methods and + * builds a minimal OpenAPI 3.1 spec at static/.well-known/openapi.json. + * + * Usage: npx tsx scripts/generate-openapi.ts + */ + +import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs'; +import { resolve, relative, dirname, join } from 'path'; + +const ROOT = process.cwd(); +const ROUTES_DIR = resolve(ROOT, 'src/routes/api'); +const OUTPUT = resolve(ROOT, 'static/api/openapi.json'); + +const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8')); + +function walkDir(dir: string): string[] { + const results: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + results.push(...walkDir(full)); + } else if (entry === '+server.ts') { + results.push(full); + } + } + return results; +} + +const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; +const METHOD_RE = new RegExp(`export\\s+const\\s+(${HTTP_METHODS.join('|')})`, 'g'); + +function svelteKitPathToOpenAPI(filePath: string): string { + const rel = relative(ROUTES_DIR, dirname(filePath)); + const segments = rel === '' ? [] : rel.split('/'); + const apiPath = + '/api/' + + segments + .map((s) => { + const match = s.match(/^\[(.+)\]$/); + return match ? `{${match[1]}}` : s; + }) + .join('/'); + return apiPath; +} + +function extractMethods(filePath: string): string[] { + const content = readFileSync(filePath, 'utf-8'); + const methods: string[] = []; + let match; + while ((match = METHOD_RE.exec(content)) !== null) { + methods.push(match[1].toLowerCase()); + } + METHOD_RE.lastIndex = 0; + return methods; +} + +function inferTag(path: string): string { + const first = path.replace('/api/', '').split('/')[0]; + return first.charAt(0).toUpperCase() + first.slice(1); +} + +function extractPathParams(path: string) { + const params: { name: string; in: string; required: boolean; schema: { type: string } }[] = []; + const re = /\{(\w+)\}/g; + let match; + while ((match = re.exec(path)) !== null) { + params.push({ name: match[1], in: 'path', required: true, schema: { type: 'string' } }); + } + return params; +} + +const files = walkDir(ROUTES_DIR); +const paths: Record> = {}; + +for (const fullPath of files) { + const apiPath = svelteKitPathToOpenAPI(fullPath); + const methods = extractMethods(fullPath); + + if (methods.length === 0) continue; + + const pathParams = extractPathParams(apiPath); + const pathEntry: Record = {}; + + if (pathParams.length > 0) { + pathEntry.parameters = pathParams; + } + + for (const method of methods) { + const operation: Record = { + tags: [inferTag(apiPath)], + responses: { + '200': { description: 'Success' } + } + }; + + if (['post', 'put', 'patch'].includes(method)) { + operation.requestBody = { + content: { + 'application/json': { + schema: { type: 'object' } + } + } + }; + } + + pathEntry[method] = operation; + } + + paths[apiPath] = pathEntry; +} + +const sortedPaths: Record> = {}; +for (const key of Object.keys(paths).sort()) { + sortedPaths[key] = paths[key]; +} + +const spec = { + openapi: '3.1.0', + info: { + title: `${pkg.name.charAt(0).toUpperCase() + pkg.name.slice(1)} API`, + description: `Auto-generated API spec for ${pkg.name}`, + version: pkg.version, + contact: { + name: 'Bretzel', + url: 'https://bretzel.app' + } + }, + servers: [{ url: '/', description: 'Current instance' }], + security: [{ sessionCookie: [] }], + paths: sortedPaths, + components: { + securitySchemes: { + sessionCookie: { + type: 'apiKey', + in: 'cookie', + name: 'session' + } + } + } +}; + +mkdirSync(dirname(OUTPUT), { recursive: true }); +writeFileSync(OUTPUT, JSON.stringify(spec, null, 2) + '\n'); +console.log(`Generated ${relative(ROOT, OUTPUT)} with ${Object.keys(paths).length} paths`); diff --git a/static/api/openapi.json b/static/api/openapi.json new file mode 100644 index 0000000..4be26d7 --- /dev/null +++ b/static/api/openapi.json @@ -0,0 +1,796 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Crumbs API", + "description": "Auto-generated API spec for crumbs", + "version": "0.21.9", + "contact": { + "name": "Bretzel", + "url": "https://bretzel.app" + } + }, + "servers": [ + { + "url": "/", + "description": "Current instance" + } + ], + "security": [ + { + "sessionCookie": [] + } + ], + "paths": { + "/api/admin/users": { + "get": { + "tags": [ + "Admin" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "tags": [ + "Admin" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/admin/users/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "patch": { + "tags": [ + "Admin" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "delete": { + "tags": [ + "Admin" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/auth/account": { + "delete": { + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/auth/oauth/{provider}": { + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/auth/oauth/{provider}/callback": { + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/auth/password": { + "put": { + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/auth/profile": { + "put": { + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/auth/sessions": { + "get": { + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/auth/setup": { + "post": { + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/mcp": { + "post": { + "tags": [ + "Mcp" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "get": { + "tags": [ + "Mcp" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "Mcp" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/notes": { + "get": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/notes/reorder": { + "post": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/notes/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "patch": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "delete": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/notes/{id}/attachments": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "patch": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "delete": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/notes/{id}/collaborators": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "delete": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/notes/{id}/versions": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/notes/{id}/versions/{versionId}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "versionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/notes/{id}/versions/{versionId}/restore": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "versionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "Notes" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/preferences": { + "get": { + "tags": [ + "Preferences" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "put": { + "tags": [ + "Preferences" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/search": { + "get": { + "tags": [ + "Search" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/settings/api-keys": { + "get": { + "tags": [ + "Settings" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "tags": [ + "Settings" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "/api/settings/api-keys/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "delete": { + "tags": [ + "Settings" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/sync": { + "post": { + "tags": [ + "Sync" + ], + "responses": { + "200": { + "description": "Success" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "get": { + "tags": [ + "Sync" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/tags": { + "get": { + "tags": [ + "Tags" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/users/search": { + "get": { + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + } + }, + "components": { + "securitySchemes": { + "sessionCookie": { + "type": "apiKey", + "in": "cookie", + "name": "session" + } + } + } +}