Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
23 changes: 23 additions & 0 deletions src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },

Expand Down Expand Up @@ -127,3 +129,24 @@ await test('createApp', async (t) => {
})
}
})

await test('createApp with swagger disabled', async () => {
const noSwaggerPort = await getPort()
const noSwaggerDb = new Low<Data>(new Memory<Data>(), {})
noSwaggerDb.data = { posts: [{ id: '1', title: 'foo' }] }
const noSwaggerApp = createApp(noSwaggerDb, { swagger: false })

await new Promise<void>((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)
})
16 changes: 15 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -16,6 +17,7 @@ const isProduction = process.env['NODE_ENV'] === 'production'
export type AppOptions = {
logger?: boolean
static?: string[]
swagger?: boolean
}

const eta = new Eta({
Expand Down Expand Up @@ -51,10 +53,22 @@ export function createApp(db: Low<Data>, 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(
Expand Down
17 changes: 15 additions & 2 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Options:
-p, --port <port> Port (default: 3000)
-h, --host <host> Host (default: localhost)
-s, --static <dir> Static files directory (multiple allowed)
--no-swagger Disable /docs and /openapi.json
--help Show this message
--version Show version number
`)
Expand All @@ -33,6 +34,7 @@ function args(): {
port: number
host: string
static: string[]
swagger: boolean
} {
try {
const { values, positionals } = parseArgs({
Expand Down Expand Up @@ -64,6 +66,9 @@ function args(): {
type: 'boolean',
short: 'w',
},
'no-swagger': {
type: 'boolean',
},
},
allowPositionals: true,
})
Expand Down Expand Up @@ -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') {
Expand All @@ -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`))
Expand Down Expand Up @@ -140,7 +146,7 @@ const db = new Low<Data>(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:'))
Expand Down Expand Up @@ -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'),
'',
Expand Down
55 changes: 55 additions & 0 deletions src/openapi.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
Loading