mirror of
https://github.com/typicode/json-server.git
synced 2026-03-13 09:35:37 +08:00
feat: add object support
This commit is contained in:
11
README.md
11
README.md
@@ -24,7 +24,10 @@ Create a `db.json` (or `db.json5`) file
|
||||
"comments": [
|
||||
{ "id": "1", "text": "a comment about post 1", "postId": "1" },
|
||||
{ "id": "2", "text": "another comment about post 1", "postId": "1" }
|
||||
]
|
||||
],
|
||||
"profile": {
|
||||
"name": "typicode"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -64,6 +67,12 @@ PATCH /posts/:id
|
||||
DELETE /posts/:id
|
||||
```
|
||||
|
||||
```
|
||||
GET /profile
|
||||
PUT /profile
|
||||
PATCH /profile
|
||||
```
|
||||
|
||||
## Params
|
||||
|
||||
### Conditions
|
||||
|
||||
@@ -38,6 +38,7 @@ const db = new Low<Data>(new Memory<Data>(), {})
|
||||
db.data = {
|
||||
posts: [{ id: '1', title: 'foo' }],
|
||||
comments: [{ id: '1', postId: '1' }],
|
||||
object: { f1: 'foo' },
|
||||
}
|
||||
const app = createApp(db, { static: [tmpDir] })
|
||||
|
||||
@@ -58,6 +59,8 @@ await test('createApp', async (t) => {
|
||||
const COMMENTS = '/comments'
|
||||
const POST_COMMENTS = '/comments?postId=1'
|
||||
const NOT_FOUND = '/not-found'
|
||||
const OBJECT = '/object'
|
||||
const OBJECT_1 = '/object/1'
|
||||
|
||||
const arr: Test[] = [
|
||||
// Static
|
||||
@@ -74,25 +77,35 @@ await test('createApp', async (t) => {
|
||||
{ method: 'GET', url: POST_NOT_FOUND, statusCode: 404 },
|
||||
{ method: 'GET', url: COMMENTS, statusCode: 200 },
|
||||
{ method: 'GET', url: POST_COMMENTS, statusCode: 200 },
|
||||
{ method: 'GET', url: OBJECT, statusCode: 200 },
|
||||
{ method: 'GET', url: OBJECT_1, statusCode: 404 },
|
||||
{ method: 'GET', url: NOT_FOUND, statusCode: 404 },
|
||||
|
||||
{ method: 'POST', url: POSTS, statusCode: 201 },
|
||||
{ method: 'POST', url: POST_1, statusCode: 404 },
|
||||
{ method: 'POST', url: POST_NOT_FOUND, statusCode: 404 },
|
||||
{ method: 'POST', url: OBJECT, statusCode: 404 },
|
||||
{ method: 'POST', url: OBJECT_1, statusCode: 404 },
|
||||
{ method: 'POST', url: NOT_FOUND, statusCode: 404 },
|
||||
|
||||
{ method: 'PUT', url: POSTS, statusCode: 404 },
|
||||
{ method: 'PUT', url: POST_1, statusCode: 200 },
|
||||
{ method: 'PUT', url: OBJECT, statusCode: 200 },
|
||||
{ method: 'PUT', url: OBJECT_1, statusCode: 404 },
|
||||
{ method: 'PUT', url: POST_NOT_FOUND, statusCode: 404 },
|
||||
{ method: 'PUT', url: NOT_FOUND, statusCode: 404 },
|
||||
|
||||
{ method: 'PATCH', url: POSTS, statusCode: 404 },
|
||||
{ method: 'PATCH', url: POST_1, statusCode: 200 },
|
||||
{ method: 'PATCH', url: OBJECT, statusCode: 200 },
|
||||
{ method: 'PATCH', url: OBJECT_1, statusCode: 404 },
|
||||
{ method: 'PATCH', url: POST_NOT_FOUND, statusCode: 404 },
|
||||
{ method: 'PATCH', url: NOT_FOUND, statusCode: 404 },
|
||||
|
||||
{ method: 'DELETE', url: POSTS, statusCode: 404 },
|
||||
{ method: 'DELETE', url: POST_1, statusCode: 200 },
|
||||
{ method: 'DELETE', url: OBJECT, statusCode: 404 },
|
||||
{ method: 'DELETE', url: OBJECT_1, statusCode: 404 },
|
||||
{ method: 'DELETE', url: POST_NOT_FOUND, statusCode: 404 },
|
||||
{ method: 'DELETE', url: NOT_FOUND, statusCode: 404 },
|
||||
]
|
||||
|
||||
22
src/app.ts
22
src/app.ts
@@ -66,10 +66,26 @@ export function createApp(db: Low<Data>, options: AppOptions = {}) {
|
||||
next()
|
||||
})
|
||||
|
||||
app.put('/:name', async (req, res, next) => {
|
||||
const { name = '' } = req.params
|
||||
if (isItem(req.body)) {
|
||||
res.locals['data'] = await service.update(name, req.body)
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
app.put('/:name/:id', async (req, res, next) => {
|
||||
const { name = '', id = '' } = req.params
|
||||
if (isItem(req.body)) {
|
||||
res.locals['data'] = await service.update(name, id, req.body)
|
||||
res.locals['data'] = await service.updateById(name, id, req.body)
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
app.patch('/:name', async (req, res, next) => {
|
||||
const { name = '' } = req.params
|
||||
if (isItem(req.body)) {
|
||||
res.locals['data'] = await service.patch(name, req.body)
|
||||
}
|
||||
next()
|
||||
})
|
||||
@@ -77,14 +93,14 @@ export function createApp(db: Low<Data>, options: AppOptions = {}) {
|
||||
app.patch('/:name/:id', async (req, res, next) => {
|
||||
const { name = '', id = '' } = req.params
|
||||
if (isItem(req.body)) {
|
||||
res.locals['data'] = await service.patch(name, id, req.body)
|
||||
res.locals['data'] = await service.patchById(name, id, req.body)
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
app.delete('/:name/:id', async (req, res, next) => {
|
||||
const { name = '', id = '' } = req.params
|
||||
res.locals['data'] = await service.destroy(name, id)
|
||||
res.locals['data'] = await service.destroyById(name, id)
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ const service = new Service(db)
|
||||
|
||||
const POSTS = 'posts'
|
||||
const COMMENTS = 'comments'
|
||||
const OBJECT = 'object'
|
||||
|
||||
const UNKNOWN_RESOURCE = 'xxx'
|
||||
const UNKNOWN_ID = 'xxx'
|
||||
|
||||
@@ -40,10 +42,15 @@ const post3 = {
|
||||
const comment1 = { id: '1', title: 'a', postId: '1' }
|
||||
const items = 3
|
||||
|
||||
const obj = {
|
||||
f1: 'foo',
|
||||
}
|
||||
|
||||
function reset() {
|
||||
db.data = structuredClone({
|
||||
posts: [post1, post2, post3],
|
||||
comments: [comment1],
|
||||
object: obj,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,6 +64,8 @@ type Test = {
|
||||
|
||||
await test('findById', () => {
|
||||
reset()
|
||||
if (!Array.isArray(db.data?.[POSTS]))
|
||||
throw new Error('posts should be an array')
|
||||
assert.deepEqual(service.findById(POSTS, '1', {}), db.data?.[POSTS]?.[0])
|
||||
assert.equal(service.findById(POSTS, UNKNOWN_ID, {}), undefined)
|
||||
assert.deepEqual(service.findById(POSTS, '1', { _embed: ['comments'] }), {
|
||||
@@ -236,6 +245,10 @@ await test('find', async (t) => {
|
||||
name: UNKNOWN_RESOURCE,
|
||||
res: undefined,
|
||||
},
|
||||
{
|
||||
name: OBJECT,
|
||||
res: obj,
|
||||
},
|
||||
]
|
||||
for (const tc of arr) {
|
||||
await t.test(`${tc.name} ${JSON.stringify(tc.params)}`, () => {
|
||||
@@ -261,44 +274,65 @@ await test('create', async () => {
|
||||
})
|
||||
|
||||
await test('update', async () => {
|
||||
reset()
|
||||
const obj = { f1: 'bar' }
|
||||
const res = await service.update(OBJECT, obj)
|
||||
assert.equal(res, obj)
|
||||
|
||||
assert.equal(
|
||||
await service.update(UNKNOWN_RESOURCE, obj),
|
||||
undefined,
|
||||
'should ignore unknown resources',
|
||||
)
|
||||
assert.equal(
|
||||
await service.update(POSTS, {}),
|
||||
undefined,
|
||||
'should ignore arrays',
|
||||
)
|
||||
})
|
||||
|
||||
await test('updateById', async () => {
|
||||
reset()
|
||||
const post = { id: 'xxx', title: 'updated post' }
|
||||
const res = await service.update(POSTS, post1.id, post)
|
||||
const res = await service.updateById(POSTS, post1.id, post)
|
||||
assert.equal(res?.['id'], post1.id, 'id should not change')
|
||||
assert.equal(res?.['title'], post.title)
|
||||
|
||||
assert.equal(
|
||||
await service.update(UNKNOWN_RESOURCE, post1.id, post),
|
||||
await service.updateById(UNKNOWN_RESOURCE, post1.id, post),
|
||||
undefined,
|
||||
)
|
||||
assert.equal(await service.update(POSTS, UNKNOWN_ID, post), undefined)
|
||||
assert.equal(await service.updateById(POSTS, UNKNOWN_ID, post), undefined)
|
||||
})
|
||||
|
||||
await test('patch', async () => {
|
||||
await test('patchById', async () => {
|
||||
reset()
|
||||
const post = { id: 'xxx', title: 'updated post' }
|
||||
const res = await service.patch(POSTS, post1.id, post)
|
||||
const res = await service.patchById(POSTS, post1.id, post)
|
||||
assert.notEqual(res, undefined)
|
||||
assert.equal(res?.['id'], post1.id)
|
||||
assert.equal(res?.['title'], post.title)
|
||||
|
||||
assert.equal(await service.patch(UNKNOWN_RESOURCE, post1.id, post), undefined)
|
||||
assert.equal(await service.patch(POSTS, UNKNOWN_ID, post), undefined)
|
||||
assert.equal(
|
||||
await service.patchById(UNKNOWN_RESOURCE, post1.id, post),
|
||||
undefined,
|
||||
)
|
||||
assert.equal(await service.patchById(POSTS, UNKNOWN_ID, post), undefined)
|
||||
})
|
||||
|
||||
await test('destroy', async () => {
|
||||
reset()
|
||||
let prevLength = db.data?.[POSTS]?.length || 0
|
||||
await service.destroy(POSTS, post1.id)
|
||||
let prevLength = Number(db.data?.[POSTS]?.length) || 0
|
||||
await service.destroyById(POSTS, post1.id)
|
||||
assert.equal(db.data?.[POSTS]?.length, prevLength - 1)
|
||||
assert.deepEqual(db.data?.[COMMENTS], [{ ...comment1, postId: null }])
|
||||
|
||||
reset()
|
||||
prevLength = db.data?.[POSTS]?.length || 0
|
||||
await service.destroy(POSTS, post1.id, [COMMENTS])
|
||||
await service.destroyById(POSTS, post1.id, [COMMENTS])
|
||||
assert.equal(db.data[POSTS].length, prevLength - 1)
|
||||
assert.equal(db.data[COMMENTS].length, 0)
|
||||
|
||||
assert.equal(await service.destroy(UNKNOWN_RESOURCE, post1.id), undefined)
|
||||
assert.equal(await service.destroy(POSTS, UNKNOWN_ID), undefined)
|
||||
assert.equal(await service.destroyById(UNKNOWN_RESOURCE, post1.id), undefined)
|
||||
assert.equal(await service.destroyById(POSTS, UNKNOWN_ID), undefined)
|
||||
})
|
||||
|
||||
117
src/service.ts
117
src/service.ts
@@ -7,7 +7,7 @@ import sortOn from 'sort-on'
|
||||
|
||||
export type Item = Record<string, unknown>
|
||||
|
||||
export type Data = Record<string, Item[]>
|
||||
export type Data = Record<string, Item[] | Item>
|
||||
|
||||
export function isItem(obj: unknown): obj is Item {
|
||||
return typeof obj === 'object' && obj !== null
|
||||
@@ -33,6 +33,10 @@ enum Condition {
|
||||
default = '',
|
||||
}
|
||||
|
||||
function isCondition(value: string): value is Condition {
|
||||
return Object.values<string>(Condition).includes(value)
|
||||
}
|
||||
|
||||
export type PaginatedItems = {
|
||||
first: number
|
||||
prev: number | null
|
||||
@@ -43,10 +47,6 @@ export type PaginatedItems = {
|
||||
data: Item[]
|
||||
}
|
||||
|
||||
function isCondition(value: string): value is Condition {
|
||||
return Object.values<string>(Condition).includes(value)
|
||||
}
|
||||
|
||||
function embed(db: Low<Data>, name: string, item: Item, related: string): Item {
|
||||
if (inflection.singularize(related) === related) {
|
||||
const relatedData = db.data[inflection.pluralize(related)] as Item[]
|
||||
@@ -81,11 +81,13 @@ function nullifyForeignKey(db: Low<Data>, name: string, id: string) {
|
||||
if (key === name) return
|
||||
|
||||
// Nullify
|
||||
items.forEach((item) => {
|
||||
if (item[foreignKey] === id) {
|
||||
item[foreignKey] = null
|
||||
}
|
||||
})
|
||||
if (Array.isArray(items)) {
|
||||
items.forEach((item) => {
|
||||
if (item[foreignKey] === id) {
|
||||
item[foreignKey] = null
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,7 +99,9 @@ function deleteDependents(db: Low<Data>, name: string, dependents: string[]) {
|
||||
if (key === name || !dependents.includes(key)) return
|
||||
|
||||
// Delete if foreign key is null
|
||||
db.data[key] = items.filter((item) => item[foreignKey] !== null)
|
||||
if (Array.isArray(items)) {
|
||||
db.data[key] = items.filter((item) => item[foreignKey] !== null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -108,14 +112,10 @@ export class Service {
|
||||
this.#db = db
|
||||
}
|
||||
|
||||
#get(name: string): Item[] | undefined {
|
||||
#get(name: string): Item[] | Item | undefined {
|
||||
return this.#db.data[name]
|
||||
}
|
||||
|
||||
list(): string[] {
|
||||
return Object.keys(this.#db?.data || {})
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(this.#db?.data, name)
|
||||
}
|
||||
@@ -125,11 +125,17 @@ export class Service {
|
||||
id: string,
|
||||
query: { _embed?: string[] },
|
||||
): Item | undefined {
|
||||
let item = this.#get(name)?.find((item) => item['id'] === id)
|
||||
query._embed?.forEach((related) => {
|
||||
if (item !== undefined) item = embed(this.#db, name, item, related)
|
||||
})
|
||||
return item
|
||||
const value = this.#get(name)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let item = value.find((item) => item['id'] === id)
|
||||
query._embed?.forEach((related) => {
|
||||
if (item !== undefined) item = embed(this.#db, name, item, related)
|
||||
})
|
||||
return item
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
find(
|
||||
@@ -145,15 +151,16 @@ export class Service {
|
||||
_page?: number
|
||||
_per_page?: number
|
||||
} = {},
|
||||
): Item[] | PaginatedItems | undefined {
|
||||
): Item[] | PaginatedItems | Item | undefined {
|
||||
let items = this.#get(name)
|
||||
|
||||
// Not found
|
||||
if (items === undefined) return
|
||||
if (!Array.isArray(items)) {
|
||||
return items
|
||||
}
|
||||
|
||||
// Include
|
||||
query._embed?.forEach((related) => {
|
||||
if (items !== undefined)
|
||||
if (items !== undefined && Array.isArray(items))
|
||||
items = items.map((item) => embed(this.#db, name, item, related))
|
||||
})
|
||||
|
||||
@@ -168,7 +175,7 @@ export class Service {
|
||||
if (value === undefined || typeof value !== 'string') {
|
||||
continue
|
||||
}
|
||||
const re = /_(lt|lte|gt|gte|ne|includes)$/
|
||||
const re = /_(lt|lte|gt|gte|ne)$/
|
||||
const reArr = re.exec(key)
|
||||
const op = reArr?.at(1)
|
||||
if (op && isCondition(op)) {
|
||||
@@ -308,7 +315,7 @@ export class Service {
|
||||
data: Omit<Item, 'id'> = {},
|
||||
): Promise<Item | undefined> {
|
||||
const items = this.#get(name)
|
||||
if (items === undefined) return
|
||||
if (items === undefined || !Array.isArray(items)) return
|
||||
|
||||
const item = { id: randomBytes(2).toString('hex'), ...data }
|
||||
items.push(item)
|
||||
@@ -318,13 +325,27 @@ export class Service {
|
||||
}
|
||||
|
||||
async #updateOrPatch(
|
||||
name: string,
|
||||
body: Item = {},
|
||||
isPatch: boolean,
|
||||
): Promise<Item | undefined> {
|
||||
const item = this.#get(name)
|
||||
if (item === undefined || Array.isArray(item)) return
|
||||
|
||||
const nextItem = (this.#db.data[name] = isPatch ? { item, ...body } : body)
|
||||
|
||||
await this.#db.write()
|
||||
return nextItem
|
||||
}
|
||||
|
||||
async #updateOrPatchById(
|
||||
name: string,
|
||||
id: string,
|
||||
body: Omit<Item, 'id'> = {},
|
||||
body: Item = {},
|
||||
isPatch: boolean,
|
||||
): Promise<Item | undefined> {
|
||||
const items = this.#get(name)
|
||||
if (items === undefined) return
|
||||
if (items === undefined || !Array.isArray(items)) return
|
||||
|
||||
const item = items.find((item) => item['id'] === id)
|
||||
if (!item) return
|
||||
@@ -337,32 +358,40 @@ export class Service {
|
||||
return nextItem
|
||||
}
|
||||
|
||||
async update(
|
||||
name: string,
|
||||
id: string,
|
||||
body: Omit<Item, 'id'> = {},
|
||||
): Promise<Item | undefined> {
|
||||
return this.#updateOrPatch(name, id, body, false)
|
||||
async update(name: string, body: Item = {}): Promise<Item | undefined> {
|
||||
return this.#updateOrPatch(name, body, false)
|
||||
}
|
||||
|
||||
async patch(
|
||||
name: string,
|
||||
id: string,
|
||||
body: Omit<Item, 'id'> = {},
|
||||
): Promise<Item | undefined> {
|
||||
return this.#updateOrPatch(name, id, body, true)
|
||||
async patch(name: string, body: Item = {}): Promise<Item | undefined> {
|
||||
return this.#updateOrPatch(name, body, true)
|
||||
}
|
||||
|
||||
async destroy(
|
||||
async updateById(
|
||||
name: string,
|
||||
id: string,
|
||||
body: Item = {},
|
||||
): Promise<Item | undefined> {
|
||||
return this.#updateOrPatchById(name, id, body, false)
|
||||
}
|
||||
|
||||
async patchById(
|
||||
name: string,
|
||||
id: string,
|
||||
body: Item = {},
|
||||
): Promise<Item | undefined> {
|
||||
return this.#updateOrPatchById(name, id, body, true)
|
||||
}
|
||||
|
||||
async destroyById(
|
||||
name: string,
|
||||
id: string,
|
||||
dependents: string[] = [],
|
||||
): Promise<Item | undefined> {
|
||||
const items = this.#get(name)
|
||||
if (items === undefined) return
|
||||
if (items === undefined || !Array.isArray(items)) return
|
||||
|
||||
const item = items.find((item) => item['id'] === id)
|
||||
if (!item) return
|
||||
if (item === undefined) return
|
||||
const index = items.indexOf(item)
|
||||
items.splice(index, 1)[0]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user