From 36a030b37add7672d7a28e92b0ee5686b7ff4616 Mon Sep 17 00:00:00 2001 From: RiceWithMeat <47690223+RiceWithMeat@users.noreply.github.com> Date: Tue, 21 May 2024 03:02:30 +0700 Subject: [PATCH] =?UTF-8?q?#167=20=F0=9F=90=98=20call=20server=20response?= =?UTF-8?q?=20interceptors=20for=20database=20requests,=20add=20detabase?= =?UTF-8?q?=20api=20interceptors,=20add=20baseUrl=20field=20for=20database?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../databaseConfigSchema.ts | 6 +- .../createDatabaseRoutes.test.ts | 178 +++++++----- .../createDatabaseRoutes.ts | 71 ++++- .../createNestedDatabaseRoutes.test.ts | 8 +- .../createNestedDatabaseRoutes.ts | 270 +++++++++++------- .../createShallowDatabaseRoutes.test.ts | 8 +- .../createShallowDatabaseRoutes.ts | 211 +++++++++----- .../createDatabaseMockServer.ts | 8 +- .../createGraphQLMockServer.ts | 6 +- .../createMockServer/createMockServer.ts | 20 +- .../createRestMockServer.ts | 6 +- src/utils/types/server.ts | 2 + 12 files changed, 513 insertions(+), 281 deletions(-) diff --git a/bin/validateMockServerConfig/databaseConfigSchema/databaseConfigSchema.ts b/bin/validateMockServerConfig/databaseConfigSchema/databaseConfigSchema.ts index deef36a7..b1be460f 100644 --- a/bin/validateMockServerConfig/databaseConfigSchema/databaseConfigSchema.ts +++ b/bin/validateMockServerConfig/databaseConfigSchema/databaseConfigSchema.ts @@ -1,13 +1,17 @@ import { z } from 'zod'; +import { baseUrlSchema } from '../baseUrlSchema/baseUrlSchema'; +import { interceptorsSchema } from '../interceptorsSchema/interceptorsSchema'; import { plainObjectSchema, stringForwardSlashSchema, stringJsonFilenameSchema } from '../utils'; export const databaseConfigSchema = z.strictObject({ + baseUrl: baseUrlSchema.optional(), data: z.union([plainObjectSchema(z.record(z.unknown())), stringJsonFilenameSchema]), routes: z .union([ plainObjectSchema(z.record(stringForwardSlashSchema, stringForwardSlashSchema)), stringJsonFilenameSchema ]) - .optional() + .optional(), + interceptors: plainObjectSchema(interceptorsSchema).optional() }); diff --git a/src/core/database/createDatabaseRoutes/createDatabaseRoutes.test.ts b/src/core/database/createDatabaseRoutes/createDatabaseRoutes.test.ts index 06712ec4..70fdebd1 100644 --- a/src/core/database/createDatabaseRoutes/createDatabaseRoutes.test.ts +++ b/src/core/database/createDatabaseRoutes/createDatabaseRoutes.test.ts @@ -5,108 +5,142 @@ import path from 'path'; import request from 'supertest'; import { createDatabaseRoutes } from '@/core/database'; +import { urlJoin } from '@/utils/helpers'; import { createTmpDir } from '@/utils/helpers/tests'; import type { DatabaseConfig, MockServerConfig } from '@/utils/types'; import { findIndexById } from './helpers'; -describe('createDatabaseRoutes', () => { - const createServer = ( - mockServerConfig: Pick & { database: DatabaseConfig } - ) => { - const server = express(); - const routerBase = express.Router(); - const routesWithDatabaseRoutes = createDatabaseRoutes(routerBase, mockServerConfig.database); +const createServer = ( + mockServerConfig: Pick & { + database: DatabaseConfig; + } +) => { + const { baseUrl, database, interceptors } = mockServerConfig; + const server = express(); + const routerBase = express.Router(); + const routesWithDatabaseRoutes = createDatabaseRoutes({ + router: routerBase, + databaseConfig: database, + serverResponseInterceptor: interceptors?.response + }); - server.use(mockServerConfig.baseUrl ?? '/', routesWithDatabaseRoutes); - return server; - }; + const databaseBaseUrl = urlJoin(baseUrl ?? '/', database?.baseUrl ?? '/'); - describe('createDatabaseRoutes: routes and data successfully works when passing them by object', () => { - const data = { profile: { name: 'John' }, users: [{ id: 1 }, { id: 2 }] }; - const routes = { '/api/profile': '/profile' } as const; - const server = createServer({ database: { data, routes } }); + server.use(databaseBaseUrl, routesWithDatabaseRoutes); + return server; +}; - test('Should overwrite routes according to routes object (but default url should work too)', async () => { - const overwrittenUrlResponse = await request(server).get('/api/profile'); - expect(overwrittenUrlResponse.body).toStrictEqual(data.profile); +describe('createDatabaseRoutes: routes and data successfully works when passing them by object', () => { + const data = { profile: { name: 'John' }, users: [{ id: 1 }, { id: 2 }] }; + const routes = { '/api/profile': '/profile' } as const; + const server = createServer({ database: { data, routes } }); - const defaultUrlResponse = await request(server).get('/profile'); - expect(defaultUrlResponse.body).toStrictEqual(data.profile); - }); + test('Should overwrite routes according to routes object (but default url should work too)', async () => { + const overwrittenUrlResponse = await request(server).get('/api/profile'); + expect(overwrittenUrlResponse.body).toStrictEqual(data.profile); - test('Should successfully handle requests to shallow and nested database parts', async () => { - const shallowDatabaseResponse = await request(server).get('/profile'); - expect(shallowDatabaseResponse.body).toStrictEqual(data.profile); + const defaultUrlResponse = await request(server).get('/profile'); + expect(defaultUrlResponse.body).toStrictEqual(data.profile); + }); - const nestedDatabaseCollectionResponse = await request(server).get('/users'); - expect(nestedDatabaseCollectionResponse.body).toStrictEqual(data.users); + test('Should successfully handle requests to shallow and nested database parts', async () => { + const shallowDatabaseResponse = await request(server).get('/profile'); + expect(shallowDatabaseResponse.body).toStrictEqual(data.profile); - const nestedDatabaseItemResponse = await request(server).get('/users/1'); - expect(nestedDatabaseItemResponse.body).toStrictEqual( - data.users[findIndexById(data.users, 1)] - ); - }); + const nestedDatabaseCollectionResponse = await request(server).get('/users'); + expect(nestedDatabaseCollectionResponse.body).toStrictEqual(data.users); + + const nestedDatabaseItemResponse = await request(server).get('/users/1'); + expect(nestedDatabaseItemResponse.body).toStrictEqual(data.users[findIndexById(data.users, 1)]); }); +}); - describe('createDatabaseRoutes: routes and data successfully works when passing them by file', () => { - const data = { profile: { name: 'John' }, users: [{ id: 1 }, { id: 2 }] }; - const routes = { '/api/profile': '/profile' } as const; +describe('createDatabaseRoutes: routes and data successfully works when passing them by file', () => { + const data = { profile: { name: 'John' }, users: [{ id: 1 }, { id: 2 }] }; + const routes = { '/api/profile': '/profile' } as const; - let tmpDirPath: string; - let server: Express; + let tmpDirPath: string; + let server: Express; - beforeAll(() => { - tmpDirPath = createTmpDir(); + beforeAll(() => { + tmpDirPath = createTmpDir(); - const pathToData = path.join(tmpDirPath, './data.json') as `${string}.json`; - fs.writeFileSync(pathToData, JSON.stringify(data)); + const pathToData = path.join(tmpDirPath, './data.json') as `${string}.json`; + fs.writeFileSync(pathToData, JSON.stringify(data)); - const pathToRoutes = path.join(tmpDirPath, './routes.json') as `${string}.json`; - fs.writeFileSync(pathToRoutes, JSON.stringify(routes)); + const pathToRoutes = path.join(tmpDirPath, './routes.json') as `${string}.json`; + fs.writeFileSync(pathToRoutes, JSON.stringify(routes)); - server = createServer({ database: { data: pathToData, routes: pathToRoutes } }); - }); + server = createServer({ database: { data: pathToData, routes: pathToRoutes } }); + }); - afterAll(() => { - fs.rmSync(tmpDirPath, { recursive: true, force: true }); - }); + afterAll(() => { + fs.rmSync(tmpDirPath, { recursive: true, force: true }); + }); - test('Should overwrite routes according to routes object (but default url should work too)', async () => { - const overwrittenUrlResponse = await request(server).get('/api/profile'); - expect(overwrittenUrlResponse.body).toStrictEqual(data.profile); + test('Should overwrite routes according to routes object (but default url should work too)', async () => { + const overwrittenUrlResponse = await request(server).get('/api/profile'); + expect(overwrittenUrlResponse.body).toStrictEqual(data.profile); - const defaultUrlResponse = await request(server).get('/profile'); - expect(defaultUrlResponse.body).toStrictEqual(data.profile); - }); + const defaultUrlResponse = await request(server).get('/profile'); + expect(defaultUrlResponse.body).toStrictEqual(data.profile); + }); - test('Should successfully handle requests to shallow and nested database parts', async () => { - const shallowDatabaseResponse = await request(server).get('/profile'); - expect(shallowDatabaseResponse.body).toStrictEqual(data.profile); + test('Should successfully handle requests to shallow and nested database parts', async () => { + const shallowDatabaseResponse = await request(server).get('/profile'); + expect(shallowDatabaseResponse.body).toStrictEqual(data.profile); - const nestedDatabaseCollectionResponse = await request(server).get('/users'); - expect(nestedDatabaseCollectionResponse.body).toStrictEqual(data.users); + const nestedDatabaseCollectionResponse = await request(server).get('/users'); + expect(nestedDatabaseCollectionResponse.body).toStrictEqual(data.users); - const nestedDatabaseItemResponse = await request(server).get('/users/1'); - expect(nestedDatabaseItemResponse.body).toStrictEqual( - data.users[findIndexById(data.users, 1)] - ); - }); + const nestedDatabaseItemResponse = await request(server).get('/users/1'); + expect(nestedDatabaseItemResponse.body).toStrictEqual(data.users[findIndexById(data.users, 1)]); + }); +}); + +describe('createDatabaseRoutes: routes /__routes and /__db', () => { + const data = { profile: { name: 'John' }, users: [{ id: 1 }, { id: 2 }] }; + const routes = { '/api/profile': '/profile' } as const; + const server = createServer({ database: { data, routes } }); + + test('Should create /__db route that return data from databaseConfig', async () => { + const response = await request(server).get('/__db'); + expect(response.body).toStrictEqual(data); }); - describe('createDatabaseRoutes: routes /__routes and /__db', () => { + test('Should create /__routes route that return routes from databaseConfig', async () => { + const response = await request(server).get('/__routes'); + expect(response.body).toStrictEqual(routes); + }); +}); + +describe('createDatabaseRoutes: interceptors', () => { + test('Should call response interceptors in order: api -> server', async () => { + const apiInterceptor = jest.fn(); + const serverInterceptor = jest.fn(); + const data = { profile: { name: 'John' }, users: [{ id: 1 }, { id: 2 }] }; const routes = { '/api/profile': '/profile' } as const; - const server = createServer({ database: { data, routes } }); - - test('Should create /__db route that return data from databaseConfig', async () => { - const response = await request(server).get('/__db'); - expect(response.body).toStrictEqual(data); + const server = createServer({ + database: { + data, + routes, + interceptors: { + response: apiInterceptor + } + }, + interceptors: { + response: serverInterceptor + } }); - test('Should create /__routes route that return routes from databaseConfig', async () => { - const response = await request(server).get('/__routes'); - expect(response.body).toStrictEqual(routes); - }); + await request(server).get('/profile'); + + expect(apiInterceptor.mock.calls.length).toBe(1); + expect(serverInterceptor.mock.calls.length).toBe(1); + expect(apiInterceptor.mock.invocationCallOrder[0]).toBeLessThan( + serverInterceptor.mock.invocationCallOrder[0] + ); }); }); diff --git a/src/core/database/createDatabaseRoutes/createDatabaseRoutes.ts b/src/core/database/createDatabaseRoutes/createDatabaseRoutes.ts index e7245223..b5a2a6b2 100644 --- a/src/core/database/createDatabaseRoutes/createDatabaseRoutes.ts +++ b/src/core/database/createDatabaseRoutes/createDatabaseRoutes.ts @@ -1,6 +1,7 @@ import type { IRouter } from 'express'; -import type { DatabaseConfig, NestedDatabase, ShallowDatabase } from '@/utils/types'; +import { asyncHandler, callResponseInterceptors } from '@/utils/helpers'; +import type { DatabaseConfig, Interceptors, NestedDatabase, ShallowDatabase } from '@/utils/types'; import { createNestedDatabaseRoutes, @@ -13,26 +14,76 @@ import { FileStorage, MemoryStorage } from './storages'; const isVariableJsonFile = (variable: unknown): variable is `${string}.json` => typeof variable === 'string' && variable.endsWith('.json'); -export const createDatabaseRoutes = (router: IRouter, { data, routes }: DatabaseConfig) => { +interface CreateDatabaseRoutesParams { + router: IRouter; + databaseConfig: DatabaseConfig; + serverResponseInterceptor?: Interceptors['response']; +} + +export const createDatabaseRoutes = ({ + router, + databaseConfig, + serverResponseInterceptor +}: CreateDatabaseRoutesParams) => { + const { data, routes } = databaseConfig; + if (routes) { const storage = isVariableJsonFile(routes) ? new FileStorage(routes) : new MemoryStorage(routes); createRewrittenDatabaseRoutes(router, storage.read()); - router.route('/__routes').get((_request, response) => { - response.json(storage.read()); - }); + router.route('/__routes').get( + asyncHandler(async (request, response) => { + const data = await callResponseInterceptors({ + data: storage.read(), + request, + response, + interceptors: { + apiInterceptor: databaseConfig.interceptors?.response, + serverInterceptor: serverResponseInterceptor + } + }); + response.json(data); + }) + ); } const storage = isVariableJsonFile(data) ? new FileStorage(data) : new MemoryStorage(data); const { shallowDatabase, nestedDatabase } = splitDatabaseByNesting(storage.read()); - createShallowDatabaseRoutes(router, shallowDatabase, storage as MemoryStorage); - createNestedDatabaseRoutes(router, nestedDatabase, storage as MemoryStorage); - - router.route('/__db').get((_request, response) => { - response.json(storage.read()); + createShallowDatabaseRoutes({ + router, + database: shallowDatabase, + storage: storage as MemoryStorage, + responseInterceptors: { + apiInterceptor: databaseConfig.interceptors?.response, + serverInterceptor: serverResponseInterceptor + } }); + createNestedDatabaseRoutes({ + router, + database: nestedDatabase, + storage: storage as MemoryStorage, + responseInterceptors: { + apiInterceptor: databaseConfig.interceptors?.response, + serverInterceptor: serverResponseInterceptor + } + }); + + router.route('/__db').get( + asyncHandler(async (request, response) => { + const data = await callResponseInterceptors({ + data: storage.read(), + request, + response, + interceptors: { + apiInterceptor: databaseConfig.interceptors?.response, + serverInterceptor: serverResponseInterceptor + } + }); + response.json(data); + }) + ); return router; }; diff --git a/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.test.ts b/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.test.ts index f3fedb69..5b533e92 100644 --- a/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.test.ts +++ b/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.test.ts @@ -21,11 +21,11 @@ describe('CreateNestedDatabaseRoutes', () => { const routerBase = express.Router(); const storage = new MemoryStorage(nestedDatabase); - const routerWithNestedDatabaseRoutes = createNestedDatabaseRoutes( - routerBase, - nestedDatabase, + const routerWithNestedDatabaseRoutes = createNestedDatabaseRoutes({ + router: routerBase, + database: nestedDatabase, storage - ); + }); server.use(express.json()); server.use(express.text()); diff --git a/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.ts b/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.ts index 2644136e..e5231658 100644 --- a/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.ts +++ b/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.ts @@ -1,7 +1,8 @@ import type { IRouter } from 'express'; import type { ParsedUrlQuery } from 'node:querystring'; -import type { NestedDatabase } from '@/utils/types'; +import { asyncHandler, callResponseInterceptors } from '@/utils/helpers'; +import type { Interceptors, NestedDatabase } from '@/utils/types'; import type { MemoryStorage } from '../../storages'; import { createNewId, findIndexById } from '../array'; @@ -10,74 +11,97 @@ import { pagination } from '../pagination/pagination'; import { search } from '../search/search'; import { sort } from '../sort/sort'; -export const createNestedDatabaseRoutes = ( - router: IRouter, - database: NestedDatabase, - storage: MemoryStorage -) => { +interface CreateNestedDatabaseRoutesParams { + router: IRouter; + database: NestedDatabase; + storage: MemoryStorage; + responseInterceptors?: { + apiInterceptor?: Interceptors['response']; + serverInterceptor?: Interceptors['response']; + }; +} + +export const createNestedDatabaseRoutes = ({ + router, + database, + storage, + responseInterceptors +}: CreateNestedDatabaseRoutesParams) => { Object.keys(database).forEach((key) => { const collectionPath = `/${key}`; const itemPath = `/${key}/:id`; - router.route(collectionPath).get((request, response) => { - let data = storage.read(key); + router.route(collectionPath).get( + asyncHandler(async (request, response) => { + let data = storage.read(key); - if (!Array.isArray(data) || !request.query) { - // ✅ important: - // set 'Cache-Control' header for explicit browsers response revalidate - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - response.set('Cache-control', 'max-age=0, must-revalidate'); - return response.json(data); - } - - const { _page, _limit, _begin, _end, _sort, _order, _q, ...filters } = request.query; + if (!Array.isArray(data) || !request.query) { + // ✅ important: + // set 'Cache-Control' header for explicit browsers response revalidate + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + response.set('Cache-control', 'max-age=0, must-revalidate'); + return response.json(data); + } - if (Object.keys(filters).length) { - data = filter(data, filters as ParsedUrlQuery); - } + const { _page, _limit, _begin, _end, _sort, _order, _q, ...filters } = request.query; - if (_q) { - data = search(data, request.query._q as ParsedUrlQuery); - } + if (Object.keys(filters).length) { + data = filter(data, filters as ParsedUrlQuery); + } - if (_page) { - data = pagination(data, request.query as ParsedUrlQuery); - if (data._link) { - const links = {} as any; - const fullUrl = `${request.protocol}://${request.get('host')}${request.originalUrl}`; + if (_q) { + data = search(data, request.query._q as ParsedUrlQuery); + } - if (data._link.first) { - links.first = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.first}`); - } - if (data._link.prev) { - links.prev = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.prev}`); - } - if (data._link.next) { - links.next = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.next}`); - } - if (data._link.last) { - links.last = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.last}`); + if (_page) { + data = pagination(data, request.query as ParsedUrlQuery); + if (data._link) { + const links = {} as any; + const fullUrl = `${request.protocol}://${request.get('host')}${request.originalUrl}`; + + if (data._link.first) { + links.first = fullUrl.replace( + `page=${data._link.current}`, + `page=${data._link.first}` + ); + } + if (data._link.prev) { + links.prev = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.prev}`); + } + if (data._link.next) { + links.next = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.next}`); + } + if (data._link.last) { + links.last = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.last}`); + } + + data._link = { ...data._link, ...links }; + response.links(links); } + } - data._link = { ...data._link, ...links }; - response.links(links); + if (_sort) { + data = sort(data, request.query as ParsedUrlQuery); } - } - - if (_sort) { - data = sort(data, request.query as ParsedUrlQuery); - } - - if (_begin || _end) { - data = data.slice(request.query._begin ?? 0, request.query._end); - response.set('X-Total-Count', data.length); - } - // ✅ important: - // set 'Cache-Control' header for explicit browsers response revalidate - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - response.set('Cache-control', 'no-cache'); - response.json(data); - }); + + if (_begin || _end) { + data = data.slice(request.query._begin ?? 0, request.query._end); + response.set('X-Total-Count', data.length); + } + // ✅ important: + // set 'Cache-Control' header for explicit browsers response revalidate + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + response.set('Cache-control', 'no-cache'); + + const updatedData = await callResponseInterceptors({ + data, + request, + response, + interceptors: responseInterceptors + }); + response.json(updatedData); + }) + ); router.route(collectionPath).post((request, response) => { const collection = storage.read(key); @@ -88,59 +112,91 @@ export const createNestedDatabaseRoutes = ( response.status(201).json(newResource); }); - router.route(itemPath).get((request, response) => { - const currentResourceCollection = storage.read(key); - const currentResourceIndex = findIndexById(currentResourceCollection, request.params.id); - if (currentResourceIndex === -1) { - response.status(404).end(); - return; - } - - // ✅ important: - // set 'Cache-Control' header for explicit browsers response revalidate - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - response.set('Cache-control', 'no-cache'); - response.json(storage.read([key, currentResourceIndex])); - }); + router.route(itemPath).get( + asyncHandler(async (request, response) => { + const currentResourceCollection = storage.read(key); + const currentResourceIndex = findIndexById(currentResourceCollection, request.params.id); + if (currentResourceIndex === -1) { + response.status(404).end(); + return; + } - router.route(itemPath).put((request, response) => { - const currentResourceCollection = storage.read(key); - const currentResourceIndex = findIndexById(currentResourceCollection, request.params.id); - if (currentResourceIndex === -1) { - response.status(404).end(); - return; - } - - const currentResource = storage.read([key, currentResourceIndex]); - const updatedResource = { ...request.body, id: currentResource.id }; - storage.write([key, currentResourceIndex], updatedResource); - response.json(updatedResource); - }); + // ✅ important: + // set 'Cache-Control' header for explicit browsers response revalidate + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + response.set('Cache-control', 'no-cache'); + const updatedData = await callResponseInterceptors({ + data: storage.read([key, currentResourceIndex]), + request, + response, + interceptors: responseInterceptors + }); + response.json(updatedData); + }) + ); + + router.route(itemPath).put( + asyncHandler(async (request, response) => { + const currentResourceCollection = storage.read(key); + const currentResourceIndex = findIndexById(currentResourceCollection, request.params.id); + if (currentResourceIndex === -1) { + response.status(404).end(); + return; + } - router.route(itemPath).patch((request, response) => { - const currentResourceCollection = storage.read(key); - const currentResourceIndex = findIndexById(currentResourceCollection, request.params.id); - if (currentResourceIndex === -1) { - response.status(404).end(); - return; - } - - const currentResource = storage.read([key, currentResourceIndex]); - const updatedResource = { ...currentResource, ...request.body, id: currentResource.id }; - storage.write([key, currentResourceIndex], updatedResource); - response.json(updatedResource); - }); + const currentResource = storage.read([key, currentResourceIndex]); + const updatedResource = { ...request.body, id: currentResource.id }; + storage.write([key, currentResourceIndex], updatedResource); + const updatedData = await callResponseInterceptors({ + data: updatedResource, + request, + response, + interceptors: responseInterceptors + }); + response.json(updatedData); + }) + ); + + router.route(itemPath).patch( + asyncHandler(async (request, response) => { + const currentResourceCollection = storage.read(key); + const currentResourceIndex = findIndexById(currentResourceCollection, request.params.id); + if (currentResourceIndex === -1) { + response.status(404).end(); + return; + } - router.route(itemPath).delete((request, response) => { - const currentResourceCollection = storage.read(key); - const currentResourceIndex = findIndexById(currentResourceCollection, request.params.id); - if (currentResourceIndex === -1) { - response.status(404).end(); - return; - } - storage.delete([key, currentResourceIndex]); - response.status(204).end(); - }); + const currentResource = storage.read([key, currentResourceIndex]); + const updatedResource = { ...currentResource, ...request.body, id: currentResource.id }; + storage.write([key, currentResourceIndex], updatedResource); + const updatedData = await callResponseInterceptors({ + data: updatedResource, + request, + response, + interceptors: responseInterceptors + }); + response.json(updatedData); + }) + ); + + router.route(itemPath).delete( + asyncHandler(async (request, response) => { + const currentResourceCollection = storage.read(key); + const currentResourceIndex = findIndexById(currentResourceCollection, request.params.id); + if (currentResourceIndex === -1) { + response.status(404).end(); + return; + } + storage.delete([key, currentResourceIndex]); + await callResponseInterceptors({ + data: {}, + request, + response, + interceptors: responseInterceptors + }); + response.status(204).end(); + }) + ); }); return router; diff --git a/src/core/database/createDatabaseRoutes/helpers/createShallowDatabaseRoutes/createShallowDatabaseRoutes.test.ts b/src/core/database/createDatabaseRoutes/helpers/createShallowDatabaseRoutes/createShallowDatabaseRoutes.test.ts index c9246081..76659b06 100644 --- a/src/core/database/createDatabaseRoutes/helpers/createShallowDatabaseRoutes/createShallowDatabaseRoutes.test.ts +++ b/src/core/database/createDatabaseRoutes/helpers/createShallowDatabaseRoutes/createShallowDatabaseRoutes.test.ts @@ -23,11 +23,11 @@ describe('createShallowDatabaseRoutes', () => { const routerBase = express.Router(); const storage = new MemoryStorage(shallowDatabase); - const routerWithRoutesForShallowDatabase = createShallowDatabaseRoutes( - routerBase, - shallowDatabase, + const routerWithRoutesForShallowDatabase = createShallowDatabaseRoutes({ + router: routerBase, + database: shallowDatabase, storage - ); + }); server.use(express.json()); server.use(express.text()); diff --git a/src/core/database/createDatabaseRoutes/helpers/createShallowDatabaseRoutes/createShallowDatabaseRoutes.ts b/src/core/database/createDatabaseRoutes/helpers/createShallowDatabaseRoutes/createShallowDatabaseRoutes.ts index 43271473..894a592f 100644 --- a/src/core/database/createDatabaseRoutes/helpers/createShallowDatabaseRoutes/createShallowDatabaseRoutes.ts +++ b/src/core/database/createDatabaseRoutes/helpers/createShallowDatabaseRoutes/createShallowDatabaseRoutes.ts @@ -1,7 +1,8 @@ import type { IRouter } from 'express'; import type { ParsedUrlQuery } from 'node:querystring'; -import type { ShallowDatabase } from '@/utils/types'; +import { asyncHandler, callResponseInterceptors } from '@/utils/helpers'; +import type { Interceptors, ShallowDatabase } from '@/utils/types'; import type { MemoryStorage } from '../../storages'; import { filter } from '../filter/filter'; @@ -9,93 +10,149 @@ import { pagination } from '../pagination/pagination'; import { search } from '../search/search'; import { sort } from '../sort/sort'; -export const createShallowDatabaseRoutes = ( - router: IRouter, - database: ShallowDatabase, - storage: MemoryStorage -) => { +interface CreateShallowDatabaseRoutesParams { + router: IRouter; + database: ShallowDatabase; + storage: MemoryStorage; + responseInterceptors?: { + apiInterceptor?: Interceptors['response']; + serverInterceptor?: Interceptors['response']; + }; +} + +export const createShallowDatabaseRoutes = ({ + router, + database, + storage, + responseInterceptors +}: CreateShallowDatabaseRoutesParams) => { Object.keys(database).forEach((key) => { const path = `/${key}`; - router.route(path).get((request, response) => { - let data = storage.read(key); - - if (!Array.isArray(data) || !request.query) { - // ✅ important: - // set 'Cache-Control' header for explicit browsers response revalidate - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - response.set('Cache-control', 'no-cache'); - return response.json(data); - } - - data = data.filter((element) => typeof element === 'object' && element !== null); + router.route(path).get( + asyncHandler(async (request, response) => { + let data = storage.read(key); + + if (!Array.isArray(data) || !request.query) { + // ✅ important: + // set 'Cache-Control' header for explicit browsers response revalidate + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + response.set('Cache-control', 'no-cache'); + + const updatedData = await callResponseInterceptors({ + data, + request, + response, + interceptors: responseInterceptors + }); + return response.json(updatedData); + } - const { _page, _limit, _begin, _end, _sort, _order, _q, ...filters } = request.query; + data = data.filter((element) => typeof element === 'object' && element !== null); - if (Object.keys(filters).length) { - data = filter(data, filters as ParsedUrlQuery); - } + const { _page, _limit, _begin, _end, _sort, _order, _q, ...filters } = request.query; - if (_q) { - data = search(data, request.query._q as ParsedUrlQuery); - } + if (Object.keys(filters).length) { + data = filter(data, filters as ParsedUrlQuery); + } - if (_page) { - data = pagination(data, request.query as ParsedUrlQuery); - if (data._link) { - const links = {} as any; - const fullUrl = `${request.protocol}://${request.get('host')}${request.originalUrl}`; + if (_q) { + data = search(data, request.query._q as ParsedUrlQuery); + } - if (data._link.first) { - links.first = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.first}`); - } - if (data._link.prev) { - links.prev = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.prev}`); - } - if (data._link.next) { - links.next = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.next}`); - } - if (data._link.last) { - links.last = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.last}`); + if (_page) { + data = pagination(data, request.query as ParsedUrlQuery); + if (data._link) { + const links = {} as any; + const fullUrl = `${request.protocol}://${request.get('host')}${request.originalUrl}`; + + if (data._link.first) { + links.first = fullUrl.replace( + `page=${data._link.current}`, + `page=${data._link.first}` + ); + } + if (data._link.prev) { + links.prev = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.prev}`); + } + if (data._link.next) { + links.next = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.next}`); + } + if (data._link.last) { + links.last = fullUrl.replace(`page=${data._link.current}`, `page=${data._link.last}`); + } + + data._link = { ...data._link, ...links }; + response.links(links); } + } + + if (_sort) { + data = sort(data, request.query as ParsedUrlQuery); + } - data._link = { ...data._link, ...links }; - response.links(links); + if (_begin || _end) { + data = data.slice(request.query._begin ?? 0, request.query._end); + response.set('X-Total-Count', data.length); } - } - - if (_sort) { - data = sort(data, request.query as ParsedUrlQuery); - } - - if (_begin || _end) { - data = data.slice(request.query._begin ?? 0, request.query._end); - response.set('X-Total-Count', data.length); - } - // ✅ important: - // set 'Cache-Control' header for explicit browsers response revalidate - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - response.set('Cache-control', 'no-cache'); - response.json(data); - }); - - router.route(path).post((request, response) => { - storage.write(key, request.body); - response.set('Location', request.url); - response.status(201).json(request.body); - }); - - router.route(path).put((request, response) => { - storage.write(key, request.body); - response.json(request.body); - }); - - router.route(path).patch((request, response) => { - const currentResource = storage.read(key); - const updatedResource = { ...currentResource, ...request.body }; - storage.write(key, updatedResource); - response.json(updatedResource); - }); + // ✅ important: + // set 'Cache-Control' header for explicit browsers response revalidate + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + response.set('Cache-control', 'no-cache'); + + const updatedData = await callResponseInterceptors({ + data, + request, + response, + interceptors: responseInterceptors + }); + + response.json(updatedData); + }) + ); + + router.route(path).post( + asyncHandler(async (request, response) => { + storage.write(key, request.body); + response.set('Location', request.url); + response.status(201); + const updatedData = await callResponseInterceptors({ + data: request.body, + request, + response, + interceptors: responseInterceptors + }); + response.json(updatedData); + }) + ); + + router.route(path).put( + asyncHandler(async (request, response) => { + storage.write(key, request.body); + const updatedData = await callResponseInterceptors({ + data: request.body, + request, + response, + interceptors: responseInterceptors + }); + response.json(updatedData); + }) + ); + + router.route(path).patch( + asyncHandler(async (request, response) => { + const currentResource = storage.read(key); + const updatedResource = { ...currentResource, ...request.body }; + storage.write(key, updatedResource); + const updatedData = await callResponseInterceptors({ + data: updatedResource, + request, + response, + interceptors: responseInterceptors + }); + response.json(updatedData); + }) + ); }); return router; diff --git a/src/server/createDatabaseMockServer/createDatabaseMockServer.ts b/src/server/createDatabaseMockServer/createDatabaseMockServer.ts index 0ffd7a19..b4fd1ce1 100644 --- a/src/server/createDatabaseMockServer/createDatabaseMockServer.ts +++ b/src/server/createDatabaseMockServer/createDatabaseMockServer.ts @@ -19,7 +19,7 @@ export const createDatabaseMockServer = ( databaseMockServerConfig: Omit, server: Express = express() ) => { - const { cors, staticPath, data, routes } = databaseMockServerConfig; + const { cors, staticPath, data, routes, interceptors } = databaseMockServerConfig; server.set('view engine', 'ejs'); server.set('views', urlJoin(__dirname, '../../static/views')); @@ -51,7 +51,11 @@ export const createDatabaseMockServer = ( staticMiddleware(server, baseUrl, staticPath); } - const routerWithDatabaseRoutes = createDatabaseRoutes(express.Router(), { data, routes }); + const routerWithDatabaseRoutes = createDatabaseRoutes({ + router: express.Router(), + databaseConfig: { data, routes }, + serverResponseInterceptor: interceptors?.response + }); server.use(baseUrl, routerWithDatabaseRoutes); notFoundMiddleware(server, databaseMockServerConfig); diff --git a/src/server/createGraphQLMockServer/createGraphQLMockServer.ts b/src/server/createGraphQLMockServer/createGraphQLMockServer.ts index 07ce2ac2..b20672e6 100644 --- a/src/server/createGraphQLMockServer/createGraphQLMockServer.ts +++ b/src/server/createGraphQLMockServer/createGraphQLMockServer.ts @@ -61,7 +61,11 @@ export const createGraphQLMockServer = ( server.use(baseUrl, routerWithGraphqlRoutes); if (database) { - const routerWithDatabaseRoutes = createDatabaseRoutes(express.Router(), database); + const routerWithDatabaseRoutes = createDatabaseRoutes({ + router: express.Router(), + databaseConfig: database, + serverResponseInterceptor: interceptors?.response + }); server.use(baseUrl, routerWithDatabaseRoutes); } diff --git a/src/server/createMockServer/createMockServer.ts b/src/server/createMockServer/createMockServer.ts index b1960a01..2f669567 100644 --- a/src/server/createMockServer/createMockServer.ts +++ b/src/server/createMockServer/createMockServer.ts @@ -96,8 +96,24 @@ export const createMockServer = ( } if (database) { - const routerWithDatabaseRoutes = createDatabaseRoutes(express.Router(), database); - server.use(baseUrl, routerWithDatabaseRoutes); + const routerWithDatabaseRoutes = createDatabaseRoutes({ + router: express.Router(), + databaseConfig: database, + serverResponseInterceptor: interceptors?.response + }); + + const databaseBaseUrl = urlJoin(baseUrl, database.baseUrl ?? '/'); + + const apiRequestInterceptor = database.interceptors?.request; + if (apiRequestInterceptor) { + requestInterceptorMiddleware({ + server, + path: databaseBaseUrl, + interceptor: apiRequestInterceptor + }); + } + + server.use(databaseBaseUrl, routerWithDatabaseRoutes); } notFoundMiddleware(server, mockServerConfig); diff --git a/src/server/createRestMockServer/createRestMockServer.ts b/src/server/createRestMockServer/createRestMockServer.ts index 32f30eb9..4bb288b9 100644 --- a/src/server/createRestMockServer/createRestMockServer.ts +++ b/src/server/createRestMockServer/createRestMockServer.ts @@ -61,7 +61,11 @@ export const createRestMockServer = ( server.use(baseUrl, routerWithRestRoutes); if (database) { - const routerWithDatabaseRoutes = createDatabaseRoutes(express.Router(), database); + const routerWithDatabaseRoutes = createDatabaseRoutes({ + router: express.Router(), + databaseConfig: database, + serverResponseInterceptor: interceptors?.response + }); server.use(baseUrl, routerWithDatabaseRoutes); } diff --git a/src/utils/types/server.ts b/src/utils/types/server.ts index 46f20afe..955c2f11 100644 --- a/src/utils/types/server.ts +++ b/src/utils/types/server.ts @@ -35,8 +35,10 @@ export interface GraphqlConfig { } export type DatabaseConfig = { + baseUrl?: BaseUrl; data: Record | `${string}.json`; routes?: Record<`/${string}`, `/${string}`> | `${string}.json`; + interceptors?: Interceptors; }; export interface BaseMockServerConfig {