diff --git a/README.md b/README.md index 2992d2773..96ce08415 100644 --- a/README.md +++ b/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 diff --git a/src/app.test.ts b/src/app.test.ts index 03f1af6f0..0cb798957 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -38,6 +38,7 @@ const db = new Low(new Memory(), {}) 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 }, ] diff --git a/src/app.ts b/src/app.ts index e8769fc42..51885a30a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -66,10 +66,26 @@ export function createApp(db: Low, 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, 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() }) diff --git a/src/service.test.ts b/src/service.test.ts index 84921c91b..57af341ce 100644 --- a/src/service.test.ts +++ b/src/service.test.ts @@ -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) }) diff --git a/src/service.ts b/src/service.ts index a3f8d52b8..f8161a7bb 100644 --- a/src/service.ts +++ b/src/service.ts @@ -7,7 +7,7 @@ import sortOn from 'sort-on' export type Item = Record -export type Data = Record +export type Data = Record 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(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(Condition).includes(value) -} - function embed(db: Low, 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, 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, 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 = {}, ): Promise { 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 { + 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 = {}, + body: Item = {}, isPatch: boolean, ): Promise { 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( + async update(name: string, body: Item = {}): Promise { + return this.#updateOrPatch(name, body, false) + } + + async patch(name: string, body: Item = {}): Promise { + return this.#updateOrPatch(name, body, true) + } + + async updateById( name: string, id: string, - body: Omit = {}, + body: Item = {}, ): Promise { - return this.#updateOrPatch(name, id, body, false) + return this.#updateOrPatchById(name, id, body, false) } - async patch( + async patchById( name: string, id: string, - body: Omit = {}, + body: Item = {}, ): Promise { - return this.#updateOrPatch(name, id, body, true) + return this.#updateOrPatchById(name, id, body, true) } - async destroy( + async destroyById( name: string, id: string, dependents: string[] = [], ): Promise { 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]