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..baf07319 100644 --- a/src/core/database/createDatabaseRoutes/createDatabaseRoutes.test.ts +++ b/src/core/database/createDatabaseRoutes/createDatabaseRoutes.test.ts @@ -5,108 +5,112 @@ 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); - - server.use(mockServerConfig.baseUrl ?? '/', routesWithDatabaseRoutes); - return server; - }; - - 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 } }); - - 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); - }); - - 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 nestedDatabaseItemResponse = await request(server).get('/users/1'); - expect(nestedDatabaseItemResponse.body).toStrictEqual( - data.users[findIndexById(data.users, 1)] - ); - }); +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 }); - 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; + const databaseBaseUrl = urlJoin(baseUrl ?? '/', database?.baseUrl ?? '/'); - let tmpDirPath: string; - let server: Express; + server.use(databaseBaseUrl, routesWithDatabaseRoutes); + return server; +}; - beforeAll(() => { - tmpDirPath = createTmpDir(); +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 pathToData = path.join(tmpDirPath, './data.json') as `${string}.json`; - fs.writeFileSync(pathToData, JSON.stringify(data)); + 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 pathToRoutes = path.join(tmpDirPath, './routes.json') as `${string}.json`; - fs.writeFileSync(pathToRoutes, JSON.stringify(routes)); + 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); + + 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; + + let tmpDirPath: string; + let server: Express; - server = createServer({ database: { data: pathToData, routes: pathToRoutes } }); - }); + beforeAll(() => { + tmpDirPath = createTmpDir(); - afterAll(() => { - fs.rmSync(tmpDirPath, { recursive: true, force: true }); - }); + const pathToData = path.join(tmpDirPath, './data.json') as `${string}.json`; + fs.writeFileSync(pathToData, JSON.stringify(data)); - 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 pathToRoutes = path.join(tmpDirPath, './routes.json') as `${string}.json`; + fs.writeFileSync(pathToRoutes, JSON.stringify(routes)); - const defaultUrlResponse = await request(server).get('/profile'); - expect(defaultUrlResponse.body).toStrictEqual(data.profile); - }); + server = createServer({ database: { data: pathToData, routes: pathToRoutes } }); + }); + + 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 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); + }); + + 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 } }); +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); - }); + test('Should create /__db route that return data from databaseConfig', async () => { + const response = await request(server).get('/__db'); + expect(response.body).toStrictEqual(data); + }); - test('Should create /__routes route that return routes from databaseConfig', async () => { - const response = await request(server).get('/__routes'); - expect(response.body).toStrictEqual(routes); - }); + test('Should create /__routes route that return routes from databaseConfig', async () => { + const response = await request(server).get('/__routes'); + expect(response.body).toStrictEqual(routes); }); }); diff --git a/src/core/database/createDatabaseRoutes/createDatabaseRoutes.ts b/src/core/database/createDatabaseRoutes/createDatabaseRoutes.ts index e7245223..183dc5f3 100644 --- a/src/core/database/createDatabaseRoutes/createDatabaseRoutes.ts +++ b/src/core/database/createDatabaseRoutes/createDatabaseRoutes.ts @@ -1,6 +1,6 @@ import type { IRouter } from 'express'; -import type { DatabaseConfig, NestedDatabase, ShallowDatabase } from '@/utils/types'; +import type { DatabaseConfig, Interceptors, NestedDatabase, ShallowDatabase } from '@/utils/types'; import { createNestedDatabaseRoutes, @@ -13,7 +13,19 @@ 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) @@ -27,8 +39,24 @@ export const createDatabaseRoutes = (router: IRouter, { data, routes }: Database 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); + 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((_request, response) => { response.json(storage.read()); diff --git a/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.test.ts b/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.test.ts index bc24e738..557f517f 100644 --- a/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.test.ts +++ b/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.test.ts @@ -2,13 +2,13 @@ import type { Express } from 'express'; import express from 'express'; import request from 'supertest'; -import type { NestedDatabase } from '@/utils/types'; +import type { Interceptors, NestedDatabase } from '@/utils/types'; import { MemoryStorage } from '../../storages'; import { createNestedDatabaseRoutes } from './createNestedDatabaseRoutes'; -describe('CreateNestedDatabaseRoutes', () => { +describe('createNestedDatabaseRoutes', () => { const createNestedDatabase = () => ({ users: [ { @@ -28,16 +28,23 @@ describe('CreateNestedDatabaseRoutes', () => { ] }); - const createServer = (nestedDatabase: NestedDatabase) => { + const createServer = ( + nestedDatabase: NestedDatabase, + responseInterceptors?: { + apiInterceptor?: Interceptors['response']; + serverInterceptor?: Interceptors['response']; + } + ) => { const server = express(); const routerBase = express.Router(); const storage = new MemoryStorage(nestedDatabase); - const routerWithNestedDatabaseRoutes = createNestedDatabaseRoutes( - routerBase, - nestedDatabase, - storage - ); + const routerWithNestedDatabaseRoutes = createNestedDatabaseRoutes({ + router: routerBase, + database: nestedDatabase, + storage, + responseInterceptors + }); server.use(express.json()); server.use(express.text()); @@ -675,4 +682,19 @@ describe('CreateNestedDatabaseRoutes', () => { ]); }); }); + + describe('createNestedDatabaseRoutes: interceptors', () => { + test('Should call response interceptors', async () => { + const apiInterceptor = vi.fn(); + const serverInterceptor = vi.fn(); + + const nestedDatabase = createNestedDatabase(); + const server = createServer(nestedDatabase, { apiInterceptor, serverInterceptor }); + + await request(server).get('/users'); + + expect(apiInterceptor.mock.calls.length).toBe(1); + expect(serverInterceptor.mock.calls.length).toBe(1); + }); + }); }); diff --git a/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.ts b/src/core/database/createDatabaseRoutes/helpers/createNestedDatabaseRoutes/createNestedDatabaseRoutes.ts index 332c0294..8a367808 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, isPlainObject } from '@/utils/helpers'; +import type { Interceptors, NestedDatabase } from '@/utils/types'; import type { MemoryStorage } from '../../storages'; import { createNewId, findIndexById } from '../array'; @@ -10,140 +11,220 @@ 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 (Object.keys(filters).length) { + data = filter(data, filters as ParsedUrlQuery); + } + + if (_q) { + data = search(data, request.query._q 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: + // The pagination should be last because it changes the form of the response + 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 (!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 (Object.keys(filters).length) { - data = filter(data, filters as ParsedUrlQuery); - } - - if (_q) { - data = search(data, request.query._q 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: - // The pagination should be last because it changes the form of the response - 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}`); - } + response.set('Cache-control', 'no-cache'); + + const updatedData = await callResponseInterceptors({ + data, + request, + response, + interceptors: responseInterceptors + }); + + response.json(updatedData); + }) + ); + + router.route(collectionPath).post( + asyncHandler(async (request, response) => { + const collection = storage.read(key); + const newResourceId = createNewId(collection); + const newResource = { ...request.body, id: newResourceId }; + + storage.write([key, collection.length], newResource); + + const updatedResource = await callResponseInterceptors({ + data: newResource, + request, + response, + interceptors: responseInterceptors + }); + + if (isPlainObject(updatedResource) && typeof updatedResource.id === 'number') { + response.set('Location', `${request.url}/${updatedResource.id}`); + response.status(201).json(updatedResource); + return; + } + response.status(400).end(); + }) + ); + + 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; + } - data._link = { ...data._link, ...links }; - response.links(links); + // ✅ 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; } - } - - // ✅ 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(collectionPath).post((request, response) => { - const collection = storage.read(key); - const newResourceId = createNewId(collection); - const newResource = { ...request.body, id: newResourceId }; - storage.write([key, collection.length], newResource); - response.set('Location', `${request.url}/${newResourceId}`); - 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).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); - }); - - 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); - }); - - 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 = { ...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; + } + + 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: null, + 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 c3b82613..55468a3f 100644 --- a/src/core/database/createDatabaseRoutes/helpers/createShallowDatabaseRoutes/createShallowDatabaseRoutes.test.ts +++ b/src/core/database/createDatabaseRoutes/helpers/createShallowDatabaseRoutes/createShallowDatabaseRoutes.test.ts @@ -2,7 +2,7 @@ import type { Express } from 'express'; import express from 'express'; import request from 'supertest'; -import type { ShallowDatabase } from '@/utils/types'; +import type { Interceptors, ShallowDatabase } from '@/utils/types'; import { MemoryStorage } from '../../storages'; @@ -18,16 +18,23 @@ describe('createShallowDatabaseRoutes', () => { jane: { name: 'Jane Smith', age: 30 } }); - const createServer = (shallowDatabase: ShallowDatabase) => { + const createServer = ( + shallowDatabase: ShallowDatabase, + responseInterceptors?: { + apiInterceptor?: Interceptors['response']; + serverInterceptor?: Interceptors['response']; + } + ) => { const server = express(); const routerBase = express.Router(); const storage = new MemoryStorage(shallowDatabase); - const routerWithRoutesForShallowDatabase = createShallowDatabaseRoutes( - routerBase, - shallowDatabase, - storage - ); + const routerWithRoutesForShallowDatabase = createShallowDatabaseRoutes({ + router: routerBase, + database: shallowDatabase, + storage, + responseInterceptors + }); server.use(express.json()); server.use(express.text()); @@ -521,4 +528,19 @@ describe('createShallowDatabaseRoutes', () => { ]); }); }); + + describe('createShallowDatabaseRoutes: interceptors', () => { + test('Should call response interceptors', async () => { + const apiInterceptor = vi.fn(); + const serverInterceptor = vi.fn(); + + const shallowDatabase = createShallowDatabase(); + const server = createServer(shallowDatabase, { apiInterceptor, serverInterceptor }); + + await request(server).get('/users'); + + expect(apiInterceptor.mock.calls.length).toBe(1); + expect(serverInterceptor.mock.calls.length).toBe(1); + }); + }); }); diff --git a/src/core/database/createDatabaseRoutes/helpers/createShallowDatabaseRoutes/createShallowDatabaseRoutes.ts b/src/core/database/createDatabaseRoutes/helpers/createShallowDatabaseRoutes/createShallowDatabaseRoutes.ts index bb69bc60..22eeffda 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,96 +10,160 @@ 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 (_sort) { - data = sort(data, request.query as ParsedUrlQuery); - } + if (_q) { + data = search(data, request.query._q as ParsedUrlQuery); + } - 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); + } - // ✅ important: - // The pagination should be last because it changes the form of the response - 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 (_begin || _end) { + data = data.slice(request.query._begin ?? 0, request.query._end); + response.set('X-Total-Count', data.length); + } - 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}`); + // ✅ important: + // The pagination should be last because it changes the form of the response + 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); } - } - - // ✅ 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 5c19e02c..ae609ced 100644 --- a/src/server/createDatabaseMockServer/createDatabaseMockServer.ts +++ b/src/server/createDatabaseMockServer/createDatabaseMockServer.ts @@ -20,7 +20,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')); @@ -37,7 +37,7 @@ export const createDatabaseMockServer = ( cookieParseMiddleware(server); - const serverRequestInterceptor = databaseMockServerConfig.interceptors?.request; + const serverRequestInterceptor = interceptors?.request; if (serverRequestInterceptor) { requestInterceptorMiddleware({ server, interceptor: serverRequestInterceptor }); } @@ -54,7 +54,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 631587d6..eb4e9914 100644 --- a/src/server/createGraphQLMockServer/createGraphQLMockServer.ts +++ b/src/server/createGraphQLMockServer/createGraphQLMockServer.ts @@ -38,7 +38,7 @@ export const createGraphQLMockServer = ( cookieParseMiddleware(server); - const serverRequestInterceptor = graphqlMockServerConfig.interceptors?.request; + const serverRequestInterceptor = interceptors?.request; if (serverRequestInterceptor) { requestInterceptorMiddleware({ server, interceptor: serverRequestInterceptor }); } @@ -64,7 +64,23 @@ 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 + }); + + const databaseBaseUrl = urlJoin(baseUrl, database.baseUrl ?? '/'); + + const apiRequestInterceptor = database.interceptors?.request; + if (apiRequestInterceptor) { + requestInterceptorMiddleware({ + server, + path: databaseBaseUrl, + interceptor: apiRequestInterceptor + }); + } + server.use(baseUrl, routerWithDatabaseRoutes); } diff --git a/src/server/createMockServer/createMockServer.ts b/src/server/createMockServer/createMockServer.ts index 372eef0c..ebaf1443 100644 --- a/src/server/createMockServer/createMockServer.ts +++ b/src/server/createMockServer/createMockServer.ts @@ -39,7 +39,7 @@ export const createMockServer = ( cookieParseMiddleware(server); - const serverRequestInterceptor = mockServerConfig.interceptors?.request; + const serverRequestInterceptor = interceptors?.request; if (serverRequestInterceptor) { requestInterceptorMiddleware({ server, interceptor: serverRequestInterceptor }); } @@ -99,8 +99,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 c3e4e9e6..9711c55a 100644 --- a/src/server/createRestMockServer/createRestMockServer.ts +++ b/src/server/createRestMockServer/createRestMockServer.ts @@ -38,7 +38,7 @@ export const createRestMockServer = ( cookieParseMiddleware(server); - const serverRequestInterceptor = restMockServerConfig.interceptors?.request; + const serverRequestInterceptor = interceptors?.request; if (serverRequestInterceptor) { requestInterceptorMiddleware({ server, interceptor: serverRequestInterceptor }); } @@ -64,7 +64,23 @@ 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 + }); + + const databaseBaseUrl = urlJoin(baseUrl, database.baseUrl ?? '/'); + + const apiRequestInterceptor = database.interceptors?.request; + if (apiRequestInterceptor) { + requestInterceptorMiddleware({ + server, + path: databaseBaseUrl, + interceptor: apiRequestInterceptor + }); + } + server.use(baseUrl, routerWithDatabaseRoutes); } diff --git a/src/utils/types/server.ts b/src/utils/types/server.ts index 73fef260..81947480 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 {