From 83434ba1625baf2ac5078be1a096f7d4be1171c4 Mon Sep 17 00:00:00 2001 From: monil80 Date: Sun, 17 May 2026 12:33:02 +0530 Subject: [PATCH] feat: add dynamic OpenAPI/Swagger documentation --- README.md | 11 ++ src/app.test.ts | 23 +++ src/app.ts | 16 +- src/bin.ts | 17 +- src/openapi.test.ts | 55 +++++++ src/openapi.ts | 370 ++++++++++++++++++++++++++++++++++++++++++++ views/index.html | 3 + views/swagger.html | 39 +++++ 8 files changed, 531 insertions(+), 3 deletions(-) create mode 100644 src/openapi.test.ts create mode 100644 src/openapi.ts create mode 100644 views/swagger.html diff --git a/README.md b/README.md index 2d894e32b..23700fecf 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,17 @@ $ curl http://localhost:3000/posts/1 Run `json-server --help` for a list of options +## API documentation (Swagger) + +When the server is running, open [http://localhost:3000/docs](http://localhost:3000/docs) for interactive Swagger UI documentation. + +The OpenAPI specification is generated dynamically from your `db.json` and updates when resources change: + +- `GET /docs` — Swagger UI +- `GET /openapi.json` — OpenAPI 3.0 spec + +Disable with `--no-swagger` on the CLI, or pass `{ swagger: false }` to `createApp`. + ## Sponsors ✨ | Sponsors | diff --git a/src/app.test.ts b/src/app.test.ts index 7faf530a6..4f952c819 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -67,6 +67,8 @@ await test('createApp', async (t) => { const arr: Test[] = [ // Static { method: 'GET', url: '/', statusCode: 200 }, + { method: 'GET', url: '/docs', statusCode: 200 }, + { method: 'GET', url: '/openapi.json', statusCode: 200 }, { method: 'GET', url: '/test.html', statusCode: 200 }, { method: 'GET', url: `/${file}`, statusCode: 200 }, @@ -127,3 +129,24 @@ await test('createApp', async (t) => { }) } }) + +await test('createApp with swagger disabled', async () => { + const noSwaggerPort = await getPort() + const noSwaggerDb = new Low(new Memory(), {}) + noSwaggerDb.data = { posts: [{ id: '1', title: 'foo' }] } + const noSwaggerApp = createApp(noSwaggerDb, { swagger: false }) + + await new Promise((resolve, reject) => { + try { + const server = noSwaggerApp.listen(noSwaggerPort, () => resolve()) + test.after(() => server.close()) + } catch (err) { + reject(err) + } + }) + + const docs = await fetch(`http://localhost:${noSwaggerPort}/docs`) + const spec = await fetch(`http://localhost:${noSwaggerPort}/openapi.json`) + assert.equal(docs.status, 404) + assert.equal(spec.status, 404) +}) diff --git a/src/app.ts b/src/app.ts index b8c5e79e7..0def29c4b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,7 @@ import { Low } from 'lowdb' import { json } from 'milliparsec' import sirv from 'sirv' +import { generateOpenAPISpec } from './openapi.js' import { Data, isItem, Service } from './service.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -16,6 +17,7 @@ const isProduction = process.env['NODE_ENV'] === 'production' export type AppOptions = { logger?: boolean static?: string[] + swagger?: boolean } const eta = new Eta({ @@ -51,10 +53,22 @@ export function createApp(db: Low, options: AppOptions = {}) { // @ts-expect-error expected app.use(json()) + const swaggerEnabled = options.swagger !== false + app.get('/', (_req, res) => - res.send(eta.render('index.html', { data: db.data })), + res.send( + eta.render('index.html', { data: db.data, swagger: swaggerEnabled }), + ), ) + if (swaggerEnabled) { + app.get('/docs', (_req, res) => res.send(eta.render('swagger.html', {}))) + + app.get('/openapi.json', (_req, res) => { + res.json(generateOpenAPISpec(db.data)) + }) + } + app.get('/:name', (req, res, next) => { const { name = '' } = req.params const query = Object.fromEntries( diff --git a/src/bin.ts b/src/bin.ts index 4633e5e43..589b57936 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -22,6 +22,7 @@ Options: -p, --port Port (default: 3000) -h, --host Host (default: localhost) -s, --static Static files directory (multiple allowed) + --no-swagger Disable /docs and /openapi.json --help Show this message --version Show version number `) @@ -33,6 +34,7 @@ function args(): { port: number host: string static: string[] + swagger: boolean } { try { const { values, positionals } = parseArgs({ @@ -64,6 +66,9 @@ function args(): { type: 'boolean', short: 'w', }, + 'no-swagger': { + type: 'boolean', + }, }, allowPositionals: true, }) @@ -100,6 +105,7 @@ function args(): { port: parseInt(values.port as string), host: values.host as string, static: values.static as string[], + swagger: !values['no-swagger'], } } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') { @@ -112,7 +118,7 @@ function args(): { } } -const { file, port, host, static: staticArr } = args() +const { file, port, host, static: staticArr, swagger } = args() if (!existsSync(file)) { console.log(chalk.red(`File ${file} not found`)) @@ -140,7 +146,7 @@ const db = new Low(observer, {}) await db.read() // Create app -const app = createApp(db, { logger: false, static: staticArr }) +const app = createApp(db, { logger: false, static: staticArr, swagger }) function logRoutes(data: Data) { console.log(chalk.bold('Endpoints:')) @@ -178,6 +184,13 @@ app.listen(port, () => { chalk.bold('Index:'), chalk.gray(`http://localhost:${port}/`), '', + ...(swagger + ? [ + chalk.bold('API docs:'), + chalk.gray(`http://localhost:${port}/docs`), + '', + ] + : []), chalk.bold('Static files:'), chalk.gray('Serving ./public directory if it exists'), '', diff --git a/src/openapi.test.ts b/src/openapi.test.ts new file mode 100644 index 000000000..a1fd10a47 --- /dev/null +++ b/src/openapi.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { generateOpenAPISpec } from './openapi.js' + +test('generateOpenAPISpec', async (t) => { + await t.test('generates paths for collections and singletons', () => { + const spec = generateOpenAPISpec({ + posts: [ + { id: '1', title: 'hello', views: 10 }, + { id: '2', title: 'world', views: 20 }, + ], + profile: { name: 'typicode' }, + }) + + assert.equal(spec.openapi, '3.0.3') + assert.ok(spec.paths['/posts']?.get) + assert.ok(spec.paths['/posts']?.post) + assert.ok(spec.paths['/posts/{id}']?.get) + assert.ok(spec.paths['/posts/{id}']?.delete) + assert.ok(spec.paths['/profile']?.get) + assert.ok(spec.paths['/profile']?.put) + assert.equal(spec.paths['/profile']?.delete, undefined) + + assert.ok(spec.components.schemas['posts']) + assert.equal(spec.components.schemas['posts']?.properties?.['id']?.type, 'string') + assert.ok(spec.components.schemas['profile']) + }) + + await t.test('includes filter query params from item fields', () => { + const spec = generateOpenAPISpec({ + posts: [{ id: '1', title: 'a', views: 1 }], + }) + + const getOp = spec.paths['/posts']?.get + const paramNames = getOp?.parameters?.map((p) => p.name) ?? [] + + assert.ok(paramNames.includes('title')) + assert.ok(paramNames.includes('views')) + assert.ok(paramNames.includes('views_gt')) + assert.ok(paramNames.includes('_page')) + assert.ok(paramNames.includes('_embed')) + }) + + await t.test('reflects database changes', () => { + const data = { users: [{ id: '1', email: 'a@b.c' }] } + const spec = generateOpenAPISpec(data) + assert.ok(spec.paths['/users']) + + data.users = [] + const emptySpec = generateOpenAPISpec(data) + assert.ok(emptySpec.paths['/users']) + assert.ok(emptySpec.components.schemas['users']) + }) +}) diff --git a/src/openapi.ts b/src/openapi.ts new file mode 100644 index 000000000..decd4fcc8 --- /dev/null +++ b/src/openapi.ts @@ -0,0 +1,370 @@ +import type { Data, Item } from './service.js' + +type JsonSchema = { + type?: string + properties?: Record + items?: JsonSchema + nullable?: boolean + description?: string +} + +type OpenAPIParameter = { + name: string + in: 'path' | 'query' + description?: string + schema: JsonSchema + required?: boolean +} + +type OpenAPIOperation = { + summary?: string + description?: string + parameters?: OpenAPIParameter[] + requestBody?: { + required?: boolean + content: { 'application/json': { schema: JsonSchema } } + } + responses: Record< + string, + { description: string; content?: { 'application/json': { schema: JsonSchema } } } + > +} + +type OpenAPIPathItem = Partial> + +export type OpenAPISpec = { + openapi: '3.0.3' + info: { + title: string + version: string + description: string + } + paths: Record + components: { + schemas: Record + } +} + +const COLLECTION_QUERY_PARAMS: OpenAPIParameter[] = [ + { + name: '_embed', + in: 'query', + description: 'Embed related resources (comma-separated)', + schema: { type: 'string' }, + }, + { + name: '_sort', + in: 'query', + description: 'Sort fields (prefix with - for descending)', + schema: { type: 'string' }, + }, + { + name: '_start', + in: 'query', + description: 'Range start index', + schema: { type: 'integer' }, + }, + { + name: '_end', + in: 'query', + description: 'Range end index', + schema: { type: 'integer' }, + }, + { + name: '_limit', + in: 'query', + description: 'Maximum number of items', + schema: { type: 'integer' }, + }, + { + name: '_page', + in: 'query', + description: 'Page number (use with _per_page)', + schema: { type: 'integer' }, + }, + { + name: '_per_page', + in: 'query', + description: 'Items per page (default: 10)', + schema: { type: 'integer' }, + }, +] + +function inferSchema(value: unknown): JsonSchema { + if (value === null) { + return { nullable: true } + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return { type: 'array', items: {} } + } + return { type: 'array', items: inferSchema(value[0]) } + } + + switch (typeof value) { + case 'string': + return { type: 'string' } + case 'number': + return Number.isInteger(value) ? { type: 'integer' } : { type: 'number' } + case 'boolean': + return { type: 'boolean' } + case 'object': { + const properties: Record = {} + for (const [key, nested] of Object.entries(value as Item)) { + properties[key] = inferSchema(nested) + } + return { type: 'object', properties } + } + default: + return {} + } +} + +function mergeSchemas(a: JsonSchema, b: JsonSchema): JsonSchema { + if (a.type === 'object' && b.type === 'object') { + const properties = { ...a.properties } + for (const [key, schema] of Object.entries(b.properties ?? {})) { + properties[key] = properties[key] + ? mergeSchemas(properties[key], schema) + : schema + } + return { type: 'object', properties } + } + return a +} + +function inferItemSchema(items: Item[]): JsonSchema { + const base: JsonSchema = { + type: 'object', + properties: { + id: { type: 'string', description: 'Resource identifier' }, + }, + } + + const sample = items.slice(0, 5) + for (const item of sample) { + const itemSchema = inferSchema(item) + if (itemSchema.type === 'object' && itemSchema.properties) { + base.properties = mergeSchemas( + { type: 'object', properties: base.properties }, + itemSchema, + ).properties + } + } + + return base +} + +function filterQueryParams(schema: JsonSchema): OpenAPIParameter[] { + const params: OpenAPIParameter[] = [] + for (const [name, fieldSchema] of Object.entries(schema.properties ?? {})) { + if (name === 'id') continue + + params.push({ + name, + in: 'query', + description: 'Filter by exact match', + schema: fieldSchema, + }) + + if (fieldSchema.type === 'integer' || fieldSchema.type === 'number') { + for (const suffix of ['_lt', '_lte', '_gt', '_gte', '_ne']) { + params.push({ + name: `${name}${suffix}`, + in: 'query', + description: `Filter: ${suffix.slice(1)}`, + schema: fieldSchema, + }) + } + } + } + return params +} + +function jsonResponse( + schema: JsonSchema, + description = 'Successful response', +): OpenAPIOperation['responses'] { + return { + '200': { + description, + content: { 'application/json': { schema } }, + }, + '404': { description: 'Resource not found' }, + } +} + +function collectionPaths( + name: string, + itemSchema: JsonSchema, +): Record { + const ref = { $ref: `#/components/schemas/${name}` } as unknown as JsonSchema + const listSchema: JsonSchema = { type: 'array', items: ref } + const paginatedSchema: JsonSchema = { + type: 'object', + properties: { + first: { type: 'integer' }, + prev: { type: 'integer', nullable: true }, + next: { type: 'integer', nullable: true }, + last: { type: 'integer' }, + pages: { type: 'integer' }, + items: { type: 'integer' }, + data: listSchema, + }, + } + + const filterParams = filterQueryParams(itemSchema) + + return { + [`/${name}`]: { + get: { + summary: `List ${name}`, + parameters: [...COLLECTION_QUERY_PARAMS, ...filterParams], + responses: { + '200': { + description: 'Array of resources or paginated result', + content: { + 'application/json': { + schema: { + oneOf: [listSchema, paginatedSchema], + } as unknown as JsonSchema, + }, + }, + }, + '404': { description: 'Resource not found' }, + }, + }, + post: { + summary: `Create ${name.slice(0, -1) || name}`, + description: 'id is generated automatically if omitted', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: Object.fromEntries( + Object.entries(itemSchema.properties ?? {}).filter( + ([key]) => key !== 'id', + ), + ), + }, + }, + }, + }, + responses: { + '201': { + description: 'Created resource', + content: { 'application/json': { schema: ref } }, + }, + '404': { description: 'Resource not found' }, + }, + }, + }, + [`/${name}/{id}`]: { + get: { + summary: `Get ${name.slice(0, -1) || name} by id`, + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ...COLLECTION_QUERY_PARAMS.filter((p) => p.name === '_embed'), + ], + responses: jsonResponse(ref), + }, + put: { + summary: `Replace ${name.slice(0, -1) || name}`, + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { 'application/json': { schema: ref } }, + }, + responses: jsonResponse(ref), + }, + patch: { + summary: `Update ${name.slice(0, -1) || name}`, + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { 'application/json': { schema: ref } }, + }, + responses: jsonResponse(ref), + }, + delete: { + summary: `Delete ${name.slice(0, -1) || name}`, + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { + name: '_dependent', + in: 'query', + description: 'Related resources to delete when foreign key is nullified', + schema: { type: 'string' }, + }, + ], + responses: jsonResponse(ref), + }, + }, + } +} + +function singletonPaths(name: string): Record { + const ref = { $ref: `#/components/schemas/${name}` } as unknown as JsonSchema + + return { + [`/${name}`]: { + get: { + summary: `Get ${name}`, + responses: jsonResponse(ref), + }, + put: { + summary: `Replace ${name}`, + requestBody: { + required: true, + content: { 'application/json': { schema: ref } }, + }, + responses: jsonResponse(ref), + }, + patch: { + summary: `Update ${name}`, + requestBody: { + required: true, + content: { 'application/json': { schema: ref } }, + }, + responses: jsonResponse(ref), + }, + }, + } +} + +export function generateOpenAPISpec( + data: Data, + options: { title?: string; version?: string } = {}, +): OpenAPISpec { + const schemas: Record = {} + const paths: Record = {} + + for (const [name, value] of Object.entries(data)) { + if (Array.isArray(value)) { + schemas[name] = inferItemSchema(value) + Object.assign(paths, collectionPaths(name, schemas[name])) + } else if (value && typeof value === 'object') { + schemas[name] = inferSchema(value) + Object.assign(paths, singletonPaths(name)) + } + } + + return { + openapi: '3.0.3', + info: { + title: options.title ?? 'JSON Server API', + version: options.version ?? '1.0.0', + description: + 'OpenAPI specification generated dynamically from your JSON database.', + }, + paths, + components: { schemas }, + } +} diff --git a/views/index.html b/views/index.html index 96f63c8eb..578bc3aa7 100644 --- a/views/index.html +++ b/views/index.html @@ -68,6 +68,9 @@