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
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,43 @@ Static files are served with standard MIME types and can include HTML, CSS, Java

If you are upgrading from json-server v0.x, note these behavioral changes:

- **ID handling:** `id` is always a string and will be auto-generated if not provided
- **ID handling:** `id` is always a string and will be auto-generated if not provided. Use the `--id` option to use a different field name (see below)
- **Pagination:** Use `_per_page` with `_page` instead of the deprecated `_limit` parameter
- **Relationships:** Use `_embed` instead of `_expand` for including related resources
- **Request delays:** Use browser DevTools (Network tab > throttling) instead of the removed `--delay` CLI option

> **New to json-server?** These notes are for users migrating from v0. If this is your first time using json-server, you can ignore this section.

## Custom ID field

By default, json-server uses the `id` field as the unique identifier of each item. If your data uses a different field, you can tell json-server which field to use with the `--id` option:

```bash
json-server db.json --id post_id
```

For resources with different id fields, pass a JSON file mapping resource names to their id field:

```json
// ids.json
{
"posts": "post_id",
"comments": "comment_id"
}
```

```bash
json-server db.json --id ids.json
```

The same option is available through the JavaScript API:

```js
import { createApp } from 'json-server'
import { Low } from 'lowdb'
import { Memory } from 'lowdb/node'

const db = new Low(new Memory(), {})
await db.read()
const app = createApp(db, { id: { posts: 'post_id' } })
```
49 changes: 49 additions & 0 deletions src/adapters/normalized-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,52 @@ await test('write always overwrites $schema', async () => {
assert.notEqual(data, null)
assert.equal(data?.['$schema'], DEFAULT_SCHEMA_PATH)
})

await test('read normalizes custom id fields (per-resource mapping)', async () => {
const adapter = new StubAdapter({
posts: [{ post_id: 1 }, { title: 'missing id' }],
comments: [{ id: 5 }, { text: 'no id' }],
})

const normalized = await new NormalizedAdapter(adapter, {
posts: 'post_id',
}).read()

if (normalized === null) {
assert.fail('expected data')
return
}

const posts = normalized['posts']
assert.ok(Array.isArray(posts))
// Custom id field is normalized, no default "id" is injected
assert.equal(posts[0]?.['post_id'], '1')
assert.equal(typeof posts[1]?.['post_id'], 'string')
assert.equal(posts[0]?.['id'], undefined)
assert.equal(posts[1]?.['id'], undefined)

// Resource without a mapping keeps using "id"
const comments = normalized['comments']
assert.ok(Array.isArray(comments))
assert.equal(comments[0]?.['id'], '5')
assert.equal(typeof comments[1]?.['id'], 'string')
})

await test('read normalizes custom id fields (global)', async () => {
const adapter = new StubAdapter({
posts: [{ post_id: 1 }, { title: 'missing id' }],
})

const normalized = await new NormalizedAdapter(adapter, 'post_id').read()

if (normalized === null) {
assert.fail('expected data')
return
}

const posts = normalized['posts']
assert.ok(Array.isArray(posts))
assert.equal(posts[0]?.['post_id'], '1')
assert.equal(typeof posts[1]?.['post_id'], 'string')
assert.equal(posts[0]?.['id'], undefined)
})
28 changes: 21 additions & 7 deletions src/adapters/normalized-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Adapter } from 'lowdb'

import { randomId } from '../random-id.ts'
import type { Data, Item } from '../service.ts'
import type { Data, IdOption, Item } from '../service.ts'

export const DEFAULT_SCHEMA_PATH = './node_modules/json-server/schema.json'
export type RawData = Record<string, Item[] | Item | string | undefined> & {
Expand All @@ -10,9 +10,22 @@ export type RawData = Record<string, Item[] | Item | string | undefined> & {

export class NormalizedAdapter implements Adapter<Data> {
#adapter: Adapter<RawData>
#id: string
#ids: Record<string, string>

constructor(adapter: Adapter<RawData>) {
constructor(adapter: Adapter<RawData>, id: IdOption = 'id') {
this.#adapter = adapter
if (typeof id === 'string') {
this.#id = id
this.#ids = {}
} else {
this.#id = 'id'
this.#ids = id
}
}

#idFor(name: string): string {
return this.#ids[name] ?? this.#id
}

async read(): Promise<Data | null> {
Expand All @@ -24,15 +37,16 @@ export class NormalizedAdapter implements Adapter<Data> {

delete data['$schema']

for (const value of Object.values(data)) {
for (const [name, value] of Object.entries(data)) {
if (Array.isArray(value)) {
const idField = this.#idFor(name)
for (const item of value) {
if (typeof item['id'] === 'number') {
item['id'] = item['id'].toString()
if (typeof item[idField] === 'number') {
item[idField] = item[idField].toString()
}

if (item['id'] === undefined) {
item['id'] = randomId()
if (item[idField] === undefined) {
item[idField] = randomId()
}
}
}
Expand Down
61 changes: 57 additions & 4 deletions src/app.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import assert from 'node:assert/strict'
import assert from 'node:assert/strict'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
Expand Down Expand Up @@ -139,9 +139,7 @@ await test('createApp', async (t) => {

await t.test('GET /posts?_where=... overrides query params', async () => {
const where = encodeURIComponent(JSON.stringify({ title: { eq: 'foo' } }))
const response = await fetch(
`http://localhost:${port}/posts?title:eq=bar&_where=${where}`,
)
const response = await fetch(`http://localhost:${port}/posts?title:eq=bar&_where=${where}`)
assert.equal(response.status, 200)
const data = await response.json()
assert.deepEqual(data, [{ id: '1', title: 'foo' }])
Expand Down Expand Up @@ -180,3 +178,58 @@ await test('createApp', async (t) => {
assert.deepEqual(data, { error: 'Body must be a JSON object' })
})
})

// App with a custom id field (per-resource mapping)
const customIdPort = await getPort()
const customIdDb = new Low<Data>(new Memory<Data>(), {})
customIdDb.data = {
posts: [{ post_id: '1', title: 'foo' }],
comments: [{ id: '1', text: 'hi', post_id: '1' }],
}
const customIdApp = createApp(customIdDb, { id: { posts: 'post_id' } })

await new Promise<void>((resolve, reject) => {
try {
const server = customIdApp.listen(customIdPort, () => resolve())
test.after(() => server.close())
} catch (err) {
reject(err)
}
})

await test('createApp with custom id field', async (t) => {
await t.test('GET /posts/1 finds by post_id', async () => {
const response = await fetch(`http://localhost:${customIdPort}/posts/1`)
assert.equal(response.status, 200)
const data = await response.json()
assert.deepEqual(data, { post_id: '1', title: 'foo' })
})

await t.test('GET /comments/1 still uses id', async () => {
const response = await fetch(`http://localhost:${customIdPort}/comments/1`)
assert.equal(response.status, 200)
const data = await response.json()
assert.deepEqual(data, { id: '1', text: 'hi', post_id: '1' })
})

await t.test('POST /posts generates post_id', async () => {
const response = await fetch(`http://localhost:${customIdPort}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'bar' }),
})
assert.equal(response.status, 201)
const data = await response.json()
assert.equal(typeof data['post_id'], 'string')
assert.equal(data['id'], undefined)
})

await t.test('DELETE /posts/1 deletes by post_id', async () => {
const response = await fetch(`http://localhost:${customIdPort}/posts/1`, {
method: 'DELETE',
})
assert.equal(response.status, 200)
const notFound = await fetch(`http://localhost:${customIdPort}/posts/1`)
assert.equal(notFound.status, 404)
})
})
5 changes: 3 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { json } from 'milliparsec'
import sirv from 'sirv'

import { parseWhere } from './parse-where.ts'
import type { Data } from './service.ts'
import type { Data, IdOption } from './service.ts'
import { isItem, Service } from './service.ts'

const __dirname = dirname(fileURLToPath(import.meta.url))
Expand All @@ -18,6 +18,7 @@ const isProduction = process.env['NODE_ENV'] === 'production'
export type AppOptions = {
logger?: boolean
static?: string[]
id?: IdOption
}

const eta = new Eta({
Expand Down Expand Up @@ -93,7 +94,7 @@ function withIdAndBody(

export function createApp(db: Low<Data>, options: AppOptions = {}) {
// Create service
const service = new Service(db)
const service = new Service(db, options.id)

// Create app
const app = new App()
Expand Down
26 changes: 22 additions & 4 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { NormalizedAdapter } from "./adapters/normalized-adapter.ts";
import type { RawData } from "./adapters/normalized-adapter.ts";
import { Observer } from "./adapters/observer.ts";
import { createApp } from "./app.ts";
import type { Data } from "./service.ts";
import type { Data, IdOption } from "./service.ts";

function help() {
console.log(`Usage: json-server [options] <file>
Expand All @@ -25,6 +25,8 @@ Options:
-p, --port <port> Port (default: 3000)
-h, --host <host> Host (default: localhost)
-s, --static <dir> Static files directory (multiple allowed)
--id <field|file> Id field name, or a JSON file mapping resource names to
id field names (default: id)
--help Show this message
--version Show version number
`);
Expand All @@ -36,6 +38,7 @@ function args(): {
port: number;
host: string;
static: string[];
id: IdOption;
} {
try {
const { values, positionals } = parseArgs({
Expand All @@ -56,6 +59,9 @@ function args(): {
multiple: true,
default: [],
},
id: {
type: "string",
},
help: {
type: "boolean",
},
Expand Down Expand Up @@ -94,12 +100,24 @@ function args(): {
process.exit();
}

// Resolve --id: a field name, or a JSON file mapping resource names to
// id field names.
let id: IdOption = "id";
if (typeof values.id === "string" && values.id !== "") {
if (existsSync(values.id)) {
id = JSON.parse(readFileSync(values.id, "utf-8")) as Record<string, string>;
} else {
id = values.id;
}
}

// App args and options
return {
file: positionals[0] ?? "",
port: parseInt(values.port as string),
host: values.host as string,
static: values.static as string[],
id,
};
} catch (e) {
if ((e as NodeJS.ErrnoException).code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
Expand All @@ -112,7 +130,7 @@ function args(): {
}
}

const { file, port, host, static: staticArr } = args();
const { file, port, host, static: staticArr, id } = args();

if (!existsSync(file)) {
console.log(chalk.red(`File ${file} not found`));
Expand All @@ -134,13 +152,13 @@ if (extname(file) === ".json5") {
} else {
adapter = new JSONFile<RawData>(file);
}
const observer = new Observer(new NormalizedAdapter(adapter));
const observer = new Observer(new NormalizedAdapter(adapter, id));

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, id });

function logRoutes(data: Data) {
console.log(chalk.bold("Endpoints:"));
Expand Down
Loading