From b3f246c6c4f4509c11b1d4d47d44fdf771cf5e5d Mon Sep 17 00:00:00 2001 From: Jia-ben00 <316397950+Jia-ben00@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:30:08 +0800 Subject: [PATCH] feat: support custom id field per resource Add an --id option to use a different field than 'id' as the unique identifier of items. It accepts a field name (applied to all resources) or a JSON file mapping resource names to id field names. Resolves the long-standing request in #279. --- README.md | 36 ++++++++++++- src/adapters/normalized-adapter.test.ts | 49 +++++++++++++++++ src/adapters/normalized-adapter.ts | 28 +++++++--- src/app.test.ts | 61 +++++++++++++++++++-- src/app.ts | 5 +- src/bin.ts | 26 +++++++-- src/service.test.ts | 72 +++++++++++++++++++++++++ src/service.ts | 38 ++++++++++--- 8 files changed, 291 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2fc9ded8e..dbf094d54 100644 --- a/README.md +++ b/README.md @@ -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' } }) +``` diff --git a/src/adapters/normalized-adapter.test.ts b/src/adapters/normalized-adapter.test.ts index 67f85a3ec..4e6275d71 100644 --- a/src/adapters/normalized-adapter.test.ts +++ b/src/adapters/normalized-adapter.test.ts @@ -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) +}) diff --git a/src/adapters/normalized-adapter.ts b/src/adapters/normalized-adapter.ts index e2d9e7284..b212de171 100644 --- a/src/adapters/normalized-adapter.ts +++ b/src/adapters/normalized-adapter.ts @@ -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 & { @@ -10,9 +10,22 @@ export type RawData = Record & { export class NormalizedAdapter implements Adapter { #adapter: Adapter + #id: string + #ids: Record - constructor(adapter: Adapter) { + constructor(adapter: Adapter, 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 { @@ -24,15 +37,16 @@ export class NormalizedAdapter implements Adapter { 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() } } } diff --git a/src/app.test.ts b/src/app.test.ts index bac286d98..91a8dd706 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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' @@ -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' }]) @@ -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(new Memory(), {}) +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((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) + }) +}) diff --git a/src/app.ts b/src/app.ts index 40a70c046..da2647d98 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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)) @@ -18,6 +18,7 @@ const isProduction = process.env['NODE_ENV'] === 'production' export type AppOptions = { logger?: boolean static?: string[] + id?: IdOption } const eta = new Eta({ @@ -93,7 +94,7 @@ function withIdAndBody( export function createApp(db: Low, options: AppOptions = {}) { // Create service - const service = new Service(db) + const service = new Service(db, options.id) // Create app const app = new App() diff --git a/src/bin.ts b/src/bin.ts index 3cc39d83b..ba1f02b35 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -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] @@ -25,6 +25,8 @@ Options: -p, --port Port (default: 3000) -h, --host Host (default: localhost) -s, --static Static files directory (multiple allowed) + --id Id field name, or a JSON file mapping resource names to + id field names (default: id) --help Show this message --version Show version number `); @@ -36,6 +38,7 @@ function args(): { port: number; host: string; static: string[]; + id: IdOption; } { try { const { values, positionals } = parseArgs({ @@ -56,6 +59,9 @@ function args(): { multiple: true, default: [], }, + id: { + type: "string", + }, help: { type: "boolean", }, @@ -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; + } 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") { @@ -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`)); @@ -134,13 +152,13 @@ if (extname(file) === ".json5") { } else { adapter = new JSONFile(file); } -const observer = new Observer(new NormalizedAdapter(adapter)); +const observer = new Observer(new NormalizedAdapter(adapter, id)); 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, id }); function logRoutes(data: Data) { console.log(chalk.bold("Endpoints:")); diff --git a/src/service.test.ts b/src/service.test.ts index d89e0654e..d1d397bc6 100644 --- a/src/service.test.ts +++ b/src/service.test.ts @@ -178,3 +178,75 @@ await test('destroy', async (t) => { assert.equal(await service.destroyById(POSTS, UNKNOWN_ID), undefined) }) }) + +// Resources with a custom id field (per-resource mapping) +const customIdData: Data = { + posts: [ + { post_id: 'p1', title: 'a' }, + { post_id: 'p2', title: 'b' }, + ], + comments: [{ id: 'c1', text: 'hi', post_id: 'p1' }], +} +const customIdService = new Service(new Low(new Memory(), customIdData), { + posts: 'post_id', +}) + +await test('custom id field (per-resource mapping)', async (t) => { + await t.test('findById uses the custom id field', () => { + const item = customIdService.findById('posts', 'p1', {}) + assert.equal(item?.['post_id'], 'p1') + assert.equal(item?.['title'], 'a') + + // Resource without a mapping keeps using "id" + const comment = customIdService.findById('comments', 'c1', {}) + assert.equal(comment?.['id'], 'c1') + }) + + await t.test('create generates a custom id field', async () => { + const res = await customIdService.create('posts', { title: 'c' }) + assert.equal(typeof res?.['post_id'], 'string') + assert.equal(res?.['id'], undefined, 'should not create a default "id"') + }) + + await t.test('updateById keeps the custom id field', async () => { + const res = await customIdService.updateById('posts', 'p2', { + post_id: 'xxx', + title: 'updated', + }) + assert.equal(res?.['post_id'], 'p2', 'id should not change') + assert.equal(res?.['title'], 'updated') + }) + + await t.test('patchById keeps the custom id field', async () => { + const res = await customIdService.patchById('posts', 'p2', { title: 'patched' }) + assert.equal(res?.['post_id'], 'p2') + assert.equal(res?.['title'], 'patched') + }) + + await t.test('destroyById uses the custom id field', async () => { + const prevLength = (customIdService.find('posts', { where: {} }) as Item[]).length + await customIdService.destroyById('posts', 'p2') + assert.equal((customIdService.find('posts', { where: {} }) as Item[]).length, prevLength - 1) + assert.equal(customIdService.findById('posts', 'p2', {}), undefined) + }) +}) + +// Global custom id field +const globalIdService = new Service( + new Low(new Memory(), { + posts: [{ post_id: '1', title: 'a' }], + }), + 'post_id', +) + +await test('custom id field (global)', async (t) => { + await t.test('findById uses the custom id field', () => { + assert.equal(globalIdService.findById('posts', '1', {})?.['title'], 'a') + }) + + await t.test('create generates the custom id field', async () => { + const res = await globalIdService.create('posts', { title: 'b' }) + assert.equal(typeof res?.['post_id'], 'string') + assert.equal(res?.['id'], undefined) + }) +}) diff --git a/src/service.ts b/src/service.ts index ad06bfa8f..1a9b75b6e 100644 --- a/src/service.ts +++ b/src/service.ts @@ -10,6 +10,14 @@ export type Item = Record export type Data = Record +/** + * The name of the field used as the id of an item. + * + * Can be a string, in which case every resource uses the same id field, or an + * object mapping resource names to the id field to use for that resource. + */ +export type IdOption = string | Record + export function isItem(obj: unknown): obj is Item { return typeof obj === 'object' && obj !== null && !Array.isArray(obj) } @@ -80,9 +88,25 @@ function deleteDependents(db: Low, name: string, dependents: string[]) { export class Service { #db: Low + #id: string + #ids: Record - constructor(db: Low) { + constructor(db: Low, id: IdOption = 'id') { this.#db = db + if (typeof id === 'string') { + this.#id = id + this.#ids = {} + } else { + this.#id = 'id' + this.#ids = id + } + } + + /** + * The name of the id field for the given resource. + */ + #idFor(name: string): string { + return this.#ids[name] ?? this.#id } #get(name: string): Item[] | Item | undefined { @@ -95,9 +119,10 @@ export class Service { findById(name: string, id: string, query: { _embed?: string[] | string }): Item | undefined { const value = this.#get(name) + const idField = this.#idFor(name) if (Array.isArray(value)) { - let item = value.find((item) => item['id'] === id) + let item = value.find((item) => item[idField] === id) ensureArray(query._embed).forEach((related) => { if (item !== undefined) item = embed(this.#db, name, item, related) }) @@ -146,7 +171,7 @@ export class Service { const items = this.#get(name) if (items === undefined || !Array.isArray(items)) return - const item = { ...data, id: randomId() } + const item = { ...data, [this.#idFor(name)]: randomId() } items.push(item) await this.#db.write() @@ -172,10 +197,11 @@ export class Service { const items = this.#get(name) if (items === undefined || !Array.isArray(items)) return - const item = items.find((item) => item['id'] === id) + const idField = this.#idFor(name) + const item = items.find((item) => item[idField] === id) if (!item) return - const nextItem = isPatch ? { ...item, ...body, id } : { ...body, id } + const nextItem = isPatch ? { ...item, ...body, [idField]: id } : { ...body, [idField]: id } const index = items.indexOf(item) items.splice(index, 1, nextItem) @@ -207,7 +233,7 @@ export class Service { const items = this.#get(name) if (items === undefined || !Array.isArray(items)) return - const item = items.find((item) => item['id'] === id) + const item = items.find((item) => item[this.#idFor(name)] === id) if (item === undefined) return const index = items.indexOf(item) items.splice(index, 1)