From fda6faf1990e82fc435f26b488f4fa59f911e7d8 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 3 Jun 2024 18:09:15 +0200 Subject: [PATCH 01/33] Refactor interceptors as middlewares --- example/App.tsx | 19 ++- example/authProvider.ts | 43 ++++++ example/dataProvider.ts | 18 ++- example/fetchMock.ts | 22 ++- example/msw.ts | 34 ++++- example/users.json | 18 +++ package-lock.json | 42 +++--- package.json | 2 +- public/sinon.html | 24 +-- src/BaseServer.ts | 314 ++++++++++++++++------------------------ src/Collection.ts | 6 +- src/FetchMockServer.ts | 124 +++++++--------- src/InternalServer.ts | 264 +++++++++++++++++++++++++++++++++ src/Single.ts | 6 +- src/SinonServer.spec.ts | 278 +++++++++++++---------------------- src/SinonServer.ts | 218 +++++++--------------------- src/msw.ts | 83 +++++++---- 17 files changed, 833 insertions(+), 682 deletions(-) create mode 100644 example/authProvider.ts create mode 100644 example/users.json create mode 100644 src/InternalServer.ts diff --git a/example/App.tsx b/example/App.tsx index 961b4ea..0b3de01 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -7,11 +7,24 @@ import { Resource, ShowGuesser, } from 'react-admin'; +import { QueryClient } from 'react-query'; import { dataProvider } from './dataProvider'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, +}); + export const App = () => { return ( - + { list={ListGuesser} edit={EditGuesser} show={ShowGuesser} + recordRepresentation={(record) => + `${record.first_name} ${record.last_name}` + } /> ); }; import { Edit, ReferenceInput, SimpleForm, TextInput } from 'react-admin'; +import authProvider from './authProvider'; export const BookCreate = () => ( diff --git a/example/authProvider.ts b/example/authProvider.ts new file mode 100644 index 0000000..91eba41 --- /dev/null +++ b/example/authProvider.ts @@ -0,0 +1,43 @@ +import { type AuthProvider, HttpError } from 'react-admin'; +import data from './users.json'; + +/** + * This authProvider is only for test purposes. Don't use it in production. + */ +export const authProvider: AuthProvider = { + login: ({ username, password }) => { + const user = data.users.find( + (u) => u.username === username && u.password === password, + ); + + if (user) { + const { password, ...userToPersist } = user; + localStorage.setItem('user', JSON.stringify(userToPersist)); + return Promise.resolve(); + } + + return Promise.reject( + new HttpError('Unauthorized', 401, { + message: 'Invalid username or password', + }), + ); + }, + logout: () => { + localStorage.removeItem('user'); + return Promise.resolve(); + }, + checkError: () => Promise.resolve(), + checkAuth: () => + localStorage.getItem('user') ? Promise.resolve() : Promise.reject(), + getPermissions: () => { + return Promise.resolve(undefined); + }, + getIdentity: () => { + const persistedUser = localStorage.getItem('user'); + const user = persistedUser ? JSON.parse(persistedUser) : null; + + return Promise.resolve(user); + }, +}; + +export default authProvider; diff --git a/example/dataProvider.ts b/example/dataProvider.ts index d7106a2..db61424 100644 --- a/example/dataProvider.ts +++ b/example/dataProvider.ts @@ -1,3 +1,19 @@ import simpleRestProvider from 'ra-data-simple-rest'; +import { fetchUtils } from 'react-admin'; -export const dataProvider = simpleRestProvider('http://localhost:3000'); +const httpClient = (url: string, options: any = {}) => { + if (!options.headers) { + options.headers = new Headers({ Accept: 'application/json' }); + } + const persistedUser = localStorage.getItem('user'); + const user = persistedUser ? JSON.parse(persistedUser) : null; + if (user) { + options.headers.set('Authorization', `Bearer ${user.id}`); + } + return fetchUtils.fetchJson(url, options); +}; + +export const dataProvider = simpleRestProvider( + 'http://localhost:3000', + httpClient, +); diff --git a/example/fetchMock.ts b/example/fetchMock.ts index aaffc9e..19c1265 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -5,12 +5,30 @@ import { data } from './data'; export const initializeFetchMock = () => { const restServer = new FakeRest.FetchServer({ baseUrl: 'http://localhost:3000', + data, + loggingEnabled: true, }); if (window) { // @ts-ignore window.restServer = restServer; // give way to update data in the console } - restServer.init(data); - restServer.toggleLogging(); // logging is off by default, enable it + restServer.addMiddleware(async (request, context, next) => { + if (!request.headers?.get('Authorization')) { + return new Response(null, { status: 401 }); + } + + if ( + context.collection === 'books' && + request.method === 'POST' && + !context.requestJson?.title + ) { + return new Response(null, { + status: 400, + statusText: 'Title is required', + }); + } + + return next(request, context); + }); fetchMock.mock('begin:http://localhost:3000', restServer.getHandler()); }; diff --git a/example/msw.ts b/example/msw.ts index 34ae693..1b75528 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -1,10 +1,30 @@ import { setupWorker } from 'msw/browser'; -import { getMswHandlers } from '../src/FakeRest'; +import { MswServer } from '../src/FakeRest'; import { data } from './data'; +import { HttpResponse } from 'msw'; -export const worker = setupWorker( - ...getMswHandlers({ - baseUrl: 'http://localhost:3000', - data, - }), -); +const restServer = new MswServer({ + baseUrl: 'http://localhost:3000', + data, +}); + +restServer.addMiddleware(async (request, context, next) => { + if (!request.headers?.get('Authorization')) { + return new HttpResponse(null, { status: 401 }); + } + + if ( + context.collection === 'books' && + request.method === 'POST' && + !context.requestJson?.title + ) { + return new HttpResponse(null, { + status: 400, + statusText: 'Title is required', + }); + } + + return next(request, context); +}); + +export const worker = setupWorker(...restServer.getHandlers()); diff --git a/example/users.json b/example/users.json new file mode 100644 index 0000000..fc14c48 --- /dev/null +++ b/example/users.json @@ -0,0 +1,18 @@ +{ + "users": [ + { + "id": 1, + "username": "janedoe", + "password": "password", + "fullName": "Jane Doe", + "avatar": "data:image/jpeg;base64,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" + }, + { + "id": 2, + "username": "johndoe", + "password": "password", + "fullName": "John Doe", + "avatar": "data:image/jpeg;base64,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" + } + ] +} diff --git a/package-lock.json b/package-lock.json index a836a8e..a1d07c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "react": "^17.0.2", "react-admin": "^4.16.15", "react-dom": "^17.0.2", - "sinon": "~17.0.1", + "sinon": "~18.0.0", "typescript": "^5.4.5", "vite": "^5.2.9", "vite-plugin-dts": "^3.8.3", @@ -5724,9 +5724,9 @@ } }, "node_modules/nise": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", - "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.0.0.tgz", + "integrity": "sha512-K8ePqo9BFvN31HXwEtTNGzgrPpmvgciDsFz8aztFjt4LqKO/JeFD8tBOeuDiCMXrIl/m1YvfH8auSpxfaD09wg==", "dev": true, "dependencies": { "@sinonjs/commons": "^3.0.0", @@ -6799,17 +6799,17 @@ } }, "node_modules/sinon": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", - "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.0.tgz", + "integrity": "sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==", "dev": true, "dependencies": { - "@sinonjs/commons": "^3.0.0", + "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^11.2.2", "@sinonjs/samsam": "^8.0.0", - "diff": "^5.1.0", - "nise": "^5.1.5", - "supports-color": "^7.2.0" + "diff": "^5.2.0", + "nise": "^6.0.0", + "supports-color": "^7" }, "funding": { "type": "opencollective", @@ -12080,9 +12080,9 @@ "dev": true }, "nise": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", - "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.0.0.tgz", + "integrity": "sha512-K8ePqo9BFvN31HXwEtTNGzgrPpmvgciDsFz8aztFjt4LqKO/JeFD8tBOeuDiCMXrIl/m1YvfH8auSpxfaD09wg==", "dev": true, "requires": { "@sinonjs/commons": "^3.0.0", @@ -12878,17 +12878,17 @@ "dev": true }, "sinon": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", - "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.0.tgz", + "integrity": "sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==", "dev": true, "requires": { - "@sinonjs/commons": "^3.0.0", + "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^11.2.2", "@sinonjs/samsam": "^8.0.0", - "diff": "^5.1.0", - "nise": "^5.1.5", - "supports-color": "^7.2.0" + "diff": "^5.2.0", + "nise": "^6.0.0", + "supports-color": "^7" }, "dependencies": { "has-flag": { diff --git a/package.json b/package.json index dc115b8..d6cc8f3 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "react": "^17.0.2", "react-admin": "^4.16.15", "react-dom": "^17.0.2", - "sinon": "~17.0.1", + "sinon": "~18.0.0", "typescript": "^5.4.5", "vite": "^5.2.9", "vite-plugin-dts": "^3.8.3", diff --git a/public/sinon.html b/public/sinon.html index 3d813b7..890af55 100644 --- a/public/sinon.html +++ b/public/sinon.html @@ -61,18 +61,22 @@

POST /books { author_id: 1, title: 'Emma' }

// Now query the fake REST server var req = new XMLHttpRequest(); req.open("GET", "/authors", false); -req.send(null); -document.getElementById('req1').value = req.responseText; +req.send(); +req.onload = function() { + document.getElementById('req1').value = req.responseText; +}; -var req = new XMLHttpRequest(); -req.open("GET", "/books/3", false); -req.send(null); -document.getElementById('req2').value = req.responseText; +// var req = new XMLHttpRequest(); +// req.open("GET", "/books/3", false); +// req.send(); +// req.onload = function() { +// document.getElementById('req2').value = req.responseText; +// }; -var req = new XMLHttpRequest(); -req.open("POST", "/books", false); -req.send(JSON.stringify({ author_id: 1, title: 'Emma' })); -document.getElementById('req3').value = req.responseText; +// var req = new XMLHttpRequest(); +// req.open("POST", "/books", false); +// req.send(JSON.stringify({ author_id: 1, title: 'Emma' })); +// document.getElementById('req3').value = req.responseText; // restore native XHR constructor server.restore(); diff --git a/src/BaseServer.ts b/src/BaseServer.ts index 5e46844..6b6c033 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -1,177 +1,122 @@ -import { Collection } from './Collection.js'; -import { Single } from './Single.js'; -import type { CollectionItem, Query, QueryFunction } from './types.js'; - -export class BaseServer { - baseUrl = ''; - identifierName = 'id'; - loggingEnabled = false; - defaultQuery: QueryFunction = () => ({}); - batchUrl: string | null = null; - collections: Record> = {}; - singles: Record> = {}; - getNewId?: () => number | string; - - constructor({ - baseUrl = '', - batchUrl = null, - data, - defaultQuery = () => ({}), - identifierName = 'id', - getNewId, - loggingEnabled = false, - }: BaseServerOptions = {}) { - this.baseUrl = baseUrl; - this.batchUrl = batchUrl; - this.getNewId = getNewId; - this.loggingEnabled = loggingEnabled; - this.identifierName = identifierName; - this.defaultQuery = defaultQuery; - - if (data) { - this.init(data); - } - } +import { + type BaseRequest, + type BaseResponse, + type FakeRestContext, + InternalServer, +} from './InternalServer.js'; + +export abstract class BaseServer< + RequestType, + ResponseType, +> extends InternalServer { + middlewares: Array> = []; + + decodeRequest(request: BaseRequest): BaseRequest { + for (const name of this.getSingleNames()) { + const matches = request.url?.match( + new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), + ); - /** - * Shortcut for adding several collections if identifierName is always the same - */ - init(data: Record) { - for (const name in data) { - const value = data[name]; - if (Array.isArray(value)) { - this.addCollection( - name, - new Collection({ - items: value, - identifierName: this.identifierName, - getNewId: this.getNewId, - }), - ); - } else { - this.addSingle(name, new Single(value)); + if (matches) { + request.single = name; + return request; } } - } - - toggleLogging() { - this.loggingEnabled = !this.loggingEnabled; - } - /** - * @param Function ResourceName => object - */ - setDefaultQuery(query: QueryFunction) { - this.defaultQuery = query; - } - - setBatchUrl(batchUrl: string) { - this.batchUrl = batchUrl; - } - - /** - * @deprecated use setBatchUrl instead - */ - setBatch(url: string) { - console.warn( - 'Server.setBatch() is deprecated, use Server.setBatchUrl() instead', + const matches = request.url?.match( + new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), ); - this.batchUrl = url; - } - addCollection( - name: string, - collection: Collection, - ) { - this.collections[name] = collection; - collection.setServer(this); - collection.setName(name); - } - - getCollection(name: string) { - return this.collections[name]; - } - - getCollectionNames() { - return Object.keys(this.collections); - } + if (matches) { + const name = matches[1]; + const params = Object.assign( + {}, + this.defaultQuery(name), + request.params, + ); - addSingle( - name: string, - single: Single, - ) { - this.singles[name] = single; - single.setServer(this); - single.setName(name); - } + request.collection = name; + request.params = params; + return request; + } - getSingle(name: string) { - return this.singles[name]; + return request; } - getSingleNames() { - return Object.keys(this.singles); - } + abstract extractContext( + request: RequestType, + ): Promise< + Pick + >; + abstract respond( + response: BaseResponse, + request: RequestType, + context: FakeRestContext, + ): Promise; + + addBaseContext(context: FakeRestContext): FakeRestContext { + for (const name of this.getSingleNames()) { + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), + ); + if (!matches) continue; + return { + ...context, + single: name, + }; + } - /** - * @param {string} name - * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } - */ - getCount(name: string, params?: Query) { - return this.collections[name].getCount(params); - } + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), + ); + if (matches) { + const name = matches[1]; + const params = Object.assign( + {}, + this.defaultQuery(name), + context.params, + ); - /** - * @param {string} name - * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } - */ - getAll(name: string, params?: Query) { - return this.collections[name].getAll(params); - } + return { + ...context, + collection: name, + params, + }; + } - getOne(name: string, identifier: string | number, params?: Query) { - return this.collections[name].getOne(identifier, params); + return context; } - addOne(name: string, item: CollectionItem) { - if (!Object.prototype.hasOwnProperty.call(this.collections, name)) { - this.addCollection( - name, - new Collection({ - items: [], - identifierName: 'id', - getNewId: this.getNewId, - }), - ); - } - return this.collections[name].addOne(item); - } + async handle(request: RequestType): Promise { + const context = this.addBaseContext(await this.extractContext(request)); - updateOne(name: string, identifier: string | number, item: CollectionItem) { - return this.collections[name].updateOne(identifier, item); - } + // Call middlewares + let index = 0; + const middlewares = [...this.middlewares]; - removeOne(name: string, identifier: string | number) { - return this.collections[name].removeOne(identifier); - } + const next = (req: RequestType, ctx: FakeRestContext) => { + const middleware = middlewares[index++]; + if (middleware) { + return middleware(req, ctx, next); + } - getOnly(name: string, params?: Query) { - return this.singles[name].getOnly(); - } + return this.handleRequest(req, ctx); + }; - updateOnly(name: string, item: CollectionItem) { - return this.singles[name].updateOnly(item); + const response = await next(request, context); + // @ts-ignore + return this.respond(response, request, context); } - handleRequest(request: BaseRequest, opts?: RequestInit): BaseResponse { + handleRequest(request: RequestType, ctx: FakeRestContext): BaseResponse { // Handle Single Objects for (const name of this.getSingleNames()) { - const matches = request.url?.match( + const matches = ctx.url?.match( new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), ); if (!matches) continue; - if (request.method === 'GET') { + if (ctx.method === 'GET') { try { return { status: 200, @@ -187,9 +132,9 @@ export class BaseServer { }; } } - if (request.method === 'PUT') { + if (ctx.method === 'PUT') { try { - if (request.requestJson == null) { + if (ctx.requestJson == null) { return { status: 400, headers: {}, @@ -197,7 +142,7 @@ export class BaseServer { } return { status: 200, - body: this.updateOnly(name, request.requestJson), + body: this.updateOnly(name, ctx.requestJson), headers: { 'Content-Type': 'application/json', }, @@ -209,9 +154,9 @@ export class BaseServer { }; } } - if (request.method === 'PATCH') { + if (ctx.method === 'PATCH') { try { - if (request.requestJson == null) { + if (ctx.requestJson == null) { return { status: 400, headers: {}, @@ -219,7 +164,7 @@ export class BaseServer { } return { status: 200, - body: this.updateOnly(name, request.requestJson), + body: this.updateOnly(name, ctx.requestJson), headers: { 'Content-Type': 'application/json', }, @@ -234,20 +179,16 @@ export class BaseServer { } // handle collections - const matches = request.url?.match( + const matches = ctx.url?.match( new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), ); if (!matches) { return { status: 404, headers: {} }; } const name = matches[1]; - const params = Object.assign( - {}, - this.defaultQuery(name), - request.params, - ); + const params = Object.assign({}, this.defaultQuery(name), ctx.params); if (!matches[2]) { - if (request.method === 'GET') { + if (ctx.method === 'GET') { if (!this.getCollection(name)) { return { status: 404, headers: {} }; } @@ -285,15 +226,15 @@ export class BaseServer { }, }; } - if (request.method === 'POST') { - if (request.requestJson == null) { + if (ctx.method === 'POST') { + if (ctx.requestJson == null) { return { status: 400, headers: {}, }; } - const newResource = this.addOne(name, request.requestJson); + const newResource = this.addOne(name, ctx.requestJson); const newResourceURI = `${this.baseUrl}/${name}/${ newResource[this.getCollection(name).identifierName] }`; @@ -312,7 +253,7 @@ export class BaseServer { return { status: 404, headers: {} }; } const id = Number.parseInt(matches[3]); - if (request.method === 'GET') { + if (ctx.method === 'GET') { try { return { status: 200, @@ -328,9 +269,9 @@ export class BaseServer { }; } } - if (request.method === 'PUT') { + if (ctx.method === 'PUT') { try { - if (request.requestJson == null) { + if (ctx.requestJson == null) { return { status: 400, headers: {}, @@ -338,7 +279,7 @@ export class BaseServer { } return { status: 200, - body: this.updateOne(name, id, request.requestJson), + body: this.updateOne(name, id, ctx.requestJson), headers: { 'Content-Type': 'application/json', }, @@ -350,9 +291,9 @@ export class BaseServer { }; } } - if (request.method === 'PATCH') { + if (ctx.method === 'PATCH') { try { - if (request.requestJson == null) { + if (ctx.requestJson == null) { return { status: 400, headers: {}, @@ -360,7 +301,7 @@ export class BaseServer { } return { status: 200, - body: this.updateOne(name, id, request.requestJson), + body: this.updateOne(name, id, ctx.requestJson), headers: { 'Content-Type': 'application/json', }, @@ -372,7 +313,7 @@ export class BaseServer { }; } } - if (request.method === 'DELETE') { + if (ctx.method === 'DELETE') { try { return { status: 200, @@ -394,27 +335,14 @@ export class BaseServer { headers: {}, }; } -} -export type BaseServerOptions = { - baseUrl?: string; - batchUrl?: string | null; - data?: Record; - defaultQuery?: QueryFunction; - identifierName?: string; - getNewId?: () => number | string; - loggingEnabled?: boolean; -}; - -type BaseRequest = { - url?: string; - method?: string; - requestJson?: Record | undefined; - params?: { [key: string]: any }; -}; + addMiddleware(middleware: Middleware) { + this.middlewares.push(middleware); + } +} -type BaseResponse = { - status: number; - body?: Record | Record[]; - headers: { [key: string]: string }; -}; +export type Middleware = ( + request: RequestType, + context: FakeRestContext, + next: (req: RequestType, ctx: FakeRestContext) => Promise, +) => Promise; diff --git a/src/Collection.ts b/src/Collection.ts index 9be8fac..18fbde7 100644 --- a/src/Collection.ts +++ b/src/Collection.ts @@ -1,6 +1,6 @@ import get from 'lodash/get.js'; import matches from 'lodash/matches.js'; -import type { BaseServer } from './BaseServer.js'; +import type { InternalServer } from './InternalServer.js'; import type { CollectionItem, Embed, @@ -14,7 +14,7 @@ import type { export class Collection { sequence = 0; items: T[] = []; - server: BaseServer | null = null; + server: InternalServer | null = null; name: string | null = null; identifierName = 'id'; getNewId: () => number | string; @@ -42,7 +42,7 @@ export class Collection { * A Collection may need to access other collections (e.g. for embedding references) * This is done through a reference to the parent server. */ - setServer(server: BaseServer) { + setServer(server: InternalServer) { this.server = server; } diff --git a/src/FetchMockServer.ts b/src/FetchMockServer.ts index 1c87a65..aa5095e 100644 --- a/src/FetchMockServer.ts +++ b/src/FetchMockServer.ts @@ -1,55 +1,58 @@ -import { BaseServer, type BaseServerOptions } from './BaseServer.js'; +import type { MockResponseObject, MockMatcherFunction } from 'fetch-mock'; +import { BaseServer } from './BaseServer.js'; +import type { + BaseResponse, + BaseServerOptions, + FakeRestContext, +} from './InternalServer.js'; import { parseQueryString } from './parseQueryString.js'; -import type { MockResponse, MockResponseObject } from 'fetch-mock'; -export class FetchMockServer extends BaseServer { - requestInterceptors: FetchMockRequestInterceptor[] = []; - responseInterceptors: FetchMockResponseInterceptor[] = []; - - decode(request: Request, opts?: RequestInit) { - const req: FetchMockFakeRestRequest = - typeof request === 'string' ? new Request(request, opts) : request; - req.queryString = req.url +export class FetchMockServer extends BaseServer { + async extractContext(request: Request) { + const req = + typeof request === 'string' ? new Request(request) : request; + const queryString = req.url ? decodeURIComponent(req.url.slice(req.url.indexOf('?') + 1)) : ''; - req.params = parseQueryString(req.queryString); - return (req as Request) - .text() - .then((text) => { - req.requestBody = text; - try { - req.requestJson = JSON.parse(text); - } catch (e) { - // not JSON, no big deal - } - }) - .then(() => - this.requestInterceptors.reduce( - (previous, current) => current(previous), - req, - ), - ); - } + const params = parseQueryString(queryString); + const text = await req.text(); + let requestJson: Record | undefined = undefined; + try { + requestJson = JSON.parse(text); + } catch (e) { + // not JSON, no big deal + } - respond(response: MockResponseObject, request: FetchMockFakeRestRequest) { - const resp = this.responseInterceptors.reduce( - (previous, current) => current(previous, request), - response, - ); - this.log(request, resp); + return { + url: req.url, + params, + requestJson, + method: req.method, + }; + } - return resp; + async respond( + response: BaseResponse, + request: FetchMockFakeRestRequest, + context: FakeRestContext, + ) { + this.log(request, response, context); + return response; } - log(request: FetchMockFakeRestRequest, response: MockResponseObject) { + log( + request: FetchMockFakeRestRequest, + response: MockResponseObject, + context: FakeRestContext, + ) { if (!this.loggingEnabled) return; if (console.group) { // Better logging in Chrome - console.groupCollapsed(request.method, request.url, '(FakeRest)'); + console.groupCollapsed(context.method, context.url, '(FakeRest)'); console.group('request'); - console.log(request.method, request.url); + console.log(context.method, context.url); console.log('headers', request.headers); - console.log('body ', request.requestBody); + console.log('body ', request.requestJson); console.groupEnd(); console.group('response', response.status); console.log('headers', response.headers); @@ -59,12 +62,12 @@ export class FetchMockServer extends BaseServer { } else { console.log( 'FakeRest request ', - request.method, - request.url, + context.method, + context.url, 'headers', request.headers, 'body', - request.requestBody, + request.requestJson, ); console.log( 'FakeRest response', @@ -77,39 +80,12 @@ export class FetchMockServer extends BaseServer { } } - batch(request: any) { - throw new Error('not implemented'); - } - - /** - * @param {Request} fetch request - * @param {Object} options - * - */ - handle(req: Request, opts?: RequestInit) { - return this.decode(req, opts).then((request) => { - // handle batch request - if ( - this.batchUrl && - this.batchUrl === request.url && - request.method === 'POST' - ) { - return this.batch(request); - } - - const response = this.handleRequest({ - url: request.url, - method: request.method, - requestJson: request.requestJson, - params: request.params, - }); - - return this.respond(response, request); - }); - } - getHandler() { - return this.handle.bind(this); + const handler = (url: string, options: RequestInit) => { + return this.handle(new Request(url, options)); + }; + + return handler; } } diff --git a/src/InternalServer.ts b/src/InternalServer.ts new file mode 100644 index 0000000..2964c6c --- /dev/null +++ b/src/InternalServer.ts @@ -0,0 +1,264 @@ +import { Collection } from './Collection.js'; +import { Single } from './Single.js'; +import type { CollectionItem, Query, QueryFunction } from './types.js'; + +export abstract class InternalServer { + baseUrl = ''; + identifierName = 'id'; + loggingEnabled = false; + defaultQuery: QueryFunction = () => ({}); + batchUrl: string | null = null; + collections: Record> = {}; + singles: Record> = {}; + getNewId?: () => number | string; + + constructor({ + baseUrl = '', + batchUrl = null, + data, + defaultQuery = () => ({}), + identifierName = 'id', + getNewId, + loggingEnabled = false, + }: BaseServerOptions = {}) { + this.baseUrl = baseUrl; + this.batchUrl = batchUrl; + this.getNewId = getNewId; + this.loggingEnabled = loggingEnabled; + this.identifierName = identifierName; + this.defaultQuery = defaultQuery; + + if (data) { + this.init(data); + } + } + + /** + * Shortcut for adding several collections if identifierName is always the same + */ + init(data: Record) { + for (const name in data) { + const value = data[name]; + if (Array.isArray(value)) { + this.addCollection( + name, + new Collection({ + items: value, + identifierName: this.identifierName, + getNewId: this.getNewId, + }), + ); + } else { + this.addSingle(name, new Single(value)); + } + } + } + + toggleLogging() { + this.loggingEnabled = !this.loggingEnabled; + } + + /** + * @param Function ResourceName => object + */ + setDefaultQuery(query: QueryFunction) { + this.defaultQuery = query; + } + + setBatchUrl(batchUrl: string) { + this.batchUrl = batchUrl; + } + + /** + * @deprecated use setBatchUrl instead + */ + setBatch(url: string) { + console.warn( + 'Server.setBatch() is deprecated, use Server.setBatchUrl() instead', + ); + this.batchUrl = url; + } + + addCollection( + name: string, + collection: Collection, + ) { + this.collections[name] = collection; + collection.setServer(this); + collection.setName(name); + } + + getCollection(name: string) { + return this.collections[name]; + } + + getCollectionNames() { + return Object.keys(this.collections); + } + + addSingle( + name: string, + single: Single, + ) { + this.singles[name] = single; + single.setServer(this); + single.setName(name); + } + + getSingle(name: string) { + return this.singles[name]; + } + + getSingleNames() { + return Object.keys(this.singles); + } + + /** + * @param {string} name + * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } + */ + getCount(name: string, params?: Query) { + return this.collections[name].getCount(params); + } + + /** + * @param {string} name + * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } + */ + getAll(name: string, params?: Query) { + return this.collections[name].getAll(params); + } + + getOne(name: string, identifier: string | number, params?: Query) { + return this.collections[name].getOne(identifier, params); + } + + addOne(name: string, item: CollectionItem) { + if (!Object.prototype.hasOwnProperty.call(this.collections, name)) { + this.addCollection( + name, + new Collection({ + items: [], + identifierName: 'id', + getNewId: this.getNewId, + }), + ); + } + return this.collections[name].addOne(item); + } + + updateOne(name: string, identifier: string | number, item: CollectionItem) { + return this.collections[name].updateOne(identifier, item); + } + + removeOne(name: string, identifier: string | number) { + return this.collections[name].removeOne(identifier); + } + + getOnly(name: string, params?: Query) { + return this.singles[name].getOnly(); + } + + updateOnly(name: string, item: CollectionItem) { + return this.singles[name].updateOnly(item); + } + + decodeRequest(request: BaseRequest): BaseRequest { + for (const name of this.getSingleNames()) { + const matches = request.url?.match( + new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), + ); + + if (matches) { + request.single = name; + return request; + } + } + + const matches = request.url?.match( + new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), + ); + + if (matches) { + const name = matches[1]; + const params = Object.assign( + {}, + this.defaultQuery(name), + request.params, + ); + + request.collection = name; + request.params = params; + return request; + } + + return request; + } + + addBaseContext(context: FakeRestContext): FakeRestContext { + for (const name of this.getSingleNames()) { + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), + ); + if (!matches) continue; + return { + ...context, + single: name, + }; + } + + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), + ); + if (matches) { + const name = matches[1]; + const params = Object.assign( + {}, + this.defaultQuery(name), + context.params, + ); + + return { + ...context, + collection: name, + params, + }; + } + + return context; + } +} + +export type BaseServerOptions = { + baseUrl?: string; + batchUrl?: string | null; + data?: Record; + defaultQuery?: QueryFunction; + identifierName?: string; + getNewId?: () => number | string; + loggingEnabled?: boolean; +}; + +export type BaseRequest = { + url?: string; + method?: string; + collection?: string; + single?: string; + requestJson?: Record | undefined; + params?: { [key: string]: any }; +}; + +export type BaseResponse = { + status: number; + body?: Record | Record[]; + headers: { [key: string]: string }; +}; + +export type FakeRestContext = { + url?: string; + method?: string; + collection?: string; + single?: string; + requestJson: Record | undefined; + params: { [key: string]: any }; +}; diff --git a/src/Single.ts b/src/Single.ts index 861e9e8..54a4a75 100644 --- a/src/Single.ts +++ b/src/Single.ts @@ -1,9 +1,9 @@ -import type { BaseServer } from './BaseServer.js'; +import type { InternalServer } from './InternalServer.js'; import type { CollectionItem, Embed, Query } from './types.js'; export class Single { obj: T | null = null; - server: BaseServer | null = null; + server: InternalServer | null = null; name: string | null = null; constructor(obj: T) { @@ -19,7 +19,7 @@ export class Single { * A Single may need to access other collections (e.g. for embedded * references) This is done through a reference to the parent server. */ - setServer(server: BaseServer) { + setServer(server: InternalServer) { this.server = server; } diff --git a/src/SinonServer.spec.ts b/src/SinonServer.spec.ts index ba7bb77..34c6541 100644 --- a/src/SinonServer.spec.ts +++ b/src/SinonServer.spec.ts @@ -1,6 +1,6 @@ import sinon, { type SinonFakeXMLHttpRequest } from 'sinon'; -import { type SinonFakeRestRequest, Server } from './SinonServer.js'; +import { Server } from './SinonServer.js'; import { Single } from './Single.js'; import { Collection } from './Collection.js'; @@ -168,21 +168,21 @@ describe('Server', () => { }); describe('addRequestInterceptor', () => { - it('should allow request transformation', () => { + it('should allow request transformation', async () => { const server = new Server(); - server.addRequestInterceptor((request) => { - const start = request.params?._start - ? request.params._start - 1 + server.addMiddleware((request, context, next) => { + const start = context.params?._start + ? context.params._start - 1 : 0; const end = - request.params?._end !== undefined - ? request.params._end - 1 + context.params?._end !== undefined + ? context.params._end - 1 : 19; - if (!request.params) { - request.params = {}; + if (!context.params) { + context.params = {}; } - request.params.range = [start, end]; - return request; + context.params.range = [start, end]; + return next(request, context); }); server.addCollection( 'foo', @@ -196,21 +196,19 @@ describe('Server', () => { let request: SinonFakeXMLHttpRequest | null; request = getFakeXMLHTTPRequest('GET', '/foo?_start=1&_end=1'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request?.status).toEqual(206); - expect((request as SinonFakeRestRequest)?.responseText).toEqual( - '[{"id":1,"name":"foo"}]', - ); + // @ts-ignore + expect(request.responseText).toEqual('[{"id":1,"name":"foo"}]'); expect(request?.getResponseHeader('Content-Range')).toEqual( 'items 0-0/2', ); request = getFakeXMLHTTPRequest('GET', '/foo?_start=2&_end=2'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request?.status).toEqual(206); - expect((request as SinonFakeRestRequest)?.responseText).toEqual( - '[{"id":2,"name":"bar"}]', - ); + // @ts-ignore + expect(request?.responseText).toEqual('[{"id":2,"name":"bar"}]'); expect(request?.getResponseHeader('Content-Range')).toEqual( 'items 1-1/2', ); @@ -218,19 +216,21 @@ describe('Server', () => { }); describe('addResponseInterceptor', () => { - it('should allow response transformation', () => { + it('should allow response transformation', async () => { const server = new Server(); - server.addResponseInterceptor((response) => { + server.addMiddleware(async (request, context, next) => { + const response = await next(request, context); + response.status = 418; + return response; + }); + server.addMiddleware(async (request, context, next) => { + const response = await next(request, context); response.body = { data: response.body, status: response.status, }; return response; }); - server.addResponseInterceptor((response) => { - response.status = 418; - return response; - }); server.addCollection( 'foo', new Collection({ @@ -242,40 +242,41 @@ describe('Server', () => { ); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(418); - expect((request as SinonFakeRestRequest).responseText).toEqual( + // @ts-ignore + expect(request.responseText).toEqual( '{"data":[{"id":1,"name":"foo"},{"id":2,"name":"bar"}],"status":200}', ); }); - it('should pass request in response interceptor', () => { + it('should pass request in response interceptor', async () => { const server = new Server(); let requestUrl: string | undefined; - server.addResponseInterceptor((response, request) => { + server.addMiddleware((request, context, next) => { requestUrl = request.url; - return response; + return next(request, context); }); server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(requestUrl).toEqual('/foo'); }); }); describe('handle', () => { - it('should respond a 404 to GET /whatever on non existing collection', () => { + it('should respond a 404 to GET /whatever on non existing collection', async () => { const server = new Server(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(404); // not responded }); - it('should respond to GET /foo by sending all items in collection foo', () => { + it('should respond to GET /foo by sending all items in collection foo', async () => { const server = new Server(); server.addCollection( 'foo', @@ -288,9 +289,10 @@ describe('Server', () => { ); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( + // @ts-ignore + expect(request.responseText).toEqual( '[{"id":1,"name":"foo"},{"id":2,"name":"bar"}]', ); expect(request.getResponseHeader('Content-Type')).toEqual( @@ -301,7 +303,7 @@ describe('Server', () => { ); }); - it('should respond to GET /foo?queryString by sending all items in collection foo satisfying query', () => { + it('should respond to GET /foo?queryString by sending all items in collection foo satisfying query', async () => { const server = new Server(); server.addCollection( 'foos', @@ -322,9 +324,10 @@ describe('Server', () => { '/foos?filter={"arg":true}&sort=name&slice=[0,10]&embed=["bars"]', ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( + // @ts-ignore + expect(request.responseText).toEqual( '[{"id":2,"name":"a","arg":true,"bars":[]},{"id":1,"name":"b","arg":true,"bars":[{"id":0,"name":"a","foo_id":1}]}]', ); expect(request.getResponseHeader('Content-Type')).toEqual( @@ -335,7 +338,7 @@ describe('Server', () => { ); }); - it('should respond to GET /foo?queryString with pagination by sending the correct content-range header', () => { + it('should respond to GET /foo?queryString with pagination by sending the correct content-range header', async () => { const server = new Server(); server.addCollection( 'foo', @@ -346,50 +349,49 @@ describe('Server', () => { let request: SinonFakeXMLHttpRequest | null; request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-10/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-4/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[5,9]'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 5-9/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[10,14]'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 10-10/11', ); }); - it('should respond to GET /foo on an empty collection with a []', () => { + it('should respond to GET /foo on an empty collection with a []', async () => { const server = new Server(); server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '[]', - ); + // @ts-ignore + expect(request.responseText).toEqual('[]'); expect(request.getResponseHeader('Content-Range')).toEqual( 'items */0', ); }); - it('should respond to POST /foo by adding an item to collection foo', () => { + it('should respond to POST /foo by adding an item to collection foo', async () => { const server = new Server(); server.addCollection( 'foo', @@ -406,11 +408,10 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(201); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"name":"baz","id":3}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"name":"baz","id":3}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); @@ -422,7 +423,7 @@ describe('Server', () => { ]); }); - it('should respond to POST /foo by adding an item to collection foo, even if the collection does not exist', () => { + it('should respond to POST /foo by adding an item to collection foo, even if the collection does not exist', async () => { const server = new Server(); const request = getFakeXMLHTTPRequest( 'POST', @@ -430,11 +431,10 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(201); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"name":"baz","id":0}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"name":"baz","id":0}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); @@ -442,7 +442,7 @@ describe('Server', () => { expect(server.getAll('foo')).toEqual([{ id: 0, name: 'baz' }]); }); - it('should respond to GET /foo/:id by sending element of identifier id in collection foo', () => { + it('should respond to GET /foo/:id by sending element of identifier id in collection foo', async () => { const server = new Server(); server.addCollection( 'foo', @@ -455,26 +455,25 @@ describe('Server', () => { ); const request = getFakeXMLHTTPRequest('GET', '/foo/2'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"id":2,"name":"bar"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"id":2,"name":"bar"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); }); - it('should respond to GET /foo/:id on a non-existing id with a 404', () => { + it('should respond to GET /foo/:id on a non-existing id with a 404', async () => { const server = new Server(); server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo/3'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(404); }); - it('should respond to PUT /foo/:id by updating element of identifier id in collection foo', () => { + it('should respond to PUT /foo/:id by updating element of identifier id in collection foo', async () => { const server = new Server(); server.addCollection( 'foo', @@ -491,11 +490,10 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"id":2,"name":"baz"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"id":2,"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); @@ -505,7 +503,7 @@ describe('Server', () => { ]); }); - it('should respond to PUT /foo/:id on a non-existing id with a 404', () => { + it('should respond to PUT /foo/:id on a non-existing id with a 404', async () => { const server = new Server(); server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest( @@ -514,11 +512,11 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(404); }); - it('should respond to PATCH /foo/:id by updating element of identifier id in collection foo', () => { + it('should respond to PATCH /foo/:id by updating element of identifier id in collection foo', async () => { const server = new Server(); server.addCollection( 'foo', @@ -535,11 +533,10 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"id":2,"name":"baz"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"id":2,"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); @@ -549,7 +546,7 @@ describe('Server', () => { ]); }); - it('should respond to PATCH /foo/:id on a non-existing id with a 404', () => { + it('should respond to PATCH /foo/:id on a non-existing id with a 404', async () => { const server = new Server(); server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest( @@ -558,11 +555,11 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(404); }); - it('should respond to DELETE /foo/:id by removing element of identifier id in collection foo', () => { + it('should respond to DELETE /foo/:id by removing element of identifier id in collection foo', async () => { const server = new Server(); server.addCollection( 'foo', @@ -575,43 +572,41 @@ describe('Server', () => { ); const request = getFakeXMLHTTPRequest('DELETE', '/foo/2'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"id":2,"name":"bar"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"id":2,"name":"bar"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); expect(server.getAll('foo')).toEqual([{ id: 1, name: 'foo' }]); }); - it('should respond to DELETE /foo/:id on a non-existing id with a 404', () => { + it('should respond to DELETE /foo/:id on a non-existing id with a 404', async () => { const server = new Server(); server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest('DELETE', '/foo/3'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(404); }); - it('should respond to GET /foo/ with single item', () => { + it('should respond to GET /foo/ with single item', async () => { const server = new Server(); server.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"name":"foo"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"name":"foo"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); }); - it('should respond to PUT /foo/ by updating the singleton record', () => { + it('should respond to PUT /foo/ by updating the singleton record', async () => { const server = new Server(); server.addSingle('foo', new Single({ name: 'foo' })); @@ -621,18 +616,17 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"name":"baz"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); expect(server.getOnly('foo')).toEqual({ name: 'baz' }); }); - it('should respond to PATCH /foo/ by updating the singleton record', () => { + it('should respond to PATCH /foo/ by updating the singleton record', async () => { const server = new Server(); server.addSingle('foo', new Single({ name: 'foo' })); @@ -642,11 +636,10 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"name":"baz"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); @@ -655,7 +648,7 @@ describe('Server', () => { }); describe('setDefaultQuery', () => { - it('should set the default query string', () => { + it('should set the default query string', async () => { const server = new Server(); server.addCollection( 'foo', @@ -668,18 +661,17 @@ describe('Server', () => { }); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 2-4/10', ); const expected = [{ id: 2 }, { id: 3 }, { id: 4 }]; - expect((request as SinonFakeRestRequest).responseText).toEqual( - JSON.stringify(expected), - ); + // @ts-ignore + expect(request.responseText).toEqual(JSON.stringify(expected)); }); - it('should not override any provided query string', () => { + it('should not override any provided query string', async () => { const server = new Server(); server.addCollection( 'foo', @@ -690,7 +682,7 @@ describe('Server', () => { server.setDefaultQuery((name) => ({ range: [2, 4] })); const request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-4/10', @@ -702,72 +694,8 @@ describe('Server', () => { { id: 3 }, { id: 4 }, ]; - expect((request as SinonFakeRestRequest).responseText).toEqual( - JSON.stringify(expected), - ); - }); - }); - - describe('batch', () => { - it('should return batch response', () => { - const server = new Server(); - server.init({ - foo: [{ a: 1 }, { a: 2 }, { a: 3 }], - bar: [{ b: true }, { b: false }], - biz: { name: 'biz' }, - }); - server.setBatchUrl('/batch'); - const request = getFakeXMLHTTPRequest( - 'POST', - '/batch', - JSON.stringify({ - foo0: '/foo/0', - allbar: '/bar', - baz0: '/baz/0', - biz: '/biz', - }), - ); - if (request == null) throw new Error('request is null'); - server.handle(request); - expect((request as SinonFakeRestRequest).responseText).toEqual( - JSON.stringify({ - foo0: { - code: 200, - headers: [ - { - name: 'Content-Type', - value: 'application/json', - }, - ], - body: '{"a":1,"id":0}', - }, - allbar: { - code: 200, - headers: [ - { name: 'Content-Type', value: 'application/json' }, - { name: 'Content-Range', value: 'items 0-1/2' }, - ], - body: '[{"b":true,"id":0},{"b":false,"id":1}]', - }, - baz0: { - code: 404, - headers: [ - { name: 'Content-Type', value: 'application/json' }, - ], - body: {}, - }, - biz: { - code: 200, - headers: [ - { - name: 'Content-Type', - value: 'application/json', - }, - ], - body: '{"name":"biz"}', - }, - }), - ); + // @ts-ignore + expect(request.responseText).toEqual(JSON.stringify(expected)); }); }); }); diff --git a/src/SinonServer.ts b/src/SinonServer.ts index 64ec399..59cf9ac 100644 --- a/src/SinonServer.ts +++ b/src/SinonServer.ts @@ -1,88 +1,82 @@ import type { SinonFakeXMLHttpRequest } from 'sinon'; -import { BaseServer, type BaseServerOptions } from './BaseServer.js'; +import { BaseServer } from './BaseServer.js'; import { parseQueryString } from './parseQueryString.js'; +import type { BaseResponse, BaseServerOptions } from './InternalServer.js'; -export class SinonServer extends BaseServer { - requestInterceptors: SinonRequestInterceptor[] = []; - responseInterceptors: SinonResponseInterceptor[] = []; +export class SinonServer extends BaseServer< + SinonFakeXMLHttpRequest, + SinonFakeRestResponse +> { + async extractContext(request: SinonFakeXMLHttpRequest) { + const req: Request | SinonFakeXMLHttpRequest = + typeof request === 'string' ? new Request(request) : request; - addRequestInterceptor(interceptor: SinonRequestInterceptor) { - this.requestInterceptors.push(interceptor); - } - - addResponseInterceptor(interceptor: SinonResponseInterceptor) { - this.responseInterceptors.push(interceptor); - } - - decode( - request: string | Request | SinonFakeRestRequest, - opts?: RequestInit, - ): SinonFakeRestRequest | Promise { - const req: SinonFakeRestRequest = - typeof request === 'string' ? new Request(request, opts) : request; - req.queryString = req.url + const queryString = req.url ? decodeURIComponent(req.url.slice(req.url.indexOf('?') + 1)) : ''; - req.params = parseQueryString(req.queryString); - if (req.requestBody) { + const params = parseQueryString(queryString); + let requestJson: Record | undefined = undefined; + if ((req as SinonFakeXMLHttpRequest).requestBody) { try { - req.requestJson = JSON.parse(req.requestBody); + requestJson = JSON.parse( + (req as SinonFakeXMLHttpRequest).requestBody, + ); } catch (error) { // body isn't JSON, skipping } } - return this.requestInterceptors.reduce( - (previous, current) => current(previous), - req, - ); + + return { + url: req.url, + params, + requestJson, + method: req.method, + }; } - respond( - body: any, - headers: Record | null, - request: SinonFakeRestRequest, - status = 200, - ) { - let resp: SinonFakeRestResponse = { - status, - headers: headers || {}, - body, + async respond(response: BaseResponse, request: SinonFakeXMLHttpRequest) { + const sinonResponse = { + status: response.status, + body: response.body ?? '', + headers: response.headers ?? {}, }; - if (resp.headers == null) { - resp.headers = {}; - } - if (Array.isArray(resp.headers)) { + + if (Array.isArray(sinonResponse.headers)) { if ( - !(resp.headers as Array<{ name: string; value: string }>).find( - (header) => header.name.toLowerCase() === 'content-type', - ) + !( + sinonResponse.headers as Array<{ + name: string; + value: string; + }> + ).find((header) => header.name.toLowerCase() === 'content-type') ) { - resp.headers.push({ + sinonResponse.headers.push({ name: 'Content-Type', value: 'application/json', }); } - } else if (!(resp.headers as Record)['Content-Type']) { - resp.headers['Content-Type'] = 'application/json'; + } else if ( + !(sinonResponse.headers as Record)['Content-Type'] + ) { + sinonResponse.headers['Content-Type'] = 'application/json'; } - resp = this.responseInterceptors.reduce( - (previous, current) => current(previous, request), - resp, + request.respond( + sinonResponse.status, + sinonResponse.headers, + JSON.stringify(sinonResponse.body), ); - this.log(request, resp); - if (request.respond == null) { - throw new Error('request.respond is null'); - } - return request.respond( - resp.status as number, - resp.headers, - JSON.stringify(resp.body), - ); + this.log(request, sinonResponse); + + return { + status: response.status, + body: response.body, + headers: response.headers, + }; } - log(request: SinonFakeRestRequest, response: SinonFakeRestResponse) { + log(request: SinonFakeXMLHttpRequest, response: SinonFakeRestResponse) { if (!this.loggingEnabled) return; if (console.group) { // Better logging in Chrome @@ -118,93 +112,10 @@ export class SinonServer extends BaseServer { } } - batch(request: SinonFakeRestRequest) { - const json = request.requestJson; - const handle = this.handle.bind(this); - - if (json == null) { - throw new Error('json is null'); - } - - const jsonResponse = Object.keys(json).reduce( - (jsonResponse, requestName) => { - let subResponse: Record | undefined = - undefined; - const sub: SinonFakeRestRequest = { - url: json[requestName], - method: 'GET', - params: {}, - respond: (code, headers, body) => { - subResponse = { - code: code, - headers: Object.keys(headers || {}).map( - (headerName) => ({ - name: headerName, - value: headers[headerName], - }), - ), - body: body || {}, - }; - }, - }; - handle(sub); - - jsonResponse[requestName] = subResponse || { - code: 404, - headers: [], - body: {}, - }; - - return jsonResponse; - }, - {} as Record>, - ); - - return this.respond(jsonResponse, null, request); - } - - /** - * @param {FakeXMLHttpRequest} request - * - * String request.url The URL set on the request object. - * String request.method The request method as a string. - * Object request.requestHeaders An object of all request headers, i.e.: - * { - * "Accept": "text/html", - * "Connection": "keep-alive" - * } - * String request.requestBody The request body - * String request.username Username, if any. - * String request.password Password, if any. - */ - handle(request: Request | SinonFakeRestRequest | string) { - const req = this.decode(request) as SinonFakeRestRequest; - - if ( - this.batchUrl && - this.batchUrl === req.url && - req.method === 'POST' - ) { - return this.batch(req); - } - - const response = this.handleRequest({ - url: req.url, - method: req.method, - requestJson: req.requestJson, - params: req.params, - }); - - return this.respond( - response.body, - response.headers, - req, - response.status, - ); - } - getHandler() { - return this.handle.bind(this); + return (request: SinonFakeXMLHttpRequest) => { + return this.handle(request); + }; } } @@ -218,25 +129,8 @@ export const getSinonHandler = (options: BaseServerOptions) => { */ export const Server = SinonServer; -export type SinonFakeRestRequest = Partial & { - requestBody?: string; - responseText?: string; - requestJson?: Record; - queryString?: string; - params?: { [key: string]: any }; -}; - export type SinonFakeRestResponse = { status: number; body: any; headers: Record; }; - -export type SinonRequestInterceptor = ( - request: SinonFakeRestRequest, -) => SinonFakeRestRequest; - -export type SinonResponseInterceptor = ( - response: SinonFakeRestResponse, - request: SinonFakeRestRequest, -) => SinonFakeRestResponse; diff --git a/src/msw.ts b/src/msw.ts index 7ab9b09..cf085d9 100644 --- a/src/msw.ts +++ b/src/msw.ts @@ -1,7 +1,46 @@ import { http, HttpResponse } from 'msw'; -import { BaseServer, type BaseServerOptions } from './BaseServer.js'; +import { BaseServer } from './BaseServer.js'; +import type { + BaseRequest, + BaseResponse, + BaseServerOptions, +} from './InternalServer.js'; + +export class MswServer extends BaseServer { + async respond(response: BaseResponse) { + return HttpResponse.json(response.body, { + status: response.status, + headers: response.headers, + }); + } + + async extractContext(request: Request) { + const url = new URL(request.url); + const params = Object.fromEntries( + Array.from(new URLSearchParams(url.search).entries()).map( + ([key, value]) => [key, JSON.parse(value)], + ), + ); + let requestJson: Record | undefined = undefined; + try { + const text = await request.text(); + requestJson = JSON.parse(text); + } catch (e) { + // not JSON, no big deal + } + + const req: MswFakeRestRequest = request; + req.requestJson = requestJson; + req.params = params; + + return { + url: request.url, + params, + requestJson, + method: request.method, + }; + } -export class MswServer extends BaseServer { getHandlers() { return Object.keys(this.collections).map((collectionName) => getCollectionHandlers({ @@ -25,36 +64,22 @@ const getCollectionHandlers = ({ }: { baseUrl: string; collectionName: string; - server: BaseServer; + server: MswServer; }) => { return http.all( // Using a regex ensures we match all URLs that start with the collection name new RegExp(`${baseUrl}/${collectionName}`), - async ({ request }) => { - const url = new URL(request.url); - const params = Object.fromEntries( - Array.from(new URLSearchParams(url.search).entries()).map( - ([key, value]) => [key, JSON.parse(value)], - ), - ); - let requestJson: Record | undefined = undefined; - try { - const text = await request.text(); - requestJson = JSON.parse(text); - } catch (e) { - // not JSON, no big deal - } - const response = server.handleRequest({ - url: request.url.split('?')[0], - method: request.method, - requestJson, - params, - }); - - return HttpResponse.json(response.body, { - status: response.status, - headers: response.headers, - }); - }, + ({ request }) => server.handle(request), ); }; + +export type MswFakeRestRequest = Partial & BaseRequest; + +export type MswRequestInterceptor = ( + request: MswFakeRestRequest, +) => MswFakeRestRequest; + +export type MswResponseInterceptor = ( + response: HttpResponse, + request: Request, +) => HttpResponse; From c0c6b61e47f72c24beb8ba0e3bb6ad9827564dbe Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 3 Jun 2024 22:11:57 +0200 Subject: [PATCH 02/33] Fix Sinon --- public/sinon.html | 137 +++++++++++++++++++++++---------------------- src/SinonServer.ts | 8 ++- 2 files changed, 77 insertions(+), 68 deletions(-) diff --git a/public/sinon.html b/public/sinon.html index 890af55..cd1febb 100644 --- a/public/sinon.html +++ b/public/sinon.html @@ -1,13 +1,13 @@ - - Test FakeRest server - - -

FakeRest example

-

See source for example FakeRest usage.

-

Test data

-
+  
+    Test FakeRest server
+  
+  
+    

FakeRest example

+

See source for example FakeRest usage.

+

Test data

+
 {
     'authors': [
         { id: 0, first_name: 'Leo', last_name: 'Tolstoi' },
@@ -20,66 +20,69 @@ 

Test data

{ id: 3, author_id: 1, title: 'Sense and Sensibility' } ] } -
-
-

GET /authors

- -
-
-

GET /books/3

- -
-
-

POST /books { author_id: 1, title: 'Emma' }

- -
- - - + + - - \ No newline at end of file + // restore native XHR constructor + server.restore(); + + + diff --git a/src/SinonServer.ts b/src/SinonServer.ts index 59cf9ac..27a895b 100644 --- a/src/SinonServer.ts +++ b/src/SinonServer.ts @@ -61,6 +61,12 @@ export class SinonServer extends BaseServer< sinonResponse.headers['Content-Type'] = 'application/json'; } + // This is an internal property of SinonFakeXMLHttpRequest but we have to reset it to 1 + // to allow the request to be resolved by Sinon. + // See https://github.com/sinonjs/sinon/issues/637 + // @ts-expect-error + request.readyState = 1; + request.respond( sinonResponse.status, sinonResponse.headers, @@ -114,7 +120,7 @@ export class SinonServer extends BaseServer< getHandler() { return (request: SinonFakeXMLHttpRequest) => { - return this.handle(request); + this.handle(request); }; } } From af6b6e718b5929d2ff51c15af5ca4a5c10ffbbf2 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 3 Jun 2024 22:14:29 +0200 Subject: [PATCH 03/33] Rename BaseServer classes --- src/BaseServer.ts | 474 +++++++++++++------------------ src/BaseServerWithMiddlewares.ts | 347 ++++++++++++++++++++++ src/Collection.ts | 6 +- src/FetchMockServer.ts | 9 +- src/InternalServer.ts | 264 ----------------- src/Single.ts | 6 +- src/SinonServer.ts | 6 +- src/msw.ts | 6 +- 8 files changed, 560 insertions(+), 558 deletions(-) create mode 100644 src/BaseServerWithMiddlewares.ts delete mode 100644 src/InternalServer.ts diff --git a/src/BaseServer.ts b/src/BaseServer.ts index 6b6c033..f9dc87c 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -1,15 +1,167 @@ -import { - type BaseRequest, - type BaseResponse, - type FakeRestContext, - InternalServer, -} from './InternalServer.js'; - -export abstract class BaseServer< - RequestType, - ResponseType, -> extends InternalServer { - middlewares: Array> = []; +import { Collection } from './Collection.js'; +import { Single } from './Single.js'; +import type { CollectionItem, Query, QueryFunction } from './types.js'; + +export abstract class BaseServer { + baseUrl = ''; + identifierName = 'id'; + loggingEnabled = false; + defaultQuery: QueryFunction = () => ({}); + batchUrl: string | null = null; + collections: Record> = {}; + singles: Record> = {}; + getNewId?: () => number | string; + + constructor({ + baseUrl = '', + batchUrl = null, + data, + defaultQuery = () => ({}), + identifierName = 'id', + getNewId, + loggingEnabled = false, + }: BaseServerOptions = {}) { + this.baseUrl = baseUrl; + this.batchUrl = batchUrl; + this.getNewId = getNewId; + this.loggingEnabled = loggingEnabled; + this.identifierName = identifierName; + this.defaultQuery = defaultQuery; + + if (data) { + this.init(data); + } + } + + /** + * Shortcut for adding several collections if identifierName is always the same + */ + init(data: Record) { + for (const name in data) { + const value = data[name]; + if (Array.isArray(value)) { + this.addCollection( + name, + new Collection({ + items: value, + identifierName: this.identifierName, + getNewId: this.getNewId, + }), + ); + } else { + this.addSingle(name, new Single(value)); + } + } + } + + toggleLogging() { + this.loggingEnabled = !this.loggingEnabled; + } + + /** + * @param Function ResourceName => object + */ + setDefaultQuery(query: QueryFunction) { + this.defaultQuery = query; + } + + setBatchUrl(batchUrl: string) { + this.batchUrl = batchUrl; + } + + /** + * @deprecated use setBatchUrl instead + */ + setBatch(url: string) { + console.warn( + 'Server.setBatch() is deprecated, use Server.setBatchUrl() instead', + ); + this.batchUrl = url; + } + + addCollection( + name: string, + collection: Collection, + ) { + this.collections[name] = collection; + collection.setServer(this); + collection.setName(name); + } + + getCollection(name: string) { + return this.collections[name]; + } + + getCollectionNames() { + return Object.keys(this.collections); + } + + addSingle( + name: string, + single: Single, + ) { + this.singles[name] = single; + single.setServer(this); + single.setName(name); + } + + getSingle(name: string) { + return this.singles[name]; + } + + getSingleNames() { + return Object.keys(this.singles); + } + + /** + * @param {string} name + * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } + */ + getCount(name: string, params?: Query) { + return this.collections[name].getCount(params); + } + + /** + * @param {string} name + * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } + */ + getAll(name: string, params?: Query) { + return this.collections[name].getAll(params); + } + + getOne(name: string, identifier: string | number, params?: Query) { + return this.collections[name].getOne(identifier, params); + } + + addOne(name: string, item: CollectionItem) { + if (!Object.prototype.hasOwnProperty.call(this.collections, name)) { + this.addCollection( + name, + new Collection({ + items: [], + identifierName: 'id', + getNewId: this.getNewId, + }), + ); + } + return this.collections[name].addOne(item); + } + + updateOne(name: string, identifier: string | number, item: CollectionItem) { + return this.collections[name].updateOne(identifier, item); + } + + removeOne(name: string, identifier: string | number) { + return this.collections[name].removeOne(identifier); + } + + getOnly(name: string, params?: Query) { + return this.singles[name].getOnly(); + } + + updateOnly(name: string, item: CollectionItem) { + return this.singles[name].updateOnly(item); + } decodeRequest(request: BaseRequest): BaseRequest { for (const name of this.getSingleNames()) { @@ -43,17 +195,6 @@ export abstract class BaseServer< return request; } - abstract extractContext( - request: RequestType, - ): Promise< - Pick - >; - abstract respond( - response: BaseResponse, - request: RequestType, - context: FakeRestContext, - ): Promise; - addBaseContext(context: FakeRestContext): FakeRestContext { for (const name of this.getSingleNames()) { const matches = context.url?.match( @@ -86,263 +227,38 @@ export abstract class BaseServer< return context; } +} - async handle(request: RequestType): Promise { - const context = this.addBaseContext(await this.extractContext(request)); - - // Call middlewares - let index = 0; - const middlewares = [...this.middlewares]; - - const next = (req: RequestType, ctx: FakeRestContext) => { - const middleware = middlewares[index++]; - if (middleware) { - return middleware(req, ctx, next); - } - - return this.handleRequest(req, ctx); - }; - - const response = await next(request, context); - // @ts-ignore - return this.respond(response, request, context); - } - - handleRequest(request: RequestType, ctx: FakeRestContext): BaseResponse { - // Handle Single Objects - for (const name of this.getSingleNames()) { - const matches = ctx.url?.match( - new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), - ); - if (!matches) continue; - - if (ctx.method === 'GET') { - try { - return { - status: 200, - body: this.getOnly(name), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - if (ctx.method === 'PUT') { - try { - if (ctx.requestJson == null) { - return { - status: 400, - headers: {}, - }; - } - return { - status: 200, - body: this.updateOnly(name, ctx.requestJson), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - if (ctx.method === 'PATCH') { - try { - if (ctx.requestJson == null) { - return { - status: 400, - headers: {}, - }; - } - return { - status: 200, - body: this.updateOnly(name, ctx.requestJson), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - } +export type BaseServerOptions = { + baseUrl?: string; + batchUrl?: string | null; + data?: Record; + defaultQuery?: QueryFunction; + identifierName?: string; + getNewId?: () => number | string; + loggingEnabled?: boolean; +}; - // handle collections - const matches = ctx.url?.match( - new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), - ); - if (!matches) { - return { status: 404, headers: {} }; - } - const name = matches[1]; - const params = Object.assign({}, this.defaultQuery(name), ctx.params); - if (!matches[2]) { - if (ctx.method === 'GET') { - if (!this.getCollection(name)) { - return { status: 404, headers: {} }; - } - const count = this.getCount( - name, - params.filter ? { filter: params.filter } : {}, - ); - if (count > 0) { - const items = this.getAll(name, params); - const first = params.range ? params.range[0] : 0; - const last = - params.range && params.range.length === 2 - ? Math.min( - items.length - 1 + first, - params.range[1], - ) - : items.length - 1; - - return { - status: items.length === count ? 200 : 206, - body: items, - headers: { - 'Content-Type': 'application/json', - 'Content-Range': `items ${first}-${last}/${count}`, - }, - }; - } - - return { - status: 200, - body: [], - headers: { - 'Content-Type': 'application/json', - 'Content-Range': 'items */0', - }, - }; - } - if (ctx.method === 'POST') { - if (ctx.requestJson == null) { - return { - status: 400, - headers: {}, - }; - } - - const newResource = this.addOne(name, ctx.requestJson); - const newResourceURI = `${this.baseUrl}/${name}/${ - newResource[this.getCollection(name).identifierName] - }`; - - return { - status: 201, - body: newResource, - headers: { - 'Content-Type': 'application/json', - Location: newResourceURI, - }, - }; - } - } else { - if (!this.getCollection(name)) { - return { status: 404, headers: {} }; - } - const id = Number.parseInt(matches[3]); - if (ctx.method === 'GET') { - try { - return { - status: 200, - body: this.getOne(name, id, params), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - if (ctx.method === 'PUT') { - try { - if (ctx.requestJson == null) { - return { - status: 400, - headers: {}, - }; - } - return { - status: 200, - body: this.updateOne(name, id, ctx.requestJson), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - if (ctx.method === 'PATCH') { - try { - if (ctx.requestJson == null) { - return { - status: 400, - headers: {}, - }; - } - return { - status: 200, - body: this.updateOne(name, id, ctx.requestJson), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - if (ctx.method === 'DELETE') { - try { - return { - status: 200, - body: this.removeOne(name, id), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - } - return { - status: 404, - headers: {}, - }; - } +export type BaseRequest = { + url?: string; + method?: string; + collection?: string; + single?: string; + requestJson?: Record | undefined; + params?: { [key: string]: any }; +}; - addMiddleware(middleware: Middleware) { - this.middlewares.push(middleware); - } -} +export type BaseResponse = { + status: number; + body?: Record | Record[]; + headers: { [key: string]: string }; +}; -export type Middleware = ( - request: RequestType, - context: FakeRestContext, - next: (req: RequestType, ctx: FakeRestContext) => Promise, -) => Promise; +export type FakeRestContext = { + url?: string; + method?: string; + collection?: string; + single?: string; + requestJson: Record | undefined; + params: { [key: string]: any }; +}; diff --git a/src/BaseServerWithMiddlewares.ts b/src/BaseServerWithMiddlewares.ts new file mode 100644 index 0000000..3ecfd55 --- /dev/null +++ b/src/BaseServerWithMiddlewares.ts @@ -0,0 +1,347 @@ +import { + type BaseRequest, + type BaseResponse, + type FakeRestContext, + BaseServer, +} from './BaseServer.js'; + +export abstract class BaseServerWithMiddlewares< + RequestType, + ResponseType, +> extends BaseServer { + middlewares: Array> = []; + + decodeRequest(request: BaseRequest): BaseRequest { + for (const name of this.getSingleNames()) { + const matches = request.url?.match( + new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), + ); + + if (matches) { + request.single = name; + return request; + } + } + + const matches = request.url?.match( + new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), + ); + + if (matches) { + const name = matches[1]; + const params = Object.assign( + {}, + this.defaultQuery(name), + request.params, + ); + + request.collection = name; + request.params = params; + return request; + } + + return request; + } + + abstract extractContext( + request: RequestType, + ): Promise< + Pick + >; + abstract respond( + response: BaseResponse, + request: RequestType, + context: FakeRestContext, + ): Promise; + + addBaseContext(context: FakeRestContext): FakeRestContext { + for (const name of this.getSingleNames()) { + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), + ); + if (!matches) continue; + return { + ...context, + single: name, + }; + } + + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), + ); + if (matches) { + const name = matches[1]; + const params = Object.assign( + {}, + this.defaultQuery(name), + context.params, + ); + + return { + ...context, + collection: name, + params, + }; + } + + return context; + } + + async handle(request: RequestType): Promise { + const context = this.addBaseContext(await this.extractContext(request)); + + // Call middlewares + let index = 0; + const middlewares = [...this.middlewares]; + + const next = (req: RequestType, ctx: FakeRestContext) => { + const middleware = middlewares[index++]; + if (middleware) { + return middleware(req, ctx, next); + } + + return this.handleRequest(req, ctx); + }; + + const response = await next(request, context); + return this.respond(response, request, context); + } + + handleRequest(request: RequestType, ctx: FakeRestContext): BaseResponse { + // Handle Single Objects + for (const name of this.getSingleNames()) { + const matches = ctx.url?.match( + new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), + ); + if (!matches) continue; + + if (ctx.method === 'GET') { + try { + return { + status: 200, + body: this.getOnly(name), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + if (ctx.method === 'PUT') { + try { + if (ctx.requestJson == null) { + return { + status: 400, + headers: {}, + }; + } + return { + status: 200, + body: this.updateOnly(name, ctx.requestJson), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + if (ctx.method === 'PATCH') { + try { + if (ctx.requestJson == null) { + return { + status: 400, + headers: {}, + }; + } + return { + status: 200, + body: this.updateOnly(name, ctx.requestJson), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + } + + // handle collections + const matches = ctx.url?.match( + new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), + ); + if (!matches) { + return { status: 404, headers: {} }; + } + const name = matches[1]; + const params = Object.assign({}, this.defaultQuery(name), ctx.params); + if (!matches[2]) { + if (ctx.method === 'GET') { + if (!this.getCollection(name)) { + return { status: 404, headers: {} }; + } + const count = this.getCount( + name, + params.filter ? { filter: params.filter } : {}, + ); + if (count > 0) { + const items = this.getAll(name, params); + const first = params.range ? params.range[0] : 0; + const last = + params.range && params.range.length === 2 + ? Math.min( + items.length - 1 + first, + params.range[1], + ) + : items.length - 1; + + return { + status: items.length === count ? 200 : 206, + body: items, + headers: { + 'Content-Type': 'application/json', + 'Content-Range': `items ${first}-${last}/${count}`, + }, + }; + } + + return { + status: 200, + body: [], + headers: { + 'Content-Type': 'application/json', + 'Content-Range': 'items */0', + }, + }; + } + if (ctx.method === 'POST') { + if (ctx.requestJson == null) { + return { + status: 400, + headers: {}, + }; + } + + const newResource = this.addOne(name, ctx.requestJson); + const newResourceURI = `${this.baseUrl}/${name}/${ + newResource[this.getCollection(name).identifierName] + }`; + + return { + status: 201, + body: newResource, + headers: { + 'Content-Type': 'application/json', + Location: newResourceURI, + }, + }; + } + } else { + if (!this.getCollection(name)) { + return { status: 404, headers: {} }; + } + const id = Number.parseInt(matches[3]); + if (ctx.method === 'GET') { + try { + return { + status: 200, + body: this.getOne(name, id, params), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + if (ctx.method === 'PUT') { + try { + if (ctx.requestJson == null) { + return { + status: 400, + headers: {}, + }; + } + return { + status: 200, + body: this.updateOne(name, id, ctx.requestJson), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + if (ctx.method === 'PATCH') { + try { + if (ctx.requestJson == null) { + return { + status: 400, + headers: {}, + }; + } + return { + status: 200, + body: this.updateOne(name, id, ctx.requestJson), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + if (ctx.method === 'DELETE') { + try { + return { + status: 200, + body: this.removeOne(name, id), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + } + return { + status: 404, + headers: {}, + }; + } + + addMiddleware(middleware: Middleware) { + this.middlewares.push(middleware); + } +} + +export type Middleware = ( + request: RequestType, + context: FakeRestContext, + next: (req: RequestType, ctx: FakeRestContext) => Promise, +) => Promise; diff --git a/src/Collection.ts b/src/Collection.ts index 18fbde7..9be8fac 100644 --- a/src/Collection.ts +++ b/src/Collection.ts @@ -1,6 +1,6 @@ import get from 'lodash/get.js'; import matches from 'lodash/matches.js'; -import type { InternalServer } from './InternalServer.js'; +import type { BaseServer } from './BaseServer.js'; import type { CollectionItem, Embed, @@ -14,7 +14,7 @@ import type { export class Collection { sequence = 0; items: T[] = []; - server: InternalServer | null = null; + server: BaseServer | null = null; name: string | null = null; identifierName = 'id'; getNewId: () => number | string; @@ -42,7 +42,7 @@ export class Collection { * A Collection may need to access other collections (e.g. for embedding references) * This is done through a reference to the parent server. */ - setServer(server: InternalServer) { + setServer(server: BaseServer) { this.server = server; } diff --git a/src/FetchMockServer.ts b/src/FetchMockServer.ts index aa5095e..3c5b16b 100644 --- a/src/FetchMockServer.ts +++ b/src/FetchMockServer.ts @@ -1,13 +1,16 @@ import type { MockResponseObject, MockMatcherFunction } from 'fetch-mock'; -import { BaseServer } from './BaseServer.js'; +import { BaseServerWithMiddlewares } from './BaseServerWithMiddlewares.js'; import type { BaseResponse, BaseServerOptions, FakeRestContext, -} from './InternalServer.js'; +} from './BaseServer.js'; import { parseQueryString } from './parseQueryString.js'; -export class FetchMockServer extends BaseServer { +export class FetchMockServer extends BaseServerWithMiddlewares< + Request, + MockResponseObject +> { async extractContext(request: Request) { const req = typeof request === 'string' ? new Request(request) : request; diff --git a/src/InternalServer.ts b/src/InternalServer.ts deleted file mode 100644 index 2964c6c..0000000 --- a/src/InternalServer.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { Collection } from './Collection.js'; -import { Single } from './Single.js'; -import type { CollectionItem, Query, QueryFunction } from './types.js'; - -export abstract class InternalServer { - baseUrl = ''; - identifierName = 'id'; - loggingEnabled = false; - defaultQuery: QueryFunction = () => ({}); - batchUrl: string | null = null; - collections: Record> = {}; - singles: Record> = {}; - getNewId?: () => number | string; - - constructor({ - baseUrl = '', - batchUrl = null, - data, - defaultQuery = () => ({}), - identifierName = 'id', - getNewId, - loggingEnabled = false, - }: BaseServerOptions = {}) { - this.baseUrl = baseUrl; - this.batchUrl = batchUrl; - this.getNewId = getNewId; - this.loggingEnabled = loggingEnabled; - this.identifierName = identifierName; - this.defaultQuery = defaultQuery; - - if (data) { - this.init(data); - } - } - - /** - * Shortcut for adding several collections if identifierName is always the same - */ - init(data: Record) { - for (const name in data) { - const value = data[name]; - if (Array.isArray(value)) { - this.addCollection( - name, - new Collection({ - items: value, - identifierName: this.identifierName, - getNewId: this.getNewId, - }), - ); - } else { - this.addSingle(name, new Single(value)); - } - } - } - - toggleLogging() { - this.loggingEnabled = !this.loggingEnabled; - } - - /** - * @param Function ResourceName => object - */ - setDefaultQuery(query: QueryFunction) { - this.defaultQuery = query; - } - - setBatchUrl(batchUrl: string) { - this.batchUrl = batchUrl; - } - - /** - * @deprecated use setBatchUrl instead - */ - setBatch(url: string) { - console.warn( - 'Server.setBatch() is deprecated, use Server.setBatchUrl() instead', - ); - this.batchUrl = url; - } - - addCollection( - name: string, - collection: Collection, - ) { - this.collections[name] = collection; - collection.setServer(this); - collection.setName(name); - } - - getCollection(name: string) { - return this.collections[name]; - } - - getCollectionNames() { - return Object.keys(this.collections); - } - - addSingle( - name: string, - single: Single, - ) { - this.singles[name] = single; - single.setServer(this); - single.setName(name); - } - - getSingle(name: string) { - return this.singles[name]; - } - - getSingleNames() { - return Object.keys(this.singles); - } - - /** - * @param {string} name - * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } - */ - getCount(name: string, params?: Query) { - return this.collections[name].getCount(params); - } - - /** - * @param {string} name - * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } - */ - getAll(name: string, params?: Query) { - return this.collections[name].getAll(params); - } - - getOne(name: string, identifier: string | number, params?: Query) { - return this.collections[name].getOne(identifier, params); - } - - addOne(name: string, item: CollectionItem) { - if (!Object.prototype.hasOwnProperty.call(this.collections, name)) { - this.addCollection( - name, - new Collection({ - items: [], - identifierName: 'id', - getNewId: this.getNewId, - }), - ); - } - return this.collections[name].addOne(item); - } - - updateOne(name: string, identifier: string | number, item: CollectionItem) { - return this.collections[name].updateOne(identifier, item); - } - - removeOne(name: string, identifier: string | number) { - return this.collections[name].removeOne(identifier); - } - - getOnly(name: string, params?: Query) { - return this.singles[name].getOnly(); - } - - updateOnly(name: string, item: CollectionItem) { - return this.singles[name].updateOnly(item); - } - - decodeRequest(request: BaseRequest): BaseRequest { - for (const name of this.getSingleNames()) { - const matches = request.url?.match( - new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), - ); - - if (matches) { - request.single = name; - return request; - } - } - - const matches = request.url?.match( - new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), - ); - - if (matches) { - const name = matches[1]; - const params = Object.assign( - {}, - this.defaultQuery(name), - request.params, - ); - - request.collection = name; - request.params = params; - return request; - } - - return request; - } - - addBaseContext(context: FakeRestContext): FakeRestContext { - for (const name of this.getSingleNames()) { - const matches = context.url?.match( - new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), - ); - if (!matches) continue; - return { - ...context, - single: name, - }; - } - - const matches = context.url?.match( - new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), - ); - if (matches) { - const name = matches[1]; - const params = Object.assign( - {}, - this.defaultQuery(name), - context.params, - ); - - return { - ...context, - collection: name, - params, - }; - } - - return context; - } -} - -export type BaseServerOptions = { - baseUrl?: string; - batchUrl?: string | null; - data?: Record; - defaultQuery?: QueryFunction; - identifierName?: string; - getNewId?: () => number | string; - loggingEnabled?: boolean; -}; - -export type BaseRequest = { - url?: string; - method?: string; - collection?: string; - single?: string; - requestJson?: Record | undefined; - params?: { [key: string]: any }; -}; - -export type BaseResponse = { - status: number; - body?: Record | Record[]; - headers: { [key: string]: string }; -}; - -export type FakeRestContext = { - url?: string; - method?: string; - collection?: string; - single?: string; - requestJson: Record | undefined; - params: { [key: string]: any }; -}; diff --git a/src/Single.ts b/src/Single.ts index 54a4a75..861e9e8 100644 --- a/src/Single.ts +++ b/src/Single.ts @@ -1,9 +1,9 @@ -import type { InternalServer } from './InternalServer.js'; +import type { BaseServer } from './BaseServer.js'; import type { CollectionItem, Embed, Query } from './types.js'; export class Single { obj: T | null = null; - server: InternalServer | null = null; + server: BaseServer | null = null; name: string | null = null; constructor(obj: T) { @@ -19,7 +19,7 @@ export class Single { * A Single may need to access other collections (e.g. for embedded * references) This is done through a reference to the parent server. */ - setServer(server: InternalServer) { + setServer(server: BaseServer) { this.server = server; } diff --git a/src/SinonServer.ts b/src/SinonServer.ts index 27a895b..4fd23ab 100644 --- a/src/SinonServer.ts +++ b/src/SinonServer.ts @@ -1,9 +1,9 @@ import type { SinonFakeXMLHttpRequest } from 'sinon'; -import { BaseServer } from './BaseServer.js'; +import { BaseServerWithMiddlewares } from './BaseServerWithMiddlewares.js'; import { parseQueryString } from './parseQueryString.js'; -import type { BaseResponse, BaseServerOptions } from './InternalServer.js'; +import type { BaseResponse, BaseServerOptions } from './BaseServer.js'; -export class SinonServer extends BaseServer< +export class SinonServer extends BaseServerWithMiddlewares< SinonFakeXMLHttpRequest, SinonFakeRestResponse > { diff --git a/src/msw.ts b/src/msw.ts index cf085d9..8e8804e 100644 --- a/src/msw.ts +++ b/src/msw.ts @@ -1,12 +1,12 @@ import { http, HttpResponse } from 'msw'; -import { BaseServer } from './BaseServer.js'; +import { BaseServerWithMiddlewares } from './BaseServerWithMiddlewares.js'; import type { BaseRequest, BaseResponse, BaseServerOptions, -} from './InternalServer.js'; +} from './BaseServer.js'; -export class MswServer extends BaseServer { +export class MswServer extends BaseServerWithMiddlewares { async respond(response: BaseResponse) { return HttpResponse.json(response.body, { status: response.status, From 9096436b912207d608cc814ece05a6a4d5e4863f Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 3 Jun 2024 22:17:35 +0200 Subject: [PATCH 04/33] Add comment --- src/BaseServer.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/BaseServer.ts b/src/BaseServer.ts index f9dc87c..8b26576 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -2,6 +2,12 @@ import { Collection } from './Collection.js'; import { Single } from './Single.js'; import type { CollectionItem, Query, QueryFunction } from './types.js'; +/** + * This base class does not need generics so we can reference it in Collection and Single + * without having to propagate mocking implementation generics nor requiring the user to specify them. + * The BaseServerWithMiddlewares class is the one that needs to have generic parameters which are + * provided by the mocking implementation server classes. + */ export abstract class BaseServer { baseUrl = ''; identifierName = 'id'; From b1a0a0038b9afed4dc693784c27f2f13ab821b48 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 4 Jun 2024 09:48:26 +0200 Subject: [PATCH 05/33] Cleanup types --- src/BaseServerWithMiddlewares.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/BaseServerWithMiddlewares.ts b/src/BaseServerWithMiddlewares.ts index 3ecfd55..a4773bb 100644 --- a/src/BaseServerWithMiddlewares.ts +++ b/src/BaseServerWithMiddlewares.ts @@ -9,7 +9,7 @@ export abstract class BaseServerWithMiddlewares< RequestType, ResponseType, > extends BaseServer { - middlewares: Array> = []; + middlewares: Array> = []; decodeRequest(request: BaseRequest): BaseRequest { for (const name of this.getSingleNames()) { @@ -103,8 +103,16 @@ export abstract class BaseServerWithMiddlewares< return this.handleRequest(req, ctx); }; - const response = await next(request, context); - return this.respond(response, request, context); + try { + const response = await next(request, context); + return this.respond(response, request, context); + } catch (error) { + if (error instanceof Error) { + throw error; + } + + return error as ResponseType; + } } handleRequest(request: RequestType, ctx: FakeRestContext): BaseResponse { @@ -335,12 +343,12 @@ export abstract class BaseServerWithMiddlewares< }; } - addMiddleware(middleware: Middleware) { + addMiddleware(middleware: Middleware) { this.middlewares.push(middleware); } } -export type Middleware = ( +export type Middleware = ( request: RequestType, context: FakeRestContext, next: (req: RequestType, ctx: FakeRestContext) => Promise, From 5570922df2f283688822c7d8732e8c238ff9c446 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 4 Jun 2024 09:48:38 +0200 Subject: [PATCH 06/33] Add withDelay middleware --- example/msw.ts | 7 ++++--- src/FakeRest.ts | 3 +++ src/withDelay.ts | 11 +++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 src/withDelay.ts diff --git a/example/msw.ts b/example/msw.ts index 1b75528..ce8d0b8 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -1,5 +1,5 @@ import { setupWorker } from 'msw/browser'; -import { MswServer } from '../src/FakeRest'; +import { MswServer, withDelay } from '../src/FakeRest'; import { data } from './data'; import { HttpResponse } from 'msw'; @@ -8,9 +8,10 @@ const restServer = new MswServer({ data, }); +restServer.addMiddleware(withDelay(5000)); restServer.addMiddleware(async (request, context, next) => { if (!request.headers?.get('Authorization')) { - return new HttpResponse(null, { status: 401 }); + throw new HttpResponse(null, { status: 401 }); } if ( @@ -18,7 +19,7 @@ restServer.addMiddleware(async (request, context, next) => { request.method === 'POST' && !context.requestJson?.title ) { - return new HttpResponse(null, { + throw new HttpResponse(null, { status: 400, statusText: 'Title is required', }); diff --git a/src/FakeRest.ts b/src/FakeRest.ts index 281e3c7..994ba6d 100644 --- a/src/FakeRest.ts +++ b/src/FakeRest.ts @@ -7,6 +7,7 @@ import { import { Collection } from './Collection.js'; import { Single } from './Single.js'; import { getMswHandlers, MswServer } from './msw.js'; +import { withDelay } from './withDelay.js'; export { getSinonHandler, @@ -19,6 +20,7 @@ export { MswServer, Collection, Single, + withDelay, }; export default { @@ -32,4 +34,5 @@ export default { MswServer, Collection, Single, + withDelay, }; diff --git a/src/withDelay.ts b/src/withDelay.ts new file mode 100644 index 0000000..d538825 --- /dev/null +++ b/src/withDelay.ts @@ -0,0 +1,11 @@ +import type { Middleware } from './BaseServerWithMiddlewares.js'; + +export const withDelay = + (delayMs: number): Middleware => + (request, context, next) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(next(request, context)); + }, delayMs); + }); + }; From 7e8e47e43da8eb280ca20d68e56c022bbc4f6450 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 4 Jun 2024 14:45:50 +0200 Subject: [PATCH 07/33] Fix Sinon integration --- Makefile | 3 + example/App.tsx | 4 +- example/authProvider.ts | 10 +- example/fetchMock.ts | 9 +- example/index.tsx | 34 +++-- example/msw.ts | 7 +- example/sinon.ts | 206 +++++++++++++++++++++++++++++++ example/vite-env.d.ts | 2 +- src/BaseServerWithMiddlewares.ts | 121 +++++++++++------- src/SinonServer.ts | 8 +- 10 files changed, 339 insertions(+), 65 deletions(-) create mode 100644 example/sinon.ts diff --git a/Makefile b/Makefile index 99d001f..a93f5bf 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,9 @@ run-msw: run-fetch-mock: @NODE_ENV=development VITE_MOCK=fetch-mock npm run dev +run-sinon: + @NODE_ENV=development VITE_MOCK=sinon npm run dev + watch: @NODE_ENV=development npm run build --watch diff --git a/example/App.tsx b/example/App.tsx index 0b3de01..e2999f1 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -2,13 +2,13 @@ import React from 'react'; import { Admin, Create, + type DataProvider, EditGuesser, ListGuesser, Resource, ShowGuesser, } from 'react-admin'; import { QueryClient } from 'react-query'; -import { dataProvider } from './dataProvider'; const queryClient = new QueryClient({ defaultOptions: { @@ -18,7 +18,7 @@ const queryClient = new QueryClient({ }, }); -export const App = () => { +export const App = ({ dataProvider }: { dataProvider: DataProvider }) => { return ( Promise.resolve(), + checkError: (error) => { + const status = error.status; + if (status === 401 || status === 403) { + localStorage.removeItem('auth'); + return Promise.reject(); + } + // other error code (404, 500, etc): no need to log out + return Promise.resolve(); + }, checkAuth: () => localStorage.getItem('user') ? Promise.resolve() : Promise.reject(), getPermissions: () => { diff --git a/example/fetchMock.ts b/example/fetchMock.ts index 19c1265..dae14ab 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -1,9 +1,10 @@ import fetchMock from 'fetch-mock'; -import FakeRest from 'fakerest'; +import { FetchServer, withDelay } from 'fakerest'; import { data } from './data'; +import { dataProvider as defaultDataProvider } from './dataProvider'; export const initializeFetchMock = () => { - const restServer = new FakeRest.FetchServer({ + const restServer = new FetchServer({ baseUrl: 'http://localhost:3000', data, loggingEnabled: true, @@ -12,6 +13,8 @@ export const initializeFetchMock = () => { // @ts-ignore window.restServer = restServer; // give way to update data in the console } + + restServer.addMiddleware(withDelay(300)); restServer.addMiddleware(async (request, context, next) => { if (!request.headers?.get('Authorization')) { return new Response(null, { status: 401 }); @@ -32,3 +35,5 @@ export const initializeFetchMock = () => { }); fetchMock.mock('begin:http://localhost:3000', restServer.getHandler()); }; + +export const dataProvider = defaultDataProvider; diff --git a/example/index.tsx b/example/index.tsx index c11ee63..342198c 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -5,19 +5,39 @@ import { App } from './App'; switch (import.meta.env.VITE_MOCK) { case 'fetch-mock': import('./fetchMock') - .then(({ initializeFetchMock }) => { + .then(({ initializeFetchMock, dataProvider }) => { initializeFetchMock(); + return dataProvider; }) - .then(() => { - ReactDom.render(, document.getElementById('root')); + .then((dataProvider) => { + ReactDom.render( + , + document.getElementById('root'), + ); + }); + break; + case 'sinon': + import('./sinon') + .then(({ initializeSinon, dataProvider }) => { + initializeSinon(); + return dataProvider; + }) + .then((dataProvider) => { + ReactDom.render( + , + document.getElementById('root'), + ); }); break; default: import('./msw') - .then(({ worker }) => { - return worker.start(); + .then(({ worker, dataProvider }) => { + return worker.start().then(() => dataProvider); }) - .then(() => { - ReactDom.render(, document.getElementById('root')); + .then((dataProvider) => { + ReactDom.render( + , + document.getElementById('root'), + ); }); } diff --git a/example/msw.ts b/example/msw.ts index ce8d0b8..4f1ffd2 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -1,14 +1,15 @@ import { setupWorker } from 'msw/browser'; +import { HttpResponse } from 'msw'; import { MswServer, withDelay } from '../src/FakeRest'; import { data } from './data'; -import { HttpResponse } from 'msw'; +import { dataProvider as defaultDataProvider } from './dataProvider'; const restServer = new MswServer({ baseUrl: 'http://localhost:3000', data, }); -restServer.addMiddleware(withDelay(5000)); +restServer.addMiddleware(withDelay(300)); restServer.addMiddleware(async (request, context, next) => { if (!request.headers?.get('Authorization')) { throw new HttpResponse(null, { status: 401 }); @@ -29,3 +30,5 @@ restServer.addMiddleware(async (request, context, next) => { }); export const worker = setupWorker(...restServer.getHandlers()); + +export const dataProvider = defaultDataProvider; diff --git a/example/sinon.ts b/example/sinon.ts new file mode 100644 index 0000000..e08f620 --- /dev/null +++ b/example/sinon.ts @@ -0,0 +1,206 @@ +import sinon from 'sinon'; +import { SinonServer } from '../src/FakeRest'; +import { data } from './data'; +import { type DataProvider, HttpError } from 'react-admin'; + +export const initializeSinon = () => { + const restServer = new SinonServer({ + baseUrl: 'http://localhost:3000', + data, + loggingEnabled: true, + }); + + restServer.addMiddleware((request, context, next) => { + if (request.requestHeaders.Authorization === undefined) { + request.respond(401, {}, 'Unauthorized'); + return null; + } + + return next(request, context); + }); + + // use sinon.js to monkey-patch XmlHttpRequest + const server = sinon.fakeServer.create(); + // this is required when doing asynchronous XmlHttpRequest + server.autoRespond = true; + if (window) { + // @ts-ignore + window.restServer = restServer; // give way to update data in the console + // @ts-ignore + window.sinonServer = server; // give way to update data in the console + } + server.respondWith(restServer.getHandler()); +}; + +export const dataProvider: DataProvider = { + async getList(resource, params) { + const { page, perPage } = params.pagination; + const { field, order } = params.sort; + + const rangeStart = (page - 1) * perPage; + const rangeEnd = page * perPage - 1; + const query = { + sort: JSON.stringify([field, order]), + range: JSON.stringify([rangeStart, rangeEnd]), + filter: JSON.stringify(params.filter), + }; + const json = await sendRequest( + `http://localhost:3000/${resource}?${new URLSearchParams(query)}`, + ); + return { + data: json.json, + total: Number.parseInt( + json.headers['Content-Range'].split('/').pop() ?? '0', + 10, + ), + }; + }, + async getMany(resource, params) { + const query = { + filter: JSON.stringify({ id: params.ids }), + }; + const json = await sendRequest( + `http://localhost:3000/${resource}?${new URLSearchParams(query)}`, + ); + return { + data: json.json, + }; + }, + async getManyReference(resource, params) { + const { page, perPage } = params.pagination; + const { field, order } = params.sort; + const rangeStart = (page - 1) * perPage; + const rangeEnd = page * perPage - 1; + const query = { + sort: JSON.stringify([field, order]), + range: JSON.stringify([rangeStart, rangeEnd]), + filter: JSON.stringify({ + ...params.filter, + [params.target]: params.id, + }), + }; + const json = await sendRequest( + `http://localhost:3000/${resource}?${new URLSearchParams(query)}`, + ); + return { + data: json.json, + total: Number.parseInt( + json.headers['Content-Range'].split('/').pop() ?? '0', + 10, + ), + }; + }, + async getOne(resource, params) { + const json = await sendRequest( + `http://localhost:3000/${resource}/${params.id}`, + ); + return { + data: json.json, + }; + }, + async create(resource, params) { + const json = await sendRequest( + `http://localhost:3000/${resource}`, + 'POST', + JSON.stringify(params.data), + ); + return { + data: json.json, + }; + }, + async update(resource, params) { + const json = await sendRequest( + `http://localhost:3000/${resource}/${params.id}`, + 'PUT', + JSON.stringify(params.data), + ); + return { + data: json.json, + }; + }, + async updateMany(resource, params) { + return Promise.all( + params.ids.map((id) => + this.update(resource, { id, data: params.data }), + ), + ).then((responses) => ({ data: responses.map(({ json }) => json.id) })); + }, + async delete(resource, params) { + const json = await sendRequest( + `http://localhost:3000/${resource}/${params.id}`, + 'DELETE', + null, + ); + return { + data: json.json, + }; + }, + async deleteMany(resource, params) { + return Promise.all( + params.ids.map((id) => this.delete(resource, { id })), + ).then((responses) => ({ + data: responses.map(({ data }) => data.id), + })); + }, +}; + +const sendRequest = ( + url: string, + method = 'GET', + body: any = null, +): Promise => { + const request = new XMLHttpRequest(); + request.open(method, url); + + const persistedUser = localStorage.getItem('user'); + const user = persistedUser ? JSON.parse(persistedUser) : null; + if (user) { + request.setRequestHeader('Authorization', `Bearer ${user.id}`); + } + + // add content-type header + request.overrideMimeType('application/json'); + request.send(body); + + return new Promise((resolve, reject) => { + request.onloadend = (e) => { + let json: any; + try { + json = JSON.parse(request.responseText); + } catch (e) { + // not json, no big deal + } + // Get the raw header string + const headers = request.getAllResponseHeaders(); + + // Convert the header string into an array + // of individual headers + const arr = headers.trim().split(/[\r\n]+/); + + // Create a map of header names to values + const headerMap: Record = {}; + for (const line of arr) { + const parts = line.split(': '); + const header = parts.shift(); + if (!header) continue; + const value = parts.join(': '); + headerMap[header] = value; + } + if (request.status < 200 || request.status >= 300) { + return reject( + new HttpError( + json?.message || request.statusText, + request.status, + json, + ), + ); + } + resolve({ + status: request.status, + headers: headerMap, + body: request.responseText, + json, + }); + }; + }); +}; diff --git a/example/vite-env.d.ts b/example/vite-env.d.ts index 30fb06a..fd01e68 100644 --- a/example/vite-env.d.ts +++ b/example/vite-env.d.ts @@ -1,7 +1,7 @@ /// interface ImportMetaEnv { - readonly VITE_MOCK: 'msw' | 'fetch-mock'; + readonly VITE_MOCK: 'msw' | 'fetch-mock' | 'sinon'; } interface ImportMeta { diff --git a/src/BaseServerWithMiddlewares.ts b/src/BaseServerWithMiddlewares.ts index a4773bb..c143902 100644 --- a/src/BaseServerWithMiddlewares.ts +++ b/src/BaseServerWithMiddlewares.ts @@ -11,49 +11,6 @@ export abstract class BaseServerWithMiddlewares< > extends BaseServer { middlewares: Array> = []; - decodeRequest(request: BaseRequest): BaseRequest { - for (const name of this.getSingleNames()) { - const matches = request.url?.match( - new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), - ); - - if (matches) { - request.single = name; - return request; - } - } - - const matches = request.url?.match( - new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), - ); - - if (matches) { - const name = matches[1]; - const params = Object.assign( - {}, - this.defaultQuery(name), - request.params, - ); - - request.collection = name; - request.params = params; - return request; - } - - return request; - } - - abstract extractContext( - request: RequestType, - ): Promise< - Pick - >; - abstract respond( - response: BaseResponse, - request: RequestType, - context: FakeRestContext, - ): Promise; - addBaseContext(context: FakeRestContext): FakeRestContext { for (const name of this.getSingleNames()) { const matches = context.url?.match( @@ -87,7 +44,37 @@ export abstract class BaseServerWithMiddlewares< return context; } - async handle(request: RequestType): Promise { + extractContext( + request: RequestType, + ): Promise< + Pick + > { + throw new Error('Not implemented'); + } + + respond( + response: BaseResponse | null, + request: RequestType, + context: FakeRestContext, + ): Promise { + throw new Error('Not implemented'); + } + + extractContextSync( + request: RequestType, + ): Pick { + throw new Error('Not implemented'); + } + + respondSync( + response: BaseResponse | null, + request: RequestType, + context: FakeRestContext, + ): ResponseType { + throw new Error('Not implemented'); + } + + async handle(request: RequestType): Promise { const context = this.addBaseContext(await this.extractContext(request)); // Call middlewares @@ -105,7 +92,44 @@ export abstract class BaseServerWithMiddlewares< try { const response = await next(request, context); - return this.respond(response, request, context); + if (response != null) { + return this.respond(response, request, context); + } + } catch (error) { + if (error instanceof Error) { + throw error; + } + + return error as ResponseType; + } + } + + handleSync(request: RequestType): ResponseType | undefined { + const context = this.addBaseContext(this.extractContextSync(request)); + + // Call middlewares + let index = 0; + const middlewares = [...this.middlewares]; + + const next = (req: RequestType, ctx: FakeRestContext) => { + const middleware = middlewares[index++]; + if (middleware) { + return middleware(req, ctx, next); + } + + return this.handleRequest(req, ctx); + }; + + try { + const response = next(request, context); + if (response instanceof Promise) { + throw new Error( + 'Middleware returned a promise in a sync context', + ); + } + if (response != null) { + return this.respondSync(response, request, context); + } } catch (error) { if (error instanceof Error) { throw error; @@ -351,5 +375,8 @@ export abstract class BaseServerWithMiddlewares< export type Middleware = ( request: RequestType, context: FakeRestContext, - next: (req: RequestType, ctx: FakeRestContext) => Promise, -) => Promise; + next: ( + req: RequestType, + ctx: FakeRestContext, + ) => Promise | BaseResponse, +) => Promise | BaseResponse | null | Promise; diff --git a/src/SinonServer.ts b/src/SinonServer.ts index 4fd23ab..3ca62d4 100644 --- a/src/SinonServer.ts +++ b/src/SinonServer.ts @@ -7,7 +7,7 @@ export class SinonServer extends BaseServerWithMiddlewares< SinonFakeXMLHttpRequest, SinonFakeRestResponse > { - async extractContext(request: SinonFakeXMLHttpRequest) { + extractContextSync(request: SinonFakeXMLHttpRequest) { const req: Request | SinonFakeXMLHttpRequest = typeof request === 'string' ? new Request(request) : request; @@ -34,7 +34,7 @@ export class SinonServer extends BaseServerWithMiddlewares< }; } - async respond(response: BaseResponse, request: SinonFakeXMLHttpRequest) { + respondSync(response: BaseResponse, request: SinonFakeXMLHttpRequest) { const sinonResponse = { status: response.status, body: response.body ?? '', @@ -120,7 +120,9 @@ export class SinonServer extends BaseServerWithMiddlewares< getHandler() { return (request: SinonFakeXMLHttpRequest) => { - this.handle(request); + const result = this.handleSync(request); + console.log(result); + return result; }; } } From 7b97ff8178530d0100d4a3cbb9390ba8f752ba1e Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 4 Jun 2024 16:40:36 +0200 Subject: [PATCH 08/33] Fix SinonServer tests --- src/SinonServer.spec.ts | 183 ++++++++++++++++++++-------------------- 1 file changed, 91 insertions(+), 92 deletions(-) diff --git a/src/SinonServer.spec.ts b/src/SinonServer.spec.ts index 34c6541..445b7bf 100644 --- a/src/SinonServer.spec.ts +++ b/src/SinonServer.spec.ts @@ -1,8 +1,9 @@ import sinon, { type SinonFakeXMLHttpRequest } from 'sinon'; -import { Server } from './SinonServer.js'; +import { SinonServer } from './SinonServer.js'; import { Single } from './Single.js'; import { Collection } from './Collection.js'; +import type { BaseResponse } from './BaseServer.js'; function getFakeXMLHTTPRequest( method: string, @@ -21,10 +22,10 @@ function getFakeXMLHTTPRequest( return request; } -describe('Server', () => { +describe('SinonServer', () => { describe('init', () => { it('should populate several collections', () => { - const server = new Server(); + const server = new SinonServer(); server.init({ foo: [{ a: 1 }, { a: 2 }, { a: 3 }], bar: [{ b: true }, { b: false }], @@ -45,7 +46,7 @@ describe('Server', () => { describe('addCollection', () => { it('should add a collection and index it by name', () => { - const server = new Server(); + const server = new SinonServer(); const collection = new Collection({ items: [ { id: 1, name: 'foo' }, @@ -60,7 +61,7 @@ describe('Server', () => { describe('addSingle', () => { it('should add a single object and index it by name', () => { - const server = new Server(); + const server = new SinonServer(); const single = new Single({ name: 'foo', description: 'bar' }); server.addSingle('foo', single); expect(server.getSingle('foo')).toEqual(single); @@ -69,7 +70,7 @@ describe('Server', () => { describe('getAll', () => { it('should return all items for a given name', () => { - const server = new Server(); + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -91,7 +92,7 @@ describe('Server', () => { }); it('should support a query', () => { - const server = new Server(); + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -117,7 +118,7 @@ describe('Server', () => { describe('getOne', () => { it('should return an error when no collection match the identifier', () => { - const server = new Server(); + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ items: [{ id: 1, name: 'foo' }] }), @@ -128,7 +129,7 @@ describe('Server', () => { }); it('should return the first collection matching the identifier', () => { - const server = new Server(); + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -143,7 +144,7 @@ describe('Server', () => { }); it('should use the identifierName', () => { - const server = new Server(); + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -161,15 +162,15 @@ describe('Server', () => { describe('getOnly', () => { it('should return the single matching the identifier', () => { - const server = new Server(); + const server = new SinonServer(); server.addSingle('foo', new Single({ name: 'foo' })); expect(server.getOnly('foo')).toEqual({ name: 'foo' }); }); }); - describe('addRequestInterceptor', () => { - it('should allow request transformation', async () => { - const server = new Server(); + describe('addMiddleware', () => { + it('should allow request transformation', () => { + const server = new SinonServer(); server.addMiddleware((request, context, next) => { const start = context.params?._start ? context.params._start - 1 @@ -196,7 +197,7 @@ describe('Server', () => { let request: SinonFakeXMLHttpRequest | null; request = getFakeXMLHTTPRequest('GET', '/foo?_start=1&_end=1'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request?.status).toEqual(206); // @ts-ignore expect(request.responseText).toEqual('[{"id":1,"name":"foo"}]'); @@ -205,7 +206,7 @@ describe('Server', () => { ); request = getFakeXMLHTTPRequest('GET', '/foo?_start=2&_end=2'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request?.status).toEqual(206); // @ts-ignore expect(request?.responseText).toEqual('[{"id":2,"name":"bar"}]'); @@ -213,18 +214,16 @@ describe('Server', () => { 'items 1-1/2', ); }); - }); - describe('addResponseInterceptor', () => { - it('should allow response transformation', async () => { - const server = new Server(); - server.addMiddleware(async (request, context, next) => { - const response = await next(request, context); - response.status = 418; + it('should allow response transformation', () => { + const server = new SinonServer(); + server.addMiddleware((request, context, next) => { + const response = next(request, context); + (response as BaseResponse).status = 418; return response; }); - server.addMiddleware(async (request, context, next) => { - const response = await next(request, context); + server.addMiddleware((request, context, next) => { + const response = next(request, context) as BaseResponse; response.body = { data: response.body, status: response.status, @@ -242,7 +241,7 @@ describe('Server', () => { ); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(418); // @ts-ignore expect(request.responseText).toEqual( @@ -250,8 +249,8 @@ describe('Server', () => { ); }); - it('should pass request in response interceptor', async () => { - const server = new Server(); + it('should pass request in response interceptor', () => { + const server = new SinonServer(); let requestUrl: string | undefined; server.addMiddleware((request, context, next) => { requestUrl = request.url; @@ -261,23 +260,23 @@ describe('Server', () => { const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(requestUrl).toEqual('/foo'); }); }); describe('handle', () => { - it('should respond a 404 to GET /whatever on non existing collection', async () => { - const server = new Server(); + it('should respond a 404 to GET /whatever on non existing collection', () => { + const server = new SinonServer(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(404); // not responded }); - it('should respond to GET /foo by sending all items in collection foo', async () => { - const server = new Server(); + it('should respond to GET /foo by sending all items in collection foo', () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -289,7 +288,7 @@ describe('Server', () => { ); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual( @@ -303,8 +302,8 @@ describe('Server', () => { ); }); - it('should respond to GET /foo?queryString by sending all items in collection foo satisfying query', async () => { - const server = new Server(); + it('should respond to GET /foo?queryString by sending all items in collection foo satisfying query', () => { + const server = new SinonServer(); server.addCollection( 'foos', new Collection({ @@ -324,7 +323,7 @@ describe('Server', () => { '/foos?filter={"arg":true}&sort=name&slice=[0,10]&embed=["bars"]', ); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual( @@ -338,8 +337,8 @@ describe('Server', () => { ); }); - it('should respond to GET /foo?queryString with pagination by sending the correct content-range header', async () => { - const server = new Server(); + it('should respond to GET /foo?queryString with pagination by sending the correct content-range header', () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -349,40 +348,40 @@ describe('Server', () => { let request: SinonFakeXMLHttpRequest | null; request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(200); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-10/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-4/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[5,9]'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 5-9/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[10,14]'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 10-10/11', ); }); - it('should respond to GET /foo on an empty collection with a []', async () => { - const server = new Server(); + it('should respond to GET /foo on an empty collection with a []', () => { + const server = new SinonServer(); server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('[]'); @@ -391,8 +390,8 @@ describe('Server', () => { ); }); - it('should respond to POST /foo by adding an item to collection foo', async () => { - const server = new Server(); + it('should respond to POST /foo by adding an item to collection foo', () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -408,7 +407,7 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(201); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz","id":3}'); @@ -423,15 +422,15 @@ describe('Server', () => { ]); }); - it('should respond to POST /foo by adding an item to collection foo, even if the collection does not exist', async () => { - const server = new Server(); + it('should respond to POST /foo by adding an item to collection foo, even if the collection does not exist', () => { + const server = new SinonServer(); const request = getFakeXMLHTTPRequest( 'POST', '/foo', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(201); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz","id":0}'); @@ -442,8 +441,8 @@ describe('Server', () => { expect(server.getAll('foo')).toEqual([{ id: 0, name: 'baz' }]); }); - it('should respond to GET /foo/:id by sending element of identifier id in collection foo', async () => { - const server = new Server(); + it('should respond to GET /foo/:id by sending element of identifier id in collection foo', () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -455,7 +454,7 @@ describe('Server', () => { ); const request = getFakeXMLHTTPRequest('GET', '/foo/2'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"bar"}'); @@ -464,17 +463,17 @@ describe('Server', () => { ); }); - it('should respond to GET /foo/:id on a non-existing id with a 404', async () => { - const server = new Server(); + it('should respond to GET /foo/:id on a non-existing id with a 404', () => { + const server = new SinonServer(); server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo/3'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(404); }); - it('should respond to PUT /foo/:id by updating element of identifier id in collection foo', async () => { - const server = new Server(); + it('should respond to PUT /foo/:id by updating element of identifier id in collection foo', () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -490,7 +489,7 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"baz"}'); @@ -503,8 +502,8 @@ describe('Server', () => { ]); }); - it('should respond to PUT /foo/:id on a non-existing id with a 404', async () => { - const server = new Server(); + it('should respond to PUT /foo/:id on a non-existing id with a 404', () => { + const server = new SinonServer(); server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest( 'PUT', @@ -512,12 +511,12 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(404); }); - it('should respond to PATCH /foo/:id by updating element of identifier id in collection foo', async () => { - const server = new Server(); + it('should respond to PATCH /foo/:id by updating element of identifier id in collection foo', () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -533,7 +532,7 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"baz"}'); @@ -546,8 +545,8 @@ describe('Server', () => { ]); }); - it('should respond to PATCH /foo/:id on a non-existing id with a 404', async () => { - const server = new Server(); + it('should respond to PATCH /foo/:id on a non-existing id with a 404', () => { + const server = new SinonServer(); server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest( 'PATCH', @@ -555,12 +554,12 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(404); }); - it('should respond to DELETE /foo/:id by removing element of identifier id in collection foo', async () => { - const server = new Server(); + it('should respond to DELETE /foo/:id by removing element of identifier id in collection foo', () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -572,7 +571,7 @@ describe('Server', () => { ); const request = getFakeXMLHTTPRequest('DELETE', '/foo/2'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"bar"}'); @@ -582,22 +581,22 @@ describe('Server', () => { expect(server.getAll('foo')).toEqual([{ id: 1, name: 'foo' }]); }); - it('should respond to DELETE /foo/:id on a non-existing id with a 404', async () => { - const server = new Server(); + it('should respond to DELETE /foo/:id on a non-existing id with a 404', () => { + const server = new SinonServer(); server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest('DELETE', '/foo/3'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(404); }); - it('should respond to GET /foo/ with single item', async () => { - const server = new Server(); + it('should respond to GET /foo/ with single item', () => { + const server = new SinonServer(); server.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"name":"foo"}'); @@ -606,8 +605,8 @@ describe('Server', () => { ); }); - it('should respond to PUT /foo/ by updating the singleton record', async () => { - const server = new Server(); + it('should respond to PUT /foo/ by updating the singleton record', () => { + const server = new SinonServer(); server.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest( @@ -616,7 +615,7 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz"}'); @@ -626,8 +625,8 @@ describe('Server', () => { expect(server.getOnly('foo')).toEqual({ name: 'baz' }); }); - it('should respond to PATCH /foo/ by updating the singleton record', async () => { - const server = new Server(); + it('should respond to PATCH /foo/ by updating the singleton record', () => { + const server = new SinonServer(); server.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest( @@ -636,7 +635,7 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz"}'); @@ -648,8 +647,8 @@ describe('Server', () => { }); describe('setDefaultQuery', () => { - it('should set the default query string', async () => { - const server = new Server(); + it('should set the default query string', () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -661,7 +660,7 @@ describe('Server', () => { }); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 2-4/10', @@ -671,8 +670,8 @@ describe('Server', () => { expect(request.responseText).toEqual(JSON.stringify(expected)); }); - it('should not override any provided query string', async () => { - const server = new Server(); + it('should not override any provided query string', () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -682,7 +681,7 @@ describe('Server', () => { server.setDefaultQuery((name) => ({ range: [2, 4] })); const request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]'); if (request == null) throw new Error('request is null'); - await server.handle(request); + server.handleSync(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-4/10', From a8d3050c866752da0ed2fe0646fc7562feebad40 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 5 Jun 2024 09:03:02 +0200 Subject: [PATCH 09/33] Fix build --- src/BaseServerWithMiddlewares.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BaseServerWithMiddlewares.ts b/src/BaseServerWithMiddlewares.ts index c143902..4c414fe 100644 --- a/src/BaseServerWithMiddlewares.ts +++ b/src/BaseServerWithMiddlewares.ts @@ -378,5 +378,5 @@ export type Middleware = ( next: ( req: RequestType, ctx: FakeRestContext, - ) => Promise | BaseResponse, -) => Promise | BaseResponse | null | Promise; + ) => Promise | BaseResponse | null, +) => Promise | BaseResponse | null; From d80d993cbda2009bf736bbeb243de1931b604d8d Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 5 Jun 2024 16:54:44 +0200 Subject: [PATCH 10/33] Update Upgrade Guide --- UPGRADE.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/UPGRADE.md b/UPGRADE.md index 36af71b..aa2f745 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -39,4 +39,32 @@ server.init(data); + { id: 3, title: 'boz' }, + ], +}); +``` + +## Request and Response Interceptors Have Been Replaced By Middlewares + +Fakerest used to have request and response interceptors. We replaced those with middlewares that allows much more use cases. + +Migrate your request interceptors: + +```diff +-restServer.addRequestInterceptor(function(request) { ++restServer.addMiddleware(async function(request, context, next) { + var start = (request.params._start - 1) || 0; + var end = request.params._end !== undefined ? (request.params._end - 1) : 19; + request.params.range = [start, end]; +- return request; // always return the modified input ++ return next(request, context); +}); +``` + +Migrate your response interceptors: + +```diff +-restServer.addResponseInterceptor(function(response) { ++restServer.addMiddleware(async function(request, context, next) { ++ const response = await next(request, context); + response.body = { data: response.body, status: response.status }; + return response; +}); ``` \ No newline at end of file From 875b676bdf4fc8b99cc24083c77ca4bcbdb2adda Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:15:47 +0200 Subject: [PATCH 11/33] Fix upgrade guide --- UPGRADE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UPGRADE.md b/UPGRADE.md index aa2f745..392a91d 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -43,7 +43,7 @@ server.init(data); ## Request and Response Interceptors Have Been Replaced By Middlewares -Fakerest used to have request and response interceptors. We replaced those with middlewares that allows much more use cases. +Fakerest used to have request and response interceptors. We replaced those with middlewares. They allow much more use cases. Migrate your request interceptors: From d9a49829990287a86383ff63ba03c7edf8b9b534 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:16:00 +0200 Subject: [PATCH 12/33] Remove unnecessary sinon html file --- public/sinon.html | 88 ----------------------------------------------- 1 file changed, 88 deletions(-) delete mode 100644 public/sinon.html diff --git a/public/sinon.html b/public/sinon.html deleted file mode 100644 index cd1febb..0000000 --- a/public/sinon.html +++ /dev/null @@ -1,88 +0,0 @@ - - - - Test FakeRest server - - -

FakeRest example

-

See source for example FakeRest usage.

-

Test data

-
-{
-    'authors': [
-        { id: 0, first_name: 'Leo', last_name: 'Tolstoi' },
-        { id: 1, first_name: 'Jane', last_name: 'Austen' }
-    ],
-    'books': [
-        { id: 0, author_id: 0, title: 'Anna Karenina' },
-        { id: 1, author_id: 0, title: 'War and Peace' },
-        { id: 2, author_id: 1, title: 'Pride and Prejudice' },
-        { id: 3, author_id: 1, title: 'Sense and Sensibility' }
-    ]
-}
-
-
-

GET /authors

- -
-
-

GET /books/3

- -
-
-

POST /books { author_id: 1, title: 'Emma' }

- -
- - - - - From 9ed57cee3ca502dbf45443255a48d6c9dec61938 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:16:27 +0200 Subject: [PATCH 13/33] Use FetchMockServer in example --- example/fetchMock.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example/fetchMock.ts b/example/fetchMock.ts index dae14ab..89935b0 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -1,10 +1,10 @@ import fetchMock from 'fetch-mock'; -import { FetchServer, withDelay } from 'fakerest'; +import { FetchMockServer, withDelay } from 'fakerest'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; export const initializeFetchMock = () => { - const restServer = new FetchServer({ + const restServer = new FetchMockServer({ baseUrl: 'http://localhost:3000', data, loggingEnabled: true, From cb8e5f1315430a8c5fc6be9a1d13ab5a64f96f39 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:16:48 +0200 Subject: [PATCH 14/33] Refactor --- src/AbstractBaseServer.ts | 226 +++++++++++ src/BaseServer.ts | 542 ++++++++++++++----------- src/BaseServerWithMiddlewares.ts | 382 ----------------- src/Collection.spec.ts | 2 +- src/Collection.ts | 6 +- src/FakeRest.ts | 10 +- src/Single.spec.ts | 2 +- src/Single.ts | 6 +- src/{ => adapters}/FetchMockServer.ts | 11 +- src/{ => adapters}/SinonServer.spec.ts | 6 +- src/{ => adapters}/SinonServer.ts | 8 +- src/{ => adapters}/msw.ts | 6 +- src/withDelay.ts | 2 +- 13 files changed, 566 insertions(+), 643 deletions(-) create mode 100644 src/AbstractBaseServer.ts delete mode 100644 src/BaseServerWithMiddlewares.ts rename src/{ => adapters}/FetchMockServer.ts (92%) rename src/{ => adapters}/SinonServer.spec.ts (99%) rename src/{ => adapters}/SinonServer.ts (94%) rename src/{ => adapters}/msw.ts (92%) diff --git a/src/AbstractBaseServer.ts b/src/AbstractBaseServer.ts new file mode 100644 index 0000000..9e8e41f --- /dev/null +++ b/src/AbstractBaseServer.ts @@ -0,0 +1,226 @@ +import { Collection } from './Collection.js'; +import { Single } from './Single.js'; +import type { CollectionItem, Query, QueryFunction } from './types.js'; + +/** + * This base class does not need generics so we can reference it in Collection and Single + * without having to propagate mocking implementation generics nor requiring the user to specify them. + * The BaseServerWithMiddlewares class is the one that needs to have generic parameters which are + * provided by the mocking implementation server classes. + */ +export abstract class AbstractBaseServer { + baseUrl = ''; + identifierName = 'id'; + loggingEnabled = false; + defaultQuery: QueryFunction = () => ({}); + collections: Record> = {}; + singles: Record> = {}; + getNewId?: () => number | string; + + constructor({ + baseUrl = '', + data, + defaultQuery = () => ({}), + identifierName = 'id', + getNewId, + loggingEnabled = false, + }: BaseServerOptions = {}) { + this.baseUrl = baseUrl; + this.getNewId = getNewId; + this.loggingEnabled = loggingEnabled; + this.identifierName = identifierName; + this.defaultQuery = defaultQuery; + + if (data) { + this.init(data); + } + } + + /** + * Shortcut for adding several collections if identifierName is always the same + */ + init(data: Record) { + for (const name in data) { + const value = data[name]; + if (Array.isArray(value)) { + this.addCollection( + name, + new Collection({ + items: value, + identifierName: this.identifierName, + getNewId: this.getNewId, + }), + ); + } else { + this.addSingle(name, new Single(value)); + } + } + } + + toggleLogging() { + this.loggingEnabled = !this.loggingEnabled; + } + + /** + * @param Function ResourceName => object + */ + setDefaultQuery(query: QueryFunction) { + this.defaultQuery = query; + } + + addCollection( + name: string, + collection: Collection, + ) { + this.collections[name] = collection; + collection.setServer(this); + collection.setName(name); + } + + getCollection(name: string) { + return this.collections[name]; + } + + getCollectionNames() { + return Object.keys(this.collections); + } + + addSingle( + name: string, + single: Single, + ) { + this.singles[name] = single; + single.setServer(this); + single.setName(name); + } + + getSingle(name: string) { + return this.singles[name]; + } + + getSingleNames() { + return Object.keys(this.singles); + } + + /** + * @param {string} name + * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } + */ + getCount(name: string, params?: Query) { + return this.collections[name].getCount(params); + } + + /** + * @param {string} name + * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } + */ + getAll(name: string, params?: Query) { + return this.collections[name].getAll(params); + } + + getOne(name: string, identifier: string | number, params?: Query) { + return this.collections[name].getOne(identifier, params); + } + + addOne(name: string, item: CollectionItem) { + if (!Object.prototype.hasOwnProperty.call(this.collections, name)) { + this.addCollection( + name, + new Collection({ + items: [], + identifierName: 'id', + getNewId: this.getNewId, + }), + ); + } + return this.collections[name].addOne(item); + } + + updateOne(name: string, identifier: string | number, item: CollectionItem) { + return this.collections[name].updateOne(identifier, item); + } + + removeOne(name: string, identifier: string | number) { + return this.collections[name].removeOne(identifier); + } + + getOnly(name: string, params?: Query) { + return this.singles[name].getOnly(); + } + + updateOnly(name: string, item: CollectionItem) { + return this.singles[name].updateOnly(item); + } + + getContext( + context: Pick< + FakeRestContext, + 'url' | 'method' | 'params' | 'requestJson' + >, + ): FakeRestContext { + for (const name of this.getSingleNames()) { + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), + ); + if (!matches) continue; + return { + ...context, + single: name, + }; + } + + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), + ); + if (matches) { + const name = matches[1]; + const params = Object.assign( + {}, + this.defaultQuery(name), + context.params, + ); + + return { + ...context, + collection: name, + params, + }; + } + + return context; + } +} + +export type BaseServerOptions = { + baseUrl?: string; + batchUrl?: string | null; + data?: Record; + defaultQuery?: QueryFunction; + identifierName?: string; + getNewId?: () => number | string; + loggingEnabled?: boolean; +}; + +export type BaseRequest = { + url?: string; + method?: string; + collection?: string; + single?: string; + requestJson?: Record | undefined; + params?: { [key: string]: any }; +}; + +export type BaseResponse = { + status: number; + body?: Record | Record[]; + headers: { [key: string]: string }; +}; + +export type FakeRestContext = { + url?: string; + method?: string; + collection?: string; + single?: string; + requestJson: Record | undefined; + params: { [key: string]: any }; +}; diff --git a/src/BaseServer.ts b/src/BaseServer.ts index 8b26576..5c67d12 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -1,270 +1,348 @@ -import { Collection } from './Collection.js'; -import { Single } from './Single.js'; -import type { CollectionItem, Query, QueryFunction } from './types.js'; - -/** - * This base class does not need generics so we can reference it in Collection and Single - * without having to propagate mocking implementation generics nor requiring the user to specify them. - * The BaseServerWithMiddlewares class is the one that needs to have generic parameters which are - * provided by the mocking implementation server classes. - */ -export abstract class BaseServer { - baseUrl = ''; - identifierName = 'id'; - loggingEnabled = false; - defaultQuery: QueryFunction = () => ({}); - batchUrl: string | null = null; - collections: Record> = {}; - singles: Record> = {}; - getNewId?: () => number | string; - - constructor({ - baseUrl = '', - batchUrl = null, - data, - defaultQuery = () => ({}), - identifierName = 'id', - getNewId, - loggingEnabled = false, - }: BaseServerOptions = {}) { - this.baseUrl = baseUrl; - this.batchUrl = batchUrl; - this.getNewId = getNewId; - this.loggingEnabled = loggingEnabled; - this.identifierName = identifierName; - this.defaultQuery = defaultQuery; - - if (data) { - this.init(data); - } - } - - /** - * Shortcut for adding several collections if identifierName is always the same - */ - init(data: Record) { - for (const name in data) { - const value = data[name]; - if (Array.isArray(value)) { - this.addCollection( - name, - new Collection({ - items: value, - identifierName: this.identifierName, - getNewId: this.getNewId, - }), - ); - } else { - this.addSingle(name, new Single(value)); - } - } +import { + type BaseResponse, + type FakeRestContext, + AbstractBaseServer, +} from './AbstractBaseServer.js'; + +export abstract class BaseServer< + RequestType, + ResponseType, +> extends AbstractBaseServer { + middlewares: Array> = []; + + extractContext( + request: RequestType, + ): Promise< + Pick + > { + throw new Error('Not implemented'); } - toggleLogging() { - this.loggingEnabled = !this.loggingEnabled; + respond( + response: BaseResponse | null, + request: RequestType, + context: FakeRestContext, + ): Promise { + throw new Error('Not implemented'); } - /** - * @param Function ResourceName => object - */ - setDefaultQuery(query: QueryFunction) { - this.defaultQuery = query; + extractContextSync( + request: RequestType, + ): Pick { + throw new Error('Not implemented'); } - setBatchUrl(batchUrl: string) { - this.batchUrl = batchUrl; + respondSync( + response: BaseResponse | null, + request: RequestType, + context: FakeRestContext, + ): ResponseType { + throw new Error('Not implemented'); } - /** - * @deprecated use setBatchUrl instead - */ - setBatch(url: string) { - console.warn( - 'Server.setBatch() is deprecated, use Server.setBatchUrl() instead', - ); - this.batchUrl = url; - } + async handle(request: RequestType): Promise { + const context = this.getContext(await this.extractContext(request)); - addCollection( - name: string, - collection: Collection, - ) { - this.collections[name] = collection; - collection.setServer(this); - collection.setName(name); - } + // Call middlewares + let index = 0; + const middlewares = [...this.middlewares]; - getCollection(name: string) { - return this.collections[name]; - } - - getCollectionNames() { - return Object.keys(this.collections); - } - - addSingle( - name: string, - single: Single, - ) { - this.singles[name] = single; - single.setServer(this); - single.setName(name); - } - - getSingle(name: string) { - return this.singles[name]; - } - - getSingleNames() { - return Object.keys(this.singles); - } - - /** - * @param {string} name - * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } - */ - getCount(name: string, params?: Query) { - return this.collections[name].getCount(params); - } + const next = (req: RequestType, ctx: FakeRestContext) => { + const middleware = middlewares[index++]; + if (middleware) { + return middleware(req, ctx, next); + } - /** - * @param {string} name - * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } - */ - getAll(name: string, params?: Query) { - return this.collections[name].getAll(params); - } + return this.handleRequest(req, ctx); + }; - getOne(name: string, identifier: string | number, params?: Query) { - return this.collections[name].getOne(identifier, params); - } + try { + const response = await next(request, context); + if (response != null) { + return this.respond(response, request, context); + } + } catch (error) { + if (error instanceof Error) { + throw error; + } - addOne(name: string, item: CollectionItem) { - if (!Object.prototype.hasOwnProperty.call(this.collections, name)) { - this.addCollection( - name, - new Collection({ - items: [], - identifierName: 'id', - getNewId: this.getNewId, - }), - ); + return error as ResponseType; } - return this.collections[name].addOne(item); - } - - updateOne(name: string, identifier: string | number, item: CollectionItem) { - return this.collections[name].updateOne(identifier, item); } - removeOne(name: string, identifier: string | number) { - return this.collections[name].removeOne(identifier); - } + handleSync(request: RequestType): ResponseType | undefined { + const context = this.getContext(this.extractContextSync(request)); - getOnly(name: string, params?: Query) { - return this.singles[name].getOnly(); - } + // Call middlewares + let index = 0; + const middlewares = [...this.middlewares]; - updateOnly(name: string, item: CollectionItem) { - return this.singles[name].updateOnly(item); - } - - decodeRequest(request: BaseRequest): BaseRequest { - for (const name of this.getSingleNames()) { - const matches = request.url?.match( - new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), - ); - - if (matches) { - request.single = name; - return request; + const next = (req: RequestType, ctx: FakeRestContext) => { + const middleware = middlewares[index++]; + if (middleware) { + return middleware(req, ctx, next); } - } - const matches = request.url?.match( - new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), - ); + return this.handleRequest(req, ctx); + }; - if (matches) { - const name = matches[1]; - const params = Object.assign( - {}, - this.defaultQuery(name), - request.params, - ); + try { + const response = next(request, context); + if (response instanceof Promise) { + throw new Error( + 'Middleware returned a promise in a sync context', + ); + } + if (response != null) { + return this.respondSync(response, request, context); + } + } catch (error) { + if (error instanceof Error) { + throw error; + } - request.collection = name; - request.params = params; - return request; + return error as ResponseType; } - - return request; } - addBaseContext(context: FakeRestContext): FakeRestContext { + handleRequest(request: RequestType, ctx: FakeRestContext): BaseResponse { + // Handle Single Objects for (const name of this.getSingleNames()) { - const matches = context.url?.match( + const matches = ctx.url?.match( new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), ); if (!matches) continue; - return { - ...context, - single: name, - }; + + if (ctx.method === 'GET') { + try { + return { + status: 200, + body: this.getOnly(name), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + if (ctx.method === 'PUT') { + try { + if (ctx.requestJson == null) { + return { + status: 400, + headers: {}, + }; + } + return { + status: 200, + body: this.updateOnly(name, ctx.requestJson), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + if (ctx.method === 'PATCH') { + try { + if (ctx.requestJson == null) { + return { + status: 400, + headers: {}, + }; + } + return { + status: 200, + body: this.updateOnly(name, ctx.requestJson), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } } - const matches = context.url?.match( + // handle collections + const matches = ctx.url?.match( new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), ); - if (matches) { - const name = matches[1]; - const params = Object.assign( - {}, - this.defaultQuery(name), - context.params, - ); - - return { - ...context, - collection: name, - params, - }; + if (!matches) { + return { status: 404, headers: {} }; } + const name = matches[1]; + const params = Object.assign({}, this.defaultQuery(name), ctx.params); + if (!matches[2]) { + if (ctx.method === 'GET') { + if (!this.getCollection(name)) { + return { status: 404, headers: {} }; + } + const count = this.getCount( + name, + params.filter ? { filter: params.filter } : {}, + ); + if (count > 0) { + const items = this.getAll(name, params); + const first = params.range ? params.range[0] : 0; + const last = + params.range && params.range.length === 2 + ? Math.min( + items.length - 1 + first, + params.range[1], + ) + : items.length - 1; + + return { + status: items.length === count ? 200 : 206, + body: items, + headers: { + 'Content-Type': 'application/json', + 'Content-Range': `items ${first}-${last}/${count}`, + }, + }; + } + + return { + status: 200, + body: [], + headers: { + 'Content-Type': 'application/json', + 'Content-Range': 'items */0', + }, + }; + } + if (ctx.method === 'POST') { + if (ctx.requestJson == null) { + return { + status: 400, + headers: {}, + }; + } + + const newResource = this.addOne(name, ctx.requestJson); + const newResourceURI = `${this.baseUrl}/${name}/${ + newResource[this.getCollection(name).identifierName] + }`; + + return { + status: 201, + body: newResource, + headers: { + 'Content-Type': 'application/json', + Location: newResourceURI, + }, + }; + } + } else { + if (!this.getCollection(name)) { + return { status: 404, headers: {} }; + } + const id = Number.parseInt(matches[3]); + if (ctx.method === 'GET') { + try { + return { + status: 200, + body: this.getOne(name, id, params), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + if (ctx.method === 'PUT') { + try { + if (ctx.requestJson == null) { + return { + status: 400, + headers: {}, + }; + } + return { + status: 200, + body: this.updateOne(name, id, ctx.requestJson), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + if (ctx.method === 'PATCH') { + try { + if (ctx.requestJson == null) { + return { + status: 400, + headers: {}, + }; + } + return { + status: 200, + body: this.updateOne(name, id, ctx.requestJson), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + if (ctx.method === 'DELETE') { + try { + return { + status: 200, + body: this.removeOne(name, id), + headers: { + 'Content-Type': 'application/json', + }, + }; + } catch (error) { + return { + status: 404, + headers: {}, + }; + } + } + } + return { + status: 404, + headers: {}, + }; + } - return context; + addMiddleware(middleware: Middleware) { + this.middlewares.push(middleware); } } -export type BaseServerOptions = { - baseUrl?: string; - batchUrl?: string | null; - data?: Record; - defaultQuery?: QueryFunction; - identifierName?: string; - getNewId?: () => number | string; - loggingEnabled?: boolean; -}; - -export type BaseRequest = { - url?: string; - method?: string; - collection?: string; - single?: string; - requestJson?: Record | undefined; - params?: { [key: string]: any }; -}; - -export type BaseResponse = { - status: number; - body?: Record | Record[]; - headers: { [key: string]: string }; -}; - -export type FakeRestContext = { - url?: string; - method?: string; - collection?: string; - single?: string; - requestJson: Record | undefined; - params: { [key: string]: any }; -}; +export type Middleware = ( + request: RequestType, + context: FakeRestContext, + next: ( + req: RequestType, + ctx: FakeRestContext, + ) => Promise | BaseResponse | null, +) => Promise | BaseResponse | null; diff --git a/src/BaseServerWithMiddlewares.ts b/src/BaseServerWithMiddlewares.ts deleted file mode 100644 index 4c414fe..0000000 --- a/src/BaseServerWithMiddlewares.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { - type BaseRequest, - type BaseResponse, - type FakeRestContext, - BaseServer, -} from './BaseServer.js'; - -export abstract class BaseServerWithMiddlewares< - RequestType, - ResponseType, -> extends BaseServer { - middlewares: Array> = []; - - addBaseContext(context: FakeRestContext): FakeRestContext { - for (const name of this.getSingleNames()) { - const matches = context.url?.match( - new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), - ); - if (!matches) continue; - return { - ...context, - single: name, - }; - } - - const matches = context.url?.match( - new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), - ); - if (matches) { - const name = matches[1]; - const params = Object.assign( - {}, - this.defaultQuery(name), - context.params, - ); - - return { - ...context, - collection: name, - params, - }; - } - - return context; - } - - extractContext( - request: RequestType, - ): Promise< - Pick - > { - throw new Error('Not implemented'); - } - - respond( - response: BaseResponse | null, - request: RequestType, - context: FakeRestContext, - ): Promise { - throw new Error('Not implemented'); - } - - extractContextSync( - request: RequestType, - ): Pick { - throw new Error('Not implemented'); - } - - respondSync( - response: BaseResponse | null, - request: RequestType, - context: FakeRestContext, - ): ResponseType { - throw new Error('Not implemented'); - } - - async handle(request: RequestType): Promise { - const context = this.addBaseContext(await this.extractContext(request)); - - // Call middlewares - let index = 0; - const middlewares = [...this.middlewares]; - - const next = (req: RequestType, ctx: FakeRestContext) => { - const middleware = middlewares[index++]; - if (middleware) { - return middleware(req, ctx, next); - } - - return this.handleRequest(req, ctx); - }; - - try { - const response = await next(request, context); - if (response != null) { - return this.respond(response, request, context); - } - } catch (error) { - if (error instanceof Error) { - throw error; - } - - return error as ResponseType; - } - } - - handleSync(request: RequestType): ResponseType | undefined { - const context = this.addBaseContext(this.extractContextSync(request)); - - // Call middlewares - let index = 0; - const middlewares = [...this.middlewares]; - - const next = (req: RequestType, ctx: FakeRestContext) => { - const middleware = middlewares[index++]; - if (middleware) { - return middleware(req, ctx, next); - } - - return this.handleRequest(req, ctx); - }; - - try { - const response = next(request, context); - if (response instanceof Promise) { - throw new Error( - 'Middleware returned a promise in a sync context', - ); - } - if (response != null) { - return this.respondSync(response, request, context); - } - } catch (error) { - if (error instanceof Error) { - throw error; - } - - return error as ResponseType; - } - } - - handleRequest(request: RequestType, ctx: FakeRestContext): BaseResponse { - // Handle Single Objects - for (const name of this.getSingleNames()) { - const matches = ctx.url?.match( - new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), - ); - if (!matches) continue; - - if (ctx.method === 'GET') { - try { - return { - status: 200, - body: this.getOnly(name), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - if (ctx.method === 'PUT') { - try { - if (ctx.requestJson == null) { - return { - status: 400, - headers: {}, - }; - } - return { - status: 200, - body: this.updateOnly(name, ctx.requestJson), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - if (ctx.method === 'PATCH') { - try { - if (ctx.requestJson == null) { - return { - status: 400, - headers: {}, - }; - } - return { - status: 200, - body: this.updateOnly(name, ctx.requestJson), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - } - - // handle collections - const matches = ctx.url?.match( - new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), - ); - if (!matches) { - return { status: 404, headers: {} }; - } - const name = matches[1]; - const params = Object.assign({}, this.defaultQuery(name), ctx.params); - if (!matches[2]) { - if (ctx.method === 'GET') { - if (!this.getCollection(name)) { - return { status: 404, headers: {} }; - } - const count = this.getCount( - name, - params.filter ? { filter: params.filter } : {}, - ); - if (count > 0) { - const items = this.getAll(name, params); - const first = params.range ? params.range[0] : 0; - const last = - params.range && params.range.length === 2 - ? Math.min( - items.length - 1 + first, - params.range[1], - ) - : items.length - 1; - - return { - status: items.length === count ? 200 : 206, - body: items, - headers: { - 'Content-Type': 'application/json', - 'Content-Range': `items ${first}-${last}/${count}`, - }, - }; - } - - return { - status: 200, - body: [], - headers: { - 'Content-Type': 'application/json', - 'Content-Range': 'items */0', - }, - }; - } - if (ctx.method === 'POST') { - if (ctx.requestJson == null) { - return { - status: 400, - headers: {}, - }; - } - - const newResource = this.addOne(name, ctx.requestJson); - const newResourceURI = `${this.baseUrl}/${name}/${ - newResource[this.getCollection(name).identifierName] - }`; - - return { - status: 201, - body: newResource, - headers: { - 'Content-Type': 'application/json', - Location: newResourceURI, - }, - }; - } - } else { - if (!this.getCollection(name)) { - return { status: 404, headers: {} }; - } - const id = Number.parseInt(matches[3]); - if (ctx.method === 'GET') { - try { - return { - status: 200, - body: this.getOne(name, id, params), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - if (ctx.method === 'PUT') { - try { - if (ctx.requestJson == null) { - return { - status: 400, - headers: {}, - }; - } - return { - status: 200, - body: this.updateOne(name, id, ctx.requestJson), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - if (ctx.method === 'PATCH') { - try { - if (ctx.requestJson == null) { - return { - status: 400, - headers: {}, - }; - } - return { - status: 200, - body: this.updateOne(name, id, ctx.requestJson), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - if (ctx.method === 'DELETE') { - try { - return { - status: 200, - body: this.removeOne(name, id), - headers: { - 'Content-Type': 'application/json', - }, - }; - } catch (error) { - return { - status: 404, - headers: {}, - }; - } - } - } - return { - status: 404, - headers: {}, - }; - } - - addMiddleware(middleware: Middleware) { - this.middlewares.push(middleware); - } -} - -export type Middleware = ( - request: RequestType, - context: FakeRestContext, - next: ( - req: RequestType, - ctx: FakeRestContext, - ) => Promise | BaseResponse | null, -) => Promise | BaseResponse | null; diff --git a/src/Collection.spec.ts b/src/Collection.spec.ts index 4222504..b17687a 100644 --- a/src/Collection.spec.ts +++ b/src/Collection.spec.ts @@ -1,5 +1,5 @@ import { Collection } from './Collection.js'; -import { Server } from './SinonServer.js'; +import { Server } from './adapters/SinonServer.js'; import type { CollectionItem } from './types.js'; describe('Collection', () => { diff --git a/src/Collection.ts b/src/Collection.ts index 9be8fac..08edbc2 100644 --- a/src/Collection.ts +++ b/src/Collection.ts @@ -1,6 +1,6 @@ import get from 'lodash/get.js'; import matches from 'lodash/matches.js'; -import type { BaseServer } from './BaseServer.js'; +import type { AbstractBaseServer } from './AbstractBaseServer.js'; import type { CollectionItem, Embed, @@ -14,7 +14,7 @@ import type { export class Collection { sequence = 0; items: T[] = []; - server: BaseServer | null = null; + server: AbstractBaseServer | null = null; name: string | null = null; identifierName = 'id'; getNewId: () => number | string; @@ -42,7 +42,7 @@ export class Collection { * A Collection may need to access other collections (e.g. for embedding references) * This is done through a reference to the parent server. */ - setServer(server: BaseServer) { + setServer(server: AbstractBaseServer) { this.server = server; } diff --git a/src/FakeRest.ts b/src/FakeRest.ts index 994ba6d..956c2e3 100644 --- a/src/FakeRest.ts +++ b/src/FakeRest.ts @@ -1,12 +1,16 @@ -import { getSinonHandler, Server, SinonServer } from './SinonServer.js'; +import { + getSinonHandler, + Server, + SinonServer, +} from './adapters/SinonServer.js'; import { getFetchMockHandler, FetchServer, FetchMockServer, -} from './FetchMockServer.js'; +} from './adapters/FetchMockServer.js'; +import { getMswHandlers, MswServer } from './adapters/msw.js'; import { Collection } from './Collection.js'; import { Single } from './Single.js'; -import { getMswHandlers, MswServer } from './msw.js'; import { withDelay } from './withDelay.js'; export { diff --git a/src/Single.spec.ts b/src/Single.spec.ts index cdcb224..f9107b5 100644 --- a/src/Single.spec.ts +++ b/src/Single.spec.ts @@ -1,6 +1,6 @@ import { Single } from './Single.js'; import { Collection } from './Collection.js'; -import { Server } from './SinonServer.js'; +import { Server } from './adapters/SinonServer.js'; describe('Single', () => { describe('constructor', () => { diff --git a/src/Single.ts b/src/Single.ts index 861e9e8..5dbb320 100644 --- a/src/Single.ts +++ b/src/Single.ts @@ -1,9 +1,9 @@ -import type { BaseServer } from './BaseServer.js'; +import type { AbstractBaseServer } from './AbstractBaseServer.js'; import type { CollectionItem, Embed, Query } from './types.js'; export class Single { obj: T | null = null; - server: BaseServer | null = null; + server: AbstractBaseServer | null = null; name: string | null = null; constructor(obj: T) { @@ -19,7 +19,7 @@ export class Single { * A Single may need to access other collections (e.g. for embedded * references) This is done through a reference to the parent server. */ - setServer(server: BaseServer) { + setServer(server: AbstractBaseServer) { this.server = server; } diff --git a/src/FetchMockServer.ts b/src/adapters/FetchMockServer.ts similarity index 92% rename from src/FetchMockServer.ts rename to src/adapters/FetchMockServer.ts index 3c5b16b..1820645 100644 --- a/src/FetchMockServer.ts +++ b/src/adapters/FetchMockServer.ts @@ -1,16 +1,13 @@ import type { MockResponseObject, MockMatcherFunction } from 'fetch-mock'; -import { BaseServerWithMiddlewares } from './BaseServerWithMiddlewares.js'; +import { BaseServer } from '../BaseServer.js'; import type { BaseResponse, BaseServerOptions, FakeRestContext, -} from './BaseServer.js'; -import { parseQueryString } from './parseQueryString.js'; +} from '../AbstractBaseServer.js'; +import { parseQueryString } from '../parseQueryString.js'; -export class FetchMockServer extends BaseServerWithMiddlewares< - Request, - MockResponseObject -> { +export class FetchMockServer extends BaseServer { async extractContext(request: Request) { const req = typeof request === 'string' ? new Request(request) : request; diff --git a/src/SinonServer.spec.ts b/src/adapters/SinonServer.spec.ts similarity index 99% rename from src/SinonServer.spec.ts rename to src/adapters/SinonServer.spec.ts index 445b7bf..c403d9b 100644 --- a/src/SinonServer.spec.ts +++ b/src/adapters/SinonServer.spec.ts @@ -1,9 +1,9 @@ import sinon, { type SinonFakeXMLHttpRequest } from 'sinon'; import { SinonServer } from './SinonServer.js'; -import { Single } from './Single.js'; -import { Collection } from './Collection.js'; -import type { BaseResponse } from './BaseServer.js'; +import { Single } from '../Single.js'; +import { Collection } from '../Collection.js'; +import type { BaseResponse } from '../AbstractBaseServer.js'; function getFakeXMLHTTPRequest( method: string, diff --git a/src/SinonServer.ts b/src/adapters/SinonServer.ts similarity index 94% rename from src/SinonServer.ts rename to src/adapters/SinonServer.ts index 3ca62d4..50a247b 100644 --- a/src/SinonServer.ts +++ b/src/adapters/SinonServer.ts @@ -1,9 +1,9 @@ import type { SinonFakeXMLHttpRequest } from 'sinon'; -import { BaseServerWithMiddlewares } from './BaseServerWithMiddlewares.js'; -import { parseQueryString } from './parseQueryString.js'; -import type { BaseResponse, BaseServerOptions } from './BaseServer.js'; +import { BaseServer } from '../BaseServer.js'; +import { parseQueryString } from '../parseQueryString.js'; +import type { BaseResponse, BaseServerOptions } from '../AbstractBaseServer.js'; -export class SinonServer extends BaseServerWithMiddlewares< +export class SinonServer extends BaseServer< SinonFakeXMLHttpRequest, SinonFakeRestResponse > { diff --git a/src/msw.ts b/src/adapters/msw.ts similarity index 92% rename from src/msw.ts rename to src/adapters/msw.ts index 8e8804e..61ae573 100644 --- a/src/msw.ts +++ b/src/adapters/msw.ts @@ -1,12 +1,12 @@ import { http, HttpResponse } from 'msw'; -import { BaseServerWithMiddlewares } from './BaseServerWithMiddlewares.js'; +import { BaseServer } from '../BaseServer.js'; import type { BaseRequest, BaseResponse, BaseServerOptions, -} from './BaseServer.js'; +} from '../AbstractBaseServer.js'; -export class MswServer extends BaseServerWithMiddlewares { +export class MswServer extends BaseServer { async respond(response: BaseResponse) { return HttpResponse.json(response.body, { status: response.status, diff --git a/src/withDelay.ts b/src/withDelay.ts index d538825..dd22c1e 100644 --- a/src/withDelay.ts +++ b/src/withDelay.ts @@ -1,4 +1,4 @@ -import type { Middleware } from './BaseServerWithMiddlewares.js'; +import type { Middleware } from './BaseServer.js'; export const withDelay = (delayMs: number): Middleware => From 46a79120a153cd914c6af8a63646a8ed312856ac Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:20:39 +0200 Subject: [PATCH 15/33] Make msw example less verbose --- example/index.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/example/index.tsx b/example/index.tsx index 342198c..0b1aff5 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -32,7 +32,12 @@ switch (import.meta.env.VITE_MOCK) { default: import('./msw') .then(({ worker, dataProvider }) => { - return worker.start().then(() => dataProvider); + return worker + .start({ + quiet: true, + onUnhandledRequest: 'bypass', + }) + .then(() => dataProvider); }) .then((dataProvider) => { ReactDom.render( From 1ce980de94ff5f71686c9ffec18f24630a4e9821 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:21:22 +0200 Subject: [PATCH 16/33] Remove debug code --- src/adapters/SinonServer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/adapters/SinonServer.ts b/src/adapters/SinonServer.ts index 50a247b..d252cbb 100644 --- a/src/adapters/SinonServer.ts +++ b/src/adapters/SinonServer.ts @@ -121,7 +121,6 @@ export class SinonServer extends BaseServer< getHandler() { return (request: SinonFakeXMLHttpRequest) => { const result = this.handleSync(request); - console.log(result); return result; }; } From 6d39911b6101e49785f7b4e3c36d53fa163b486f Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:35:47 +0200 Subject: [PATCH 17/33] Simplify sinon example --- example/sinon.ts | 140 +++++------------------------------------------ 1 file changed, 15 insertions(+), 125 deletions(-) diff --git a/example/sinon.ts b/example/sinon.ts index e08f620..3f96303 100644 --- a/example/sinon.ts +++ b/example/sinon.ts @@ -1,7 +1,8 @@ import sinon from 'sinon'; import { SinonServer } from '../src/FakeRest'; import { data } from './data'; -import { type DataProvider, HttpError } from 'react-admin'; +import { HttpError, type Options } from 'react-admin'; +import simpleRestProvider from 'ra-data-simple-rest'; export const initializeSinon = () => { const restServer = new SinonServer({ @@ -32,125 +33,9 @@ export const initializeSinon = () => { server.respondWith(restServer.getHandler()); }; -export const dataProvider: DataProvider = { - async getList(resource, params) { - const { page, perPage } = params.pagination; - const { field, order } = params.sort; - - const rangeStart = (page - 1) * perPage; - const rangeEnd = page * perPage - 1; - const query = { - sort: JSON.stringify([field, order]), - range: JSON.stringify([rangeStart, rangeEnd]), - filter: JSON.stringify(params.filter), - }; - const json = await sendRequest( - `http://localhost:3000/${resource}?${new URLSearchParams(query)}`, - ); - return { - data: json.json, - total: Number.parseInt( - json.headers['Content-Range'].split('/').pop() ?? '0', - 10, - ), - }; - }, - async getMany(resource, params) { - const query = { - filter: JSON.stringify({ id: params.ids }), - }; - const json = await sendRequest( - `http://localhost:3000/${resource}?${new URLSearchParams(query)}`, - ); - return { - data: json.json, - }; - }, - async getManyReference(resource, params) { - const { page, perPage } = params.pagination; - const { field, order } = params.sort; - const rangeStart = (page - 1) * perPage; - const rangeEnd = page * perPage - 1; - const query = { - sort: JSON.stringify([field, order]), - range: JSON.stringify([rangeStart, rangeEnd]), - filter: JSON.stringify({ - ...params.filter, - [params.target]: params.id, - }), - }; - const json = await sendRequest( - `http://localhost:3000/${resource}?${new URLSearchParams(query)}`, - ); - return { - data: json.json, - total: Number.parseInt( - json.headers['Content-Range'].split('/').pop() ?? '0', - 10, - ), - }; - }, - async getOne(resource, params) { - const json = await sendRequest( - `http://localhost:3000/${resource}/${params.id}`, - ); - return { - data: json.json, - }; - }, - async create(resource, params) { - const json = await sendRequest( - `http://localhost:3000/${resource}`, - 'POST', - JSON.stringify(params.data), - ); - return { - data: json.json, - }; - }, - async update(resource, params) { - const json = await sendRequest( - `http://localhost:3000/${resource}/${params.id}`, - 'PUT', - JSON.stringify(params.data), - ); - return { - data: json.json, - }; - }, - async updateMany(resource, params) { - return Promise.all( - params.ids.map((id) => - this.update(resource, { id, data: params.data }), - ), - ).then((responses) => ({ data: responses.map(({ json }) => json.id) })); - }, - async delete(resource, params) { - const json = await sendRequest( - `http://localhost:3000/${resource}/${params.id}`, - 'DELETE', - null, - ); - return { - data: json.json, - }; - }, - async deleteMany(resource, params) { - return Promise.all( - params.ids.map((id) => this.delete(resource, { id })), - ).then((responses) => ({ - data: responses.map(({ data }) => data.id), - })); - }, -}; - -const sendRequest = ( - url: string, - method = 'GET', - body: any = null, -): Promise => { +const httpClient = (url: string, options: Options = {}): Promise => { const request = new XMLHttpRequest(); - request.open(method, url); + request.open(options.method ?? 'GET', url); const persistedUser = localStorage.getItem('user'); const user = persistedUser ? JSON.parse(persistedUser) : null; @@ -160,7 +45,7 @@ const sendRequest = ( // add content-type header request.overrideMimeType('application/json'); - request.send(body); + request.send(typeof options.body === 'string' ? options.body : undefined); return new Promise((resolve, reject) => { request.onloadend = (e) => { @@ -171,20 +56,20 @@ const sendRequest = ( // not json, no big deal } // Get the raw header string - const headers = request.getAllResponseHeaders(); + const headersAsString = request.getAllResponseHeaders(); // Convert the header string into an array // of individual headers - const arr = headers.trim().split(/[\r\n]+/); + const arr = headersAsString.trim().split(/[\r\n]+/); // Create a map of header names to values - const headerMap: Record = {}; + const headers = new Headers(); for (const line of arr) { const parts = line.split(': '); const header = parts.shift(); if (!header) continue; const value = parts.join(': '); - headerMap[header] = value; + headers.set(header, value); } if (request.status < 200 || request.status >= 300) { return reject( @@ -197,10 +82,15 @@ const sendRequest = ( } resolve({ status: request.status, - headers: headerMap, + headers, body: request.responseText, json, }); }; }); }; + +export const dataProvider = simpleRestProvider( + 'http://localhost:3000', + httpClient, +); From e2f4c198e0f75cd0e741a615be780009c468148f Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:45:45 +0200 Subject: [PATCH 18/33] Better server side validation --- example/App.tsx | 12 ++++++++++-- example/fetchMock.ts | 33 +++++++++++++++++++++++---------- example/msw.ts | 34 ++++++++++++++++++++++++---------- example/sinon.ts | 24 ++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 22 deletions(-) diff --git a/example/App.tsx b/example/App.tsx index e2999f1..0a91491 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -7,6 +7,8 @@ import { ListGuesser, Resource, ShowGuesser, + required, + AutocompleteInput, } from 'react-admin'; import { QueryClient } from 'react-query'; @@ -51,8 +53,14 @@ import authProvider from './authProvider'; export const BookCreate = () => ( - - + + + + ); diff --git a/example/fetchMock.ts b/example/fetchMock.ts index 89935b0..4fe1cca 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -19,16 +19,29 @@ export const initializeFetchMock = () => { if (!request.headers?.get('Authorization')) { return new Response(null, { status: 401 }); } - - if ( - context.collection === 'books' && - request.method === 'POST' && - !context.requestJson?.title - ) { - return new Response(null, { - status: 400, - statusText: 'Title is required', - }); + return next(request, context); + }); + restServer.addMiddleware(async (request, context, next) => { + if (context.collection === 'books' && request.method === 'POST') { + if ( + restServer.collections[context.collection].getCount({ + filter: { + title: context.requestJson?.title, + }, + }) > 0 + ) { + throw new Response( + JSON.stringify({ + errors: { + title: 'An article with this title already exists. The title must be unique.', + }, + }), + { + status: 400, + statusText: 'Title is required', + }, + ); + } } return next(request, context); diff --git a/example/msw.ts b/example/msw.ts index 4f1ffd2..bef2b18 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -12,18 +12,32 @@ const restServer = new MswServer({ restServer.addMiddleware(withDelay(300)); restServer.addMiddleware(async (request, context, next) => { if (!request.headers?.get('Authorization')) { - throw new HttpResponse(null, { status: 401 }); + throw new Response(null, { status: 401 }); } + return next(request, context); +}); - if ( - context.collection === 'books' && - request.method === 'POST' && - !context.requestJson?.title - ) { - throw new HttpResponse(null, { - status: 400, - statusText: 'Title is required', - }); +restServer.addMiddleware(async (request, context, next) => { + if (context.collection === 'books' && request.method === 'POST') { + if ( + restServer.collections[context.collection].getCount({ + filter: { + title: context.requestJson?.title, + }, + }) > 0 + ) { + throw new Response( + JSON.stringify({ + errors: { + title: 'An article with this title already exists. The title must be unique.', + }, + }), + { + status: 400, + statusText: 'Title is required', + }, + ); + } } return next(request, context); diff --git a/example/sinon.ts b/example/sinon.ts index 3f96303..2719334 100644 --- a/example/sinon.ts +++ b/example/sinon.ts @@ -20,6 +20,30 @@ export const initializeSinon = () => { return next(request, context); }); + restServer.addMiddleware((request, context, next) => { + if (context.collection === 'books' && request.method === 'POST') { + if ( + restServer.collections[context.collection].getCount({ + filter: { + title: context.requestJson?.title, + }, + }) > 0 + ) { + request.respond( + 401, + {}, + JSON.stringify({ + errors: { + title: 'An article with this title already exists. The title must be unique.', + }, + }), + ); + } + } + + return next(request, context); + }); + // use sinon.js to monkey-patch XmlHttpRequest const server = sinon.fakeServer.create(); // this is required when doing asynchronous XmlHttpRequest From 3c655d1aef335f89db1cd85e04aafba99ebb4a1c Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:57:18 +0200 Subject: [PATCH 19/33] Remove unnecessary types --- src/adapters/FetchMockServer.ts | 9 --------- src/adapters/msw.ts | 9 --------- 2 files changed, 18 deletions(-) diff --git a/src/adapters/FetchMockServer.ts b/src/adapters/FetchMockServer.ts index 1820645..3af5a9d 100644 --- a/src/adapters/FetchMockServer.ts +++ b/src/adapters/FetchMockServer.ts @@ -106,12 +106,3 @@ export type FetchMockFakeRestRequest = Partial & { queryString?: string; params?: { [key: string]: any }; }; - -export type FetchMockRequestInterceptor = ( - request: FetchMockFakeRestRequest, -) => FetchMockFakeRestRequest; - -export type FetchMockResponseInterceptor = ( - response: MockResponseObject, - request: FetchMockFakeRestRequest, -) => MockResponseObject; diff --git a/src/adapters/msw.ts b/src/adapters/msw.ts index 61ae573..5671314 100644 --- a/src/adapters/msw.ts +++ b/src/adapters/msw.ts @@ -74,12 +74,3 @@ const getCollectionHandlers = ({ }; export type MswFakeRestRequest = Partial & BaseRequest; - -export type MswRequestInterceptor = ( - request: MswFakeRestRequest, -) => MswFakeRestRequest; - -export type MswResponseInterceptor = ( - response: HttpResponse, - request: Request, -) => HttpResponse; From 8662b5eb607ed3dc2a2891a172b8e7e64fe6729b Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 15:49:51 +0200 Subject: [PATCH 20/33] Make sinon async compatible --- example/sinon.ts | 25 ++++---- src/BaseServer.ts | 49 --------------- src/adapters/SinonServer.spec.ts | 100 +++++++++++++++---------------- src/adapters/SinonServer.ts | 17 ++++-- 4 files changed, 76 insertions(+), 115 deletions(-) diff --git a/example/sinon.ts b/example/sinon.ts index 2719334..9031490 100644 --- a/example/sinon.ts +++ b/example/sinon.ts @@ -1,5 +1,5 @@ import sinon from 'sinon'; -import { SinonServer } from '../src/FakeRest'; +import { SinonServer, withDelay } from '../src/FakeRest'; import { data } from './data'; import { HttpError, type Options } from 'react-admin'; import simpleRestProvider from 'ra-data-simple-rest'; @@ -11,16 +11,19 @@ export const initializeSinon = () => { loggingEnabled: true, }); - restServer.addMiddleware((request, context, next) => { + restServer.addMiddleware(withDelay(3000)); + restServer.addMiddleware(async (request, context, next) => { if (request.requestHeaders.Authorization === undefined) { - request.respond(401, {}, 'Unauthorized'); - return null; + return { + status: 401, + headers: {}, + }; } return next(request, context); }); - restServer.addMiddleware((request, context, next) => { + restServer.addMiddleware(async (request, context, next) => { if (context.collection === 'books' && request.method === 'POST') { if ( restServer.collections[context.collection].getCount({ @@ -29,15 +32,15 @@ export const initializeSinon = () => { }, }) > 0 ) { - request.respond( - 401, - {}, - JSON.stringify({ + return { + status: 400, + headers: {}, + body: { errors: { title: 'An article with this title already exists. The title must be unique.', }, - }), - ); + }, + }; } } diff --git a/src/BaseServer.ts b/src/BaseServer.ts index 5c67d12..bea0f7b 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -26,20 +26,6 @@ export abstract class BaseServer< throw new Error('Not implemented'); } - extractContextSync( - request: RequestType, - ): Pick { - throw new Error('Not implemented'); - } - - respondSync( - response: BaseResponse | null, - request: RequestType, - context: FakeRestContext, - ): ResponseType { - throw new Error('Not implemented'); - } - async handle(request: RequestType): Promise { const context = this.getContext(await this.extractContext(request)); @@ -70,41 +56,6 @@ export abstract class BaseServer< } } - handleSync(request: RequestType): ResponseType | undefined { - const context = this.getContext(this.extractContextSync(request)); - - // Call middlewares - let index = 0; - const middlewares = [...this.middlewares]; - - const next = (req: RequestType, ctx: FakeRestContext) => { - const middleware = middlewares[index++]; - if (middleware) { - return middleware(req, ctx, next); - } - - return this.handleRequest(req, ctx); - }; - - try { - const response = next(request, context); - if (response instanceof Promise) { - throw new Error( - 'Middleware returned a promise in a sync context', - ); - } - if (response != null) { - return this.respondSync(response, request, context); - } - } catch (error) { - if (error instanceof Error) { - throw error; - } - - return error as ResponseType; - } - } - handleRequest(request: RequestType, ctx: FakeRestContext): BaseResponse { // Handle Single Objects for (const name of this.getSingleNames()) { diff --git a/src/adapters/SinonServer.spec.ts b/src/adapters/SinonServer.spec.ts index c403d9b..1114164 100644 --- a/src/adapters/SinonServer.spec.ts +++ b/src/adapters/SinonServer.spec.ts @@ -169,7 +169,7 @@ describe('SinonServer', () => { }); describe('addMiddleware', () => { - it('should allow request transformation', () => { + it('should allow request transformation', async () => { const server = new SinonServer(); server.addMiddleware((request, context, next) => { const start = context.params?._start @@ -197,7 +197,7 @@ describe('SinonServer', () => { let request: SinonFakeXMLHttpRequest | null; request = getFakeXMLHTTPRequest('GET', '/foo?_start=1&_end=1'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request?.status).toEqual(206); // @ts-ignore expect(request.responseText).toEqual('[{"id":1,"name":"foo"}]'); @@ -206,7 +206,7 @@ describe('SinonServer', () => { ); request = getFakeXMLHTTPRequest('GET', '/foo?_start=2&_end=2'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request?.status).toEqual(206); // @ts-ignore expect(request?.responseText).toEqual('[{"id":2,"name":"bar"}]'); @@ -215,7 +215,7 @@ describe('SinonServer', () => { ); }); - it('should allow response transformation', () => { + it('should allow response transformation', async () => { const server = new SinonServer(); server.addMiddleware((request, context, next) => { const response = next(request, context); @@ -241,7 +241,7 @@ describe('SinonServer', () => { ); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(418); // @ts-ignore expect(request.responseText).toEqual( @@ -249,7 +249,7 @@ describe('SinonServer', () => { ); }); - it('should pass request in response interceptor', () => { + it('should pass request in response interceptor', async () => { const server = new SinonServer(); let requestUrl: string | undefined; server.addMiddleware((request, context, next) => { @@ -260,22 +260,22 @@ describe('SinonServer', () => { const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(requestUrl).toEqual('/foo'); }); }); describe('handle', () => { - it('should respond a 404 to GET /whatever on non existing collection', () => { + it('should respond a 404 to GET /whatever on non existing collection', async () => { const server = new SinonServer(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(404); // not responded }); - it('should respond to GET /foo by sending all items in collection foo', () => { + it('should respond to GET /foo by sending all items in collection foo', async () => { const server = new SinonServer(); server.addCollection( 'foo', @@ -288,7 +288,7 @@ describe('SinonServer', () => { ); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual( @@ -302,7 +302,7 @@ describe('SinonServer', () => { ); }); - it('should respond to GET /foo?queryString by sending all items in collection foo satisfying query', () => { + it('should respond to GET /foo?queryString by sending all items in collection foo satisfying query', async () => { const server = new SinonServer(); server.addCollection( 'foos', @@ -323,7 +323,7 @@ describe('SinonServer', () => { '/foos?filter={"arg":true}&sort=name&slice=[0,10]&embed=["bars"]', ); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual( @@ -337,7 +337,7 @@ describe('SinonServer', () => { ); }); - it('should respond to GET /foo?queryString with pagination by sending the correct content-range header', () => { + it('should respond to GET /foo?queryString with pagination by sending the correct content-range header', async () => { const server = new SinonServer(); server.addCollection( 'foo', @@ -348,40 +348,40 @@ describe('SinonServer', () => { let request: SinonFakeXMLHttpRequest | null; request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(200); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-10/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-4/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[5,9]'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 5-9/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[10,14]'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 10-10/11', ); }); - it('should respond to GET /foo on an empty collection with a []', () => { + it('should respond to GET /foo on an empty collection with a []', async () => { const server = new SinonServer(); server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('[]'); @@ -390,7 +390,7 @@ describe('SinonServer', () => { ); }); - it('should respond to POST /foo by adding an item to collection foo', () => { + it('should respond to POST /foo by adding an item to collection foo', async () => { const server = new SinonServer(); server.addCollection( 'foo', @@ -407,7 +407,7 @@ describe('SinonServer', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(201); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz","id":3}'); @@ -422,7 +422,7 @@ describe('SinonServer', () => { ]); }); - it('should respond to POST /foo by adding an item to collection foo, even if the collection does not exist', () => { + it('should respond to POST /foo by adding an item to collection foo, even if the collection does not exist', async () => { const server = new SinonServer(); const request = getFakeXMLHTTPRequest( 'POST', @@ -430,7 +430,7 @@ describe('SinonServer', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(201); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz","id":0}'); @@ -441,7 +441,7 @@ describe('SinonServer', () => { expect(server.getAll('foo')).toEqual([{ id: 0, name: 'baz' }]); }); - it('should respond to GET /foo/:id by sending element of identifier id in collection foo', () => { + it('should respond to GET /foo/:id by sending element of identifier id in collection foo', async () => { const server = new SinonServer(); server.addCollection( 'foo', @@ -454,7 +454,7 @@ describe('SinonServer', () => { ); const request = getFakeXMLHTTPRequest('GET', '/foo/2'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"bar"}'); @@ -463,16 +463,16 @@ describe('SinonServer', () => { ); }); - it('should respond to GET /foo/:id on a non-existing id with a 404', () => { + it('should respond to GET /foo/:id on a non-existing id with a 404', async () => { const server = new SinonServer(); server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo/3'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(404); }); - it('should respond to PUT /foo/:id by updating element of identifier id in collection foo', () => { + it('should respond to PUT /foo/:id by updating element of identifier id in collection foo', async () => { const server = new SinonServer(); server.addCollection( 'foo', @@ -489,7 +489,7 @@ describe('SinonServer', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"baz"}'); @@ -502,7 +502,7 @@ describe('SinonServer', () => { ]); }); - it('should respond to PUT /foo/:id on a non-existing id with a 404', () => { + it('should respond to PUT /foo/:id on a non-existing id with a 404', async () => { const server = new SinonServer(); server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest( @@ -511,11 +511,11 @@ describe('SinonServer', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(404); }); - it('should respond to PATCH /foo/:id by updating element of identifier id in collection foo', () => { + it('should respond to PATCH /foo/:id by updating element of identifier id in collection foo', async () => { const server = new SinonServer(); server.addCollection( 'foo', @@ -532,7 +532,7 @@ describe('SinonServer', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"baz"}'); @@ -545,7 +545,7 @@ describe('SinonServer', () => { ]); }); - it('should respond to PATCH /foo/:id on a non-existing id with a 404', () => { + it('should respond to PATCH /foo/:id on a non-existing id with a 404', async () => { const server = new SinonServer(); server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest( @@ -554,11 +554,11 @@ describe('SinonServer', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(404); }); - it('should respond to DELETE /foo/:id by removing element of identifier id in collection foo', () => { + it('should respond to DELETE /foo/:id by removing element of identifier id in collection foo', async () => { const server = new SinonServer(); server.addCollection( 'foo', @@ -571,7 +571,7 @@ describe('SinonServer', () => { ); const request = getFakeXMLHTTPRequest('DELETE', '/foo/2'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"bar"}'); @@ -581,22 +581,22 @@ describe('SinonServer', () => { expect(server.getAll('foo')).toEqual([{ id: 1, name: 'foo' }]); }); - it('should respond to DELETE /foo/:id on a non-existing id with a 404', () => { + it('should respond to DELETE /foo/:id on a non-existing id with a 404', async () => { const server = new SinonServer(); server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest('DELETE', '/foo/3'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(404); }); - it('should respond to GET /foo/ with single item', () => { + it('should respond to GET /foo/ with single item', async () => { const server = new SinonServer(); server.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"name":"foo"}'); @@ -605,7 +605,7 @@ describe('SinonServer', () => { ); }); - it('should respond to PUT /foo/ by updating the singleton record', () => { + it('should respond to PUT /foo/ by updating the singleton record', async () => { const server = new SinonServer(); server.addSingle('foo', new Single({ name: 'foo' })); @@ -615,7 +615,7 @@ describe('SinonServer', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz"}'); @@ -625,7 +625,7 @@ describe('SinonServer', () => { expect(server.getOnly('foo')).toEqual({ name: 'baz' }); }); - it('should respond to PATCH /foo/ by updating the singleton record', () => { + it('should respond to PATCH /foo/ by updating the singleton record', async () => { const server = new SinonServer(); server.addSingle('foo', new Single({ name: 'foo' })); @@ -635,7 +635,7 @@ describe('SinonServer', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz"}'); @@ -647,7 +647,7 @@ describe('SinonServer', () => { }); describe('setDefaultQuery', () => { - it('should set the default query string', () => { + it('should set the default query string', async () => { const server = new SinonServer(); server.addCollection( 'foo', @@ -660,7 +660,7 @@ describe('SinonServer', () => { }); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 2-4/10', @@ -670,7 +670,7 @@ describe('SinonServer', () => { expect(request.responseText).toEqual(JSON.stringify(expected)); }); - it('should not override any provided query string', () => { + it('should not override any provided query string', async () => { const server = new SinonServer(); server.addCollection( 'foo', @@ -681,7 +681,7 @@ describe('SinonServer', () => { server.setDefaultQuery((name) => ({ range: [2, 4] })); const request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]'); if (request == null) throw new Error('request is null'); - server.handleSync(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-4/10', diff --git a/src/adapters/SinonServer.ts b/src/adapters/SinonServer.ts index d252cbb..f07d8fc 100644 --- a/src/adapters/SinonServer.ts +++ b/src/adapters/SinonServer.ts @@ -7,7 +7,7 @@ export class SinonServer extends BaseServer< SinonFakeXMLHttpRequest, SinonFakeRestResponse > { - extractContextSync(request: SinonFakeXMLHttpRequest) { + async extractContext(request: SinonFakeXMLHttpRequest) { const req: Request | SinonFakeXMLHttpRequest = typeof request === 'string' ? new Request(request) : request; @@ -34,7 +34,7 @@ export class SinonServer extends BaseServer< }; } - respondSync(response: BaseResponse, request: SinonFakeXMLHttpRequest) { + async respond(response: BaseResponse, request: SinonFakeXMLHttpRequest) { const sinonResponse = { status: response.status, body: response.body ?? '', @@ -62,7 +62,7 @@ export class SinonServer extends BaseServer< } // This is an internal property of SinonFakeXMLHttpRequest but we have to reset it to 1 - // to allow the request to be resolved by Sinon. + // to handle the request asynchronously. // See https://github.com/sinonjs/sinon/issues/637 // @ts-expect-error request.readyState = 1; @@ -120,8 +120,15 @@ export class SinonServer extends BaseServer< getHandler() { return (request: SinonFakeXMLHttpRequest) => { - const result = this.handleSync(request); - return result; + // This is an internal property of SinonFakeXMLHttpRequest but we have to set it to 4 to + // suppress sinon's synchronous processing (which would result in HTTP 404). This allows us + // to handle the request asynchronously. + // See https://github.com/sinonjs/sinon/issues/637 + // @ts-expect-error + request.readyState = 4; + this.handle(request); + // Let Sinon know we've handled the request + return true; }; } } From c59cce2a6f0d87d1d989030afc7fcc67ab67cf2e Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 16:04:53 +0200 Subject: [PATCH 21/33] Reorganize tests --- src/AbstractBaseServer.spec.ts | 152 +++++++++++++++++++++++++++++++ src/AbstractBaseServer.ts | 2 +- src/BaseServer.ts | 5 +- src/adapters/SinonServer.spec.ts | 145 ----------------------------- 4 files changed, 154 insertions(+), 150 deletions(-) create mode 100644 src/AbstractBaseServer.spec.ts diff --git a/src/AbstractBaseServer.spec.ts b/src/AbstractBaseServer.spec.ts new file mode 100644 index 0000000..534a9e9 --- /dev/null +++ b/src/AbstractBaseServer.spec.ts @@ -0,0 +1,152 @@ +import sinon, { type SinonFakeXMLHttpRequest } from 'sinon'; + +import { AbstractBaseServer } from './AbstractBaseServer.js'; +import { Single } from './Single.js'; +import { Collection } from './Collection.js'; + +describe('AbstractBaseServer', () => { + describe('init', () => { + it('should populate several collections', () => { + const server = new AbstractBaseServer(); + server.init({ + foo: [{ a: 1 }, { a: 2 }, { a: 3 }], + bar: [{ b: true }, { b: false }], + baz: { name: 'baz' }, + }); + expect(server.getAll('foo')).toEqual([ + { id: 0, a: 1 }, + { id: 1, a: 2 }, + { id: 2, a: 3 }, + ]); + expect(server.getAll('bar')).toEqual([ + { id: 0, b: true }, + { id: 1, b: false }, + ]); + expect(server.getOnly('baz')).toEqual({ name: 'baz' }); + }); + }); + + describe('addCollection', () => { + it('should add a collection and index it by name', () => { + const server = new AbstractBaseServer(); + const collection = new Collection({ + items: [ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' }, + ], + }); + server.addCollection('foo', collection); + const newcollection = server.getCollection('foo'); + expect(newcollection).toEqual(collection); + }); + }); + + describe('addSingle', () => { + it('should add a single object and index it by name', () => { + const server = new AbstractBaseServer(); + const single = new Single({ name: 'foo', description: 'bar' }); + server.addSingle('foo', single); + expect(server.getSingle('foo')).toEqual(single); + }); + }); + + describe('getAll', () => { + it('should return all items for a given name', () => { + const server = new AbstractBaseServer(); + server.addCollection( + 'foo', + new Collection({ + items: [ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' }, + ], + }), + ); + server.addCollection( + 'baz', + new Collection({ items: [{ id: 1, name: 'baz' }] }), + ); + expect(server.getAll('foo')).toEqual([ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' }, + ]); + expect(server.getAll('baz')).toEqual([{ id: 1, name: 'baz' }]); + }); + + it('should support a query', () => { + const server = new AbstractBaseServer(); + server.addCollection( + 'foo', + new Collection({ + items: [ + { id: 0, name: 'c', arg: false }, + { id: 1, name: 'b', arg: true }, + { id: 2, name: 'a', arg: true }, + ], + }), + ); + const params = { + filter: { arg: true }, + sort: 'name', + slice: [0, 10], + }; + const expected = [ + { id: 2, name: 'a', arg: true }, + { id: 1, name: 'b', arg: true }, + ]; + expect(server.getAll('foo', params)).toEqual(expected); + }); + }); + + describe('getOne', () => { + it('should return an error when no collection match the identifier', () => { + const server = new AbstractBaseServer(); + server.addCollection( + 'foo', + new Collection({ items: [{ id: 1, name: 'foo' }] }), + ); + expect(() => { + server.getOne('foo', 2); + }).toThrow(new Error('No item with identifier 2')); + }); + + it('should return the first collection matching the identifier', () => { + const server = new AbstractBaseServer(); + server.addCollection( + 'foo', + new Collection({ + items: [ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' }, + ], + }), + ); + expect(server.getOne('foo', 1)).toEqual({ id: 1, name: 'foo' }); + expect(server.getOne('foo', 2)).toEqual({ id: 2, name: 'bar' }); + }); + + it('should use the identifierName', () => { + const server = new AbstractBaseServer(); + server.addCollection( + 'foo', + new Collection({ + items: [ + { _id: 1, name: 'foo' }, + { _id: 2, name: 'bar' }, + ], + identifierName: '_id', + }), + ); + expect(server.getOne('foo', 1)).toEqual({ _id: 1, name: 'foo' }); + expect(server.getOne('foo', 2)).toEqual({ _id: 2, name: 'bar' }); + }); + }); + + describe('getOnly', () => { + it('should return the single matching the identifier', () => { + const server = new AbstractBaseServer(); + server.addSingle('foo', new Single({ name: 'foo' })); + expect(server.getOnly('foo')).toEqual({ name: 'foo' }); + }); + }); +}); diff --git a/src/AbstractBaseServer.ts b/src/AbstractBaseServer.ts index 9e8e41f..93725e2 100644 --- a/src/AbstractBaseServer.ts +++ b/src/AbstractBaseServer.ts @@ -8,7 +8,7 @@ import type { CollectionItem, Query, QueryFunction } from './types.js'; * The BaseServerWithMiddlewares class is the one that needs to have generic parameters which are * provided by the mocking implementation server classes. */ -export abstract class AbstractBaseServer { +export class AbstractBaseServer { baseUrl = ''; identifierName = 'id'; loggingEnabled = false; diff --git a/src/BaseServer.ts b/src/BaseServer.ts index bea0f7b..6d169e9 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -4,10 +4,7 @@ import { AbstractBaseServer, } from './AbstractBaseServer.js'; -export abstract class BaseServer< - RequestType, - ResponseType, -> extends AbstractBaseServer { +export class BaseServer extends AbstractBaseServer { middlewares: Array> = []; extractContext( diff --git a/src/adapters/SinonServer.spec.ts b/src/adapters/SinonServer.spec.ts index 1114164..c933412 100644 --- a/src/adapters/SinonServer.spec.ts +++ b/src/adapters/SinonServer.spec.ts @@ -23,151 +23,6 @@ function getFakeXMLHTTPRequest( } describe('SinonServer', () => { - describe('init', () => { - it('should populate several collections', () => { - const server = new SinonServer(); - server.init({ - foo: [{ a: 1 }, { a: 2 }, { a: 3 }], - bar: [{ b: true }, { b: false }], - baz: { name: 'baz' }, - }); - expect(server.getAll('foo')).toEqual([ - { id: 0, a: 1 }, - { id: 1, a: 2 }, - { id: 2, a: 3 }, - ]); - expect(server.getAll('bar')).toEqual([ - { id: 0, b: true }, - { id: 1, b: false }, - ]); - expect(server.getOnly('baz')).toEqual({ name: 'baz' }); - }); - }); - - describe('addCollection', () => { - it('should add a collection and index it by name', () => { - const server = new SinonServer(); - const collection = new Collection({ - items: [ - { id: 1, name: 'foo' }, - { id: 2, name: 'bar' }, - ], - }); - server.addCollection('foo', collection); - const newcollection = server.getCollection('foo'); - expect(newcollection).toEqual(collection); - }); - }); - - describe('addSingle', () => { - it('should add a single object and index it by name', () => { - const server = new SinonServer(); - const single = new Single({ name: 'foo', description: 'bar' }); - server.addSingle('foo', single); - expect(server.getSingle('foo')).toEqual(single); - }); - }); - - describe('getAll', () => { - it('should return all items for a given name', () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ - { id: 1, name: 'foo' }, - { id: 2, name: 'bar' }, - ], - }), - ); - server.addCollection( - 'baz', - new Collection({ items: [{ id: 1, name: 'baz' }] }), - ); - expect(server.getAll('foo')).toEqual([ - { id: 1, name: 'foo' }, - { id: 2, name: 'bar' }, - ]); - expect(server.getAll('baz')).toEqual([{ id: 1, name: 'baz' }]); - }); - - it('should support a query', () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ - { id: 0, name: 'c', arg: false }, - { id: 1, name: 'b', arg: true }, - { id: 2, name: 'a', arg: true }, - ], - }), - ); - const params = { - filter: { arg: true }, - sort: 'name', - slice: [0, 10], - }; - const expected = [ - { id: 2, name: 'a', arg: true }, - { id: 1, name: 'b', arg: true }, - ]; - expect(server.getAll('foo', params)).toEqual(expected); - }); - }); - - describe('getOne', () => { - it('should return an error when no collection match the identifier', () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ items: [{ id: 1, name: 'foo' }] }), - ); - expect(() => { - server.getOne('foo', 2); - }).toThrow(new Error('No item with identifier 2')); - }); - - it('should return the first collection matching the identifier', () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ - { id: 1, name: 'foo' }, - { id: 2, name: 'bar' }, - ], - }), - ); - expect(server.getOne('foo', 1)).toEqual({ id: 1, name: 'foo' }); - expect(server.getOne('foo', 2)).toEqual({ id: 2, name: 'bar' }); - }); - - it('should use the identifierName', () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ - { _id: 1, name: 'foo' }, - { _id: 2, name: 'bar' }, - ], - identifierName: '_id', - }), - ); - expect(server.getOne('foo', 1)).toEqual({ _id: 1, name: 'foo' }); - expect(server.getOne('foo', 2)).toEqual({ _id: 2, name: 'bar' }); - }); - }); - - describe('getOnly', () => { - it('should return the single matching the identifier', () => { - const server = new SinonServer(); - server.addSingle('foo', new Single({ name: 'foo' })); - expect(server.getOnly('foo')).toEqual({ name: 'foo' }); - }); - }); - describe('addMiddleware', () => { it('should allow request transformation', async () => { const server = new SinonServer(); From 7e883eb476715789280c49d5b17fffd891225440 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 16:29:18 +0200 Subject: [PATCH 22/33] Apply review suggestions --- UPGRADE.md | 38 +++++-- example/msw.ts | 5 +- src/BaseServer.ts | 102 ++++++++++++++++-- src/Collection.ts | 6 +- ...actBaseServer.spec.ts => Database.spec.ts} | 22 ++-- src/{AbstractBaseServer.ts => Database.ts} | 94 +--------------- src/Single.ts | 6 +- src/adapters/FetchMockServer.ts | 36 +++++-- src/adapters/MswServer.ts | 48 +++++++++ src/adapters/SinonServer.spec.ts | 2 +- src/adapters/SinonServer.ts | 29 ++++- src/adapters/msw.ts | 76 ------------- src/{FakeRest.ts => index.ts} | 6 +- vite.config.min.ts | 2 +- vite.config.ts | 2 +- 15 files changed, 251 insertions(+), 223 deletions(-) rename src/{AbstractBaseServer.spec.ts => Database.spec.ts} (88%) rename src/{AbstractBaseServer.ts => Database.ts} (60%) create mode 100644 src/adapters/MswServer.ts delete mode 100644 src/adapters/msw.ts rename src/{FakeRest.ts => index.ts} (87%) diff --git a/UPGRADE.md b/UPGRADE.md index 392a91d..a804d3a 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,26 +1,48 @@ # Upgrading to 4.0.0 -## Constructors Of `FetchServer` and `Server` Take An Object +## Renamed `Server` And `FetchServer` -For `Server`: +The `Server` class has been renamed to `SinonServer`. ```diff -import { Server } from 'fakerest'; -import { data } from './data'; +-import { Server } from 'fakerest'; ++import { SinonServer } from 'fakerest'; -const server = new Server('http://myapi.com'); -+const server = new Server({ baseUrl: 'http://myapi.com' }); ++const server = new SinonServer({ baseUrl: 'http://myapi.com' }); +``` + +The `FetchServer` class has been renamed to `FetchMockServer`. + +```diff +-import { FetchServer } from 'fakerest'; ++import { FetchMockServer } from 'fakerest'; + +-const server = new FetchServer('http://myapi.com'); ++const server = new FetchMockServer({ baseUrl: 'http://myapi.com' }); +``` + +## Constructors Of `SinonServer` and `FetchMockServer` Take An Object + +For `SinonServer`: + +```diff +import { SinonServer } from 'fakerest'; +import { data } from './data'; + +-const server = new SinonServer('http://myapi.com'); ++const server = new SinonServer({ baseUrl: 'http://myapi.com' }); server.init(data); ``` For `FetchServer`: ```diff -import { FetchServer } from 'fakerest'; +import { FetchMockServer } from 'fakerest'; import { data } from './data'; --const server = new FetchServer('http://myapi.com'); -+const server = new FetchServer({ baseUrl: 'http://myapi.com' }); +-const server = new FetchMockServer('http://myapi.com'); ++const server = new FetchMockServer({ baseUrl: 'http://myapi.com' }); server.init(data); ``` diff --git a/example/msw.ts b/example/msw.ts index bef2b18..d22ae47 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -1,6 +1,5 @@ import { setupWorker } from 'msw/browser'; -import { HttpResponse } from 'msw'; -import { MswServer, withDelay } from '../src/FakeRest'; +import { MswServer, withDelay } from '../src'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; @@ -43,6 +42,6 @@ restServer.addMiddleware(async (request, context, next) => { return next(request, context); }); -export const worker = setupWorker(...restServer.getHandlers()); +export const worker = setupWorker(restServer.getHandler()); export const dataProvider = defaultDataProvider; diff --git a/src/BaseServer.ts b/src/BaseServer.ts index 6d169e9..61a62d2 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -1,13 +1,67 @@ -import { - type BaseResponse, - type FakeRestContext, - AbstractBaseServer, -} from './AbstractBaseServer.js'; +import { Database, type DatabaseOptions } from './Database.js'; +import type { QueryFunction } from './types.js'; -export class BaseServer extends AbstractBaseServer { +export class BaseServer extends Database { + baseUrl = ''; + defaultQuery: QueryFunction = () => ({}); middlewares: Array> = []; - extractContext( + constructor({ + baseUrl = '', + defaultQuery = () => ({}), + ...options + }: BaseServerOptions = {}) { + super(options); + this.baseUrl = baseUrl; + this.defaultQuery = defaultQuery; + } + + /** + * @param Function ResourceName => object + */ + setDefaultQuery(query: QueryFunction) { + this.defaultQuery = query; + } + + getContext( + context: Pick< + FakeRestContext, + 'url' | 'method' | 'params' | 'requestJson' + >, + ): FakeRestContext { + for (const name of this.getSingleNames()) { + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), + ); + if (!matches) continue; + return { + ...context, + single: name, + }; + } + + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), + ); + if (matches) { + const name = matches[1]; + const params = Object.assign( + {}, + this.defaultQuery(name), + context.params, + ); + + return { + ...context, + collection: name, + params, + }; + } + + return context; + } + + getNormalizedRequest( request: RequestType, ): Promise< Pick @@ -24,7 +78,9 @@ export class BaseServer extends AbstractBaseServer { } async handle(request: RequestType): Promise { - const context = this.getContext(await this.extractContext(request)); + const context = this.getContext( + await this.getNormalizedRequest(request), + ); // Call middlewares let index = 0; @@ -294,3 +350,33 @@ export type Middleware = ( ctx: FakeRestContext, ) => Promise | BaseResponse | null, ) => Promise | BaseResponse | null; + +export type BaseServerOptions = DatabaseOptions & { + baseUrl?: string; + batchUrl?: string | null; + defaultQuery?: QueryFunction; +}; + +export type BaseRequest = { + url?: string; + method?: string; + collection?: string; + single?: string; + requestJson?: Record | undefined; + params?: { [key: string]: any }; +}; + +export type BaseResponse = { + status: number; + body?: Record | Record[]; + headers: { [key: string]: string }; +}; + +export type FakeRestContext = { + url?: string; + method?: string; + collection?: string; + single?: string; + requestJson: Record | undefined; + params: { [key: string]: any }; +}; diff --git a/src/Collection.ts b/src/Collection.ts index 08edbc2..203466e 100644 --- a/src/Collection.ts +++ b/src/Collection.ts @@ -1,6 +1,6 @@ import get from 'lodash/get.js'; import matches from 'lodash/matches.js'; -import type { AbstractBaseServer } from './AbstractBaseServer.js'; +import type { Database } from './Database.js'; import type { CollectionItem, Embed, @@ -14,7 +14,7 @@ import type { export class Collection { sequence = 0; items: T[] = []; - server: AbstractBaseServer | null = null; + server: Database | null = null; name: string | null = null; identifierName = 'id'; getNewId: () => number | string; @@ -42,7 +42,7 @@ export class Collection { * A Collection may need to access other collections (e.g. for embedding references) * This is done through a reference to the parent server. */ - setServer(server: AbstractBaseServer) { + setServer(server: Database) { this.server = server; } diff --git a/src/AbstractBaseServer.spec.ts b/src/Database.spec.ts similarity index 88% rename from src/AbstractBaseServer.spec.ts rename to src/Database.spec.ts index 534a9e9..ae500e0 100644 --- a/src/AbstractBaseServer.spec.ts +++ b/src/Database.spec.ts @@ -1,13 +1,11 @@ -import sinon, { type SinonFakeXMLHttpRequest } from 'sinon'; - -import { AbstractBaseServer } from './AbstractBaseServer.js'; +import { Database } from './Database.js'; import { Single } from './Single.js'; import { Collection } from './Collection.js'; describe('AbstractBaseServer', () => { describe('init', () => { it('should populate several collections', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.init({ foo: [{ a: 1 }, { a: 2 }, { a: 3 }], bar: [{ b: true }, { b: false }], @@ -28,7 +26,7 @@ describe('AbstractBaseServer', () => { describe('addCollection', () => { it('should add a collection and index it by name', () => { - const server = new AbstractBaseServer(); + const server = new Database(); const collection = new Collection({ items: [ { id: 1, name: 'foo' }, @@ -43,7 +41,7 @@ describe('AbstractBaseServer', () => { describe('addSingle', () => { it('should add a single object and index it by name', () => { - const server = new AbstractBaseServer(); + const server = new Database(); const single = new Single({ name: 'foo', description: 'bar' }); server.addSingle('foo', single); expect(server.getSingle('foo')).toEqual(single); @@ -52,7 +50,7 @@ describe('AbstractBaseServer', () => { describe('getAll', () => { it('should return all items for a given name', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.addCollection( 'foo', new Collection({ @@ -74,7 +72,7 @@ describe('AbstractBaseServer', () => { }); it('should support a query', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.addCollection( 'foo', new Collection({ @@ -100,7 +98,7 @@ describe('AbstractBaseServer', () => { describe('getOne', () => { it('should return an error when no collection match the identifier', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.addCollection( 'foo', new Collection({ items: [{ id: 1, name: 'foo' }] }), @@ -111,7 +109,7 @@ describe('AbstractBaseServer', () => { }); it('should return the first collection matching the identifier', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.addCollection( 'foo', new Collection({ @@ -126,7 +124,7 @@ describe('AbstractBaseServer', () => { }); it('should use the identifierName', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.addCollection( 'foo', new Collection({ @@ -144,7 +142,7 @@ describe('AbstractBaseServer', () => { describe('getOnly', () => { it('should return the single matching the identifier', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.addSingle('foo', new Single({ name: 'foo' })); expect(server.getOnly('foo')).toEqual({ name: 'foo' }); }); diff --git a/src/AbstractBaseServer.ts b/src/Database.ts similarity index 60% rename from src/AbstractBaseServer.ts rename to src/Database.ts index 93725e2..a1cd19e 100644 --- a/src/AbstractBaseServer.ts +++ b/src/Database.ts @@ -2,34 +2,19 @@ import { Collection } from './Collection.js'; import { Single } from './Single.js'; import type { CollectionItem, Query, QueryFunction } from './types.js'; -/** - * This base class does not need generics so we can reference it in Collection and Single - * without having to propagate mocking implementation generics nor requiring the user to specify them. - * The BaseServerWithMiddlewares class is the one that needs to have generic parameters which are - * provided by the mocking implementation server classes. - */ -export class AbstractBaseServer { - baseUrl = ''; +export class Database { identifierName = 'id'; - loggingEnabled = false; - defaultQuery: QueryFunction = () => ({}); collections: Record> = {}; singles: Record> = {}; getNewId?: () => number | string; constructor({ - baseUrl = '', data, - defaultQuery = () => ({}), identifierName = 'id', getNewId, - loggingEnabled = false, - }: BaseServerOptions = {}) { - this.baseUrl = baseUrl; + }: DatabaseOptions = {}) { this.getNewId = getNewId; - this.loggingEnabled = loggingEnabled; this.identifierName = identifierName; - this.defaultQuery = defaultQuery; if (data) { this.init(data); @@ -57,17 +42,6 @@ export class AbstractBaseServer { } } - toggleLogging() { - this.loggingEnabled = !this.loggingEnabled; - } - - /** - * @param Function ResourceName => object - */ - setDefaultQuery(query: QueryFunction) { - this.defaultQuery = query; - } - addCollection( name: string, collection: Collection, @@ -151,47 +125,9 @@ export class AbstractBaseServer { updateOnly(name: string, item: CollectionItem) { return this.singles[name].updateOnly(item); } - - getContext( - context: Pick< - FakeRestContext, - 'url' | 'method' | 'params' | 'requestJson' - >, - ): FakeRestContext { - for (const name of this.getSingleNames()) { - const matches = context.url?.match( - new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), - ); - if (!matches) continue; - return { - ...context, - single: name, - }; - } - - const matches = context.url?.match( - new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), - ); - if (matches) { - const name = matches[1]; - const params = Object.assign( - {}, - this.defaultQuery(name), - context.params, - ); - - return { - ...context, - collection: name, - params, - }; - } - - return context; - } } -export type BaseServerOptions = { +export type DatabaseOptions = { baseUrl?: string; batchUrl?: string | null; data?: Record; @@ -200,27 +136,3 @@ export type BaseServerOptions = { getNewId?: () => number | string; loggingEnabled?: boolean; }; - -export type BaseRequest = { - url?: string; - method?: string; - collection?: string; - single?: string; - requestJson?: Record | undefined; - params?: { [key: string]: any }; -}; - -export type BaseResponse = { - status: number; - body?: Record | Record[]; - headers: { [key: string]: string }; -}; - -export type FakeRestContext = { - url?: string; - method?: string; - collection?: string; - single?: string; - requestJson: Record | undefined; - params: { [key: string]: any }; -}; diff --git a/src/Single.ts b/src/Single.ts index 5dbb320..2d5a87b 100644 --- a/src/Single.ts +++ b/src/Single.ts @@ -1,9 +1,9 @@ -import type { AbstractBaseServer } from './AbstractBaseServer.js'; +import type { Database } from './Database.js'; import type { CollectionItem, Embed, Query } from './types.js'; export class Single { obj: T | null = null; - server: AbstractBaseServer | null = null; + server: Database | null = null; name: string | null = null; constructor(obj: T) { @@ -19,7 +19,7 @@ export class Single { * A Single may need to access other collections (e.g. for embedded * references) This is done through a reference to the parent server. */ - setServer(server: AbstractBaseServer) { + setServer(server: Database) { this.server = server; } diff --git a/src/adapters/FetchMockServer.ts b/src/adapters/FetchMockServer.ts index 3af5a9d..6e9f11b 100644 --- a/src/adapters/FetchMockServer.ts +++ b/src/adapters/FetchMockServer.ts @@ -1,14 +1,28 @@ -import type { MockResponseObject, MockMatcherFunction } from 'fetch-mock'; -import { BaseServer } from '../BaseServer.js'; -import type { - BaseResponse, - BaseServerOptions, - FakeRestContext, -} from '../AbstractBaseServer.js'; +import type { MockResponseObject } from 'fetch-mock'; +import { + type BaseResponse, + BaseServer, + type FakeRestContext, + type BaseServerOptions, +} from '../BaseServer.js'; import { parseQueryString } from '../parseQueryString.js'; export class FetchMockServer extends BaseServer { - async extractContext(request: Request) { + loggingEnabled = false; + + constructor({ + loggingEnabled = false, + ...options + }: FetchMockServerOptions = {}) { + super(options); + this.loggingEnabled = loggingEnabled; + } + + toggleLogging() { + this.loggingEnabled = !this.loggingEnabled; + } + + async getNormalizedRequest(request: Request) { const req = typeof request === 'string' ? new Request(request) : request; const queryString = req.url @@ -89,7 +103,7 @@ export class FetchMockServer extends BaseServer { } } -export const getFetchMockHandler = (options: BaseServerOptions) => { +export const getFetchMockHandler = (options: FetchMockServerOptions) => { const server = new FetchMockServer(options); return server.getHandler(); }; @@ -106,3 +120,7 @@ export type FetchMockFakeRestRequest = Partial & { queryString?: string; params?: { [key: string]: any }; }; + +export type FetchMockServerOptions = BaseServerOptions & { + loggingEnabled?: boolean; +}; diff --git a/src/adapters/MswServer.ts b/src/adapters/MswServer.ts new file mode 100644 index 0000000..916e844 --- /dev/null +++ b/src/adapters/MswServer.ts @@ -0,0 +1,48 @@ +import { http, HttpResponse } from 'msw'; +import { type BaseResponse, BaseServer } from '../BaseServer.js'; +import type { DatabaseOptions } from '../Database.js'; + +export class MswServer extends BaseServer { + async respond(response: BaseResponse) { + return HttpResponse.json(response.body, { + status: response.status, + headers: response.headers, + }); + } + + async getNormalizedRequest(request: Request) { + const url = new URL(request.url); + const params = Object.fromEntries( + Array.from(new URLSearchParams(url.search).entries()).map( + ([key, value]) => [key, JSON.parse(value)], + ), + ); + let requestJson: Record | undefined = undefined; + try { + const text = await request.text(); + requestJson = JSON.parse(text); + } catch (e) { + // not JSON, no big deal + } + + return { + url: request.url, + params, + requestJson, + method: request.method, + }; + } + + getHandler() { + return http.all( + // Using a regex ensures we match all URLs that start with the collection name + new RegExp(`${this.baseUrl}`), + ({ request }) => this.handle(request), + ); + } +} + +export const getMswHandler = (options: DatabaseOptions) => { + const server = new MswServer(options); + return server.getHandler(); +}; diff --git a/src/adapters/SinonServer.spec.ts b/src/adapters/SinonServer.spec.ts index c933412..d433c7e 100644 --- a/src/adapters/SinonServer.spec.ts +++ b/src/adapters/SinonServer.spec.ts @@ -3,7 +3,7 @@ import sinon, { type SinonFakeXMLHttpRequest } from 'sinon'; import { SinonServer } from './SinonServer.js'; import { Single } from '../Single.js'; import { Collection } from '../Collection.js'; -import type { BaseResponse } from '../AbstractBaseServer.js'; +import type { BaseResponse } from '../BaseServer.js'; function getFakeXMLHTTPRequest( method: string, diff --git a/src/adapters/SinonServer.ts b/src/adapters/SinonServer.ts index f07d8fc..51a8cc4 100644 --- a/src/adapters/SinonServer.ts +++ b/src/adapters/SinonServer.ts @@ -1,13 +1,30 @@ import type { SinonFakeXMLHttpRequest } from 'sinon'; -import { BaseServer } from '../BaseServer.js'; +import { + type BaseResponse, + BaseServer, + type BaseServerOptions, +} from '../BaseServer.js'; import { parseQueryString } from '../parseQueryString.js'; -import type { BaseResponse, BaseServerOptions } from '../AbstractBaseServer.js'; export class SinonServer extends BaseServer< SinonFakeXMLHttpRequest, SinonFakeRestResponse > { - async extractContext(request: SinonFakeXMLHttpRequest) { + loggingEnabled = false; + + constructor({ + loggingEnabled = false, + ...options + }: SinonServerOptions = {}) { + super(options); + this.loggingEnabled = loggingEnabled; + } + + toggleLogging() { + this.loggingEnabled = !this.loggingEnabled; + } + + async getNormalizedRequest(request: SinonFakeXMLHttpRequest) { const req: Request | SinonFakeXMLHttpRequest = typeof request === 'string' ? new Request(request) : request; @@ -133,7 +150,7 @@ export class SinonServer extends BaseServer< } } -export const getSinonHandler = (options: BaseServerOptions) => { +export const getSinonHandler = (options: SinonServerOptions) => { const server = new SinonServer(options); return server.getHandler(); }; @@ -148,3 +165,7 @@ export type SinonFakeRestResponse = { body: any; headers: Record; }; + +export type SinonServerOptions = BaseServerOptions & { + loggingEnabled?: boolean; +}; diff --git a/src/adapters/msw.ts b/src/adapters/msw.ts deleted file mode 100644 index 5671314..0000000 --- a/src/adapters/msw.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { http, HttpResponse } from 'msw'; -import { BaseServer } from '../BaseServer.js'; -import type { - BaseRequest, - BaseResponse, - BaseServerOptions, -} from '../AbstractBaseServer.js'; - -export class MswServer extends BaseServer { - async respond(response: BaseResponse) { - return HttpResponse.json(response.body, { - status: response.status, - headers: response.headers, - }); - } - - async extractContext(request: Request) { - const url = new URL(request.url); - const params = Object.fromEntries( - Array.from(new URLSearchParams(url.search).entries()).map( - ([key, value]) => [key, JSON.parse(value)], - ), - ); - let requestJson: Record | undefined = undefined; - try { - const text = await request.text(); - requestJson = JSON.parse(text); - } catch (e) { - // not JSON, no big deal - } - - const req: MswFakeRestRequest = request; - req.requestJson = requestJson; - req.params = params; - - return { - url: request.url, - params, - requestJson, - method: request.method, - }; - } - - getHandlers() { - return Object.keys(this.collections).map((collectionName) => - getCollectionHandlers({ - baseUrl: this.baseUrl, - collectionName, - server: this, - }), - ); - } -} - -export const getMswHandlers = (options: BaseServerOptions) => { - const server = new MswServer(options); - return server.getHandlers(); -}; - -const getCollectionHandlers = ({ - baseUrl, - collectionName, - server, -}: { - baseUrl: string; - collectionName: string; - server: MswServer; -}) => { - return http.all( - // Using a regex ensures we match all URLs that start with the collection name - new RegExp(`${baseUrl}/${collectionName}`), - ({ request }) => server.handle(request), - ); -}; - -export type MswFakeRestRequest = Partial & BaseRequest; diff --git a/src/FakeRest.ts b/src/index.ts similarity index 87% rename from src/FakeRest.ts rename to src/index.ts index 956c2e3..34b54ad 100644 --- a/src/FakeRest.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { FetchServer, FetchMockServer, } from './adapters/FetchMockServer.js'; -import { getMswHandlers, MswServer } from './adapters/msw.js'; +import { getMswHandler, MswServer } from './adapters/MswServer.js'; import { Collection } from './Collection.js'; import { Single } from './Single.js'; import { withDelay } from './withDelay.js'; @@ -16,7 +16,7 @@ import { withDelay } from './withDelay.js'; export { getSinonHandler, getFetchMockHandler, - getMswHandlers, + getMswHandler, Server, SinonServer, FetchServer, @@ -30,7 +30,7 @@ export { export default { getSinonHandler, getFetchMockHandler, - getMswHandlers, + getMswHandler, Server, SinonServer, FetchServer, diff --git a/vite.config.min.ts b/vite.config.min.ts index 86a8b0e..7a36c0b 100644 --- a/vite.config.min.ts +++ b/vite.config.min.ts @@ -6,7 +6,7 @@ export default defineConfig({ build: { lib: { // Could also be a dictionary or array of multiple entry points - entry: resolve(__dirname, 'src/FakeRest.ts'), + entry: resolve(__dirname, 'src/index.ts'), name: 'FakeRest', // the proper extensions will be added fileName: 'fakerest.min', diff --git a/vite.config.ts b/vite.config.ts index 1eb13f7..9ac7305 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ build: { lib: { // Could also be a dictionary or array of multiple entry points - entry: resolve(__dirname, 'src/FakeRest.ts'), + entry: resolve(__dirname, 'src/index.ts'), name: 'FakeRest', // the proper extensions will be added fileName: 'fakerest', From 0007f7b5900a37cad57624294d5483fdc288dcc6 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 16:36:46 +0200 Subject: [PATCH 23/33] Rename requestJson to requestBody --- example/fetchMock.ts | 6 +++--- example/msw.ts | 2 +- example/sinon.ts | 6 +++--- src/BaseServer.ts | 35 ++++++++++++--------------------- src/adapters/FetchMockServer.ts | 6 +++--- src/adapters/MswServer.ts | 6 +++--- src/adapters/SinonServer.ts | 6 +++--- 7 files changed, 29 insertions(+), 38 deletions(-) diff --git a/example/fetchMock.ts b/example/fetchMock.ts index 4fe1cca..1ce77da 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -1,5 +1,5 @@ import fetchMock from 'fetch-mock'; -import { FetchMockServer, withDelay } from 'fakerest'; +import { FetchMockServer, withDelay } from '../src'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; @@ -17,7 +17,7 @@ export const initializeFetchMock = () => { restServer.addMiddleware(withDelay(300)); restServer.addMiddleware(async (request, context, next) => { if (!request.headers?.get('Authorization')) { - return new Response(null, { status: 401 }); + throw new Response(null, { status: 401 }); } return next(request, context); }); @@ -26,7 +26,7 @@ export const initializeFetchMock = () => { if ( restServer.collections[context.collection].getCount({ filter: { - title: context.requestJson?.title, + title: context.requestBody?.title, }, }) > 0 ) { diff --git a/example/msw.ts b/example/msw.ts index d22ae47..63cdc31 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -21,7 +21,7 @@ restServer.addMiddleware(async (request, context, next) => { if ( restServer.collections[context.collection].getCount({ filter: { - title: context.requestJson?.title, + title: context.requestBody?.title, }, }) > 0 ) { diff --git a/example/sinon.ts b/example/sinon.ts index 9031490..9f598a3 100644 --- a/example/sinon.ts +++ b/example/sinon.ts @@ -1,5 +1,5 @@ import sinon from 'sinon'; -import { SinonServer, withDelay } from '../src/FakeRest'; +import { SinonServer, withDelay } from '../src'; import { data } from './data'; import { HttpError, type Options } from 'react-admin'; import simpleRestProvider from 'ra-data-simple-rest'; @@ -11,7 +11,7 @@ export const initializeSinon = () => { loggingEnabled: true, }); - restServer.addMiddleware(withDelay(3000)); + restServer.addMiddleware(withDelay(300)); restServer.addMiddleware(async (request, context, next) => { if (request.requestHeaders.Authorization === undefined) { return { @@ -28,7 +28,7 @@ export const initializeSinon = () => { if ( restServer.collections[context.collection].getCount({ filter: { - title: context.requestJson?.title, + title: context.requestBody?.title, }, }) > 0 ) { diff --git a/src/BaseServer.ts b/src/BaseServer.ts index 61a62d2..f925627 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -26,7 +26,7 @@ export class BaseServer extends Database { getContext( context: Pick< FakeRestContext, - 'url' | 'method' | 'params' | 'requestJson' + 'url' | 'method' | 'params' | 'requestBody' >, ): FakeRestContext { for (const name of this.getSingleNames()) { @@ -64,7 +64,7 @@ export class BaseServer extends Database { getNormalizedRequest( request: RequestType, ): Promise< - Pick + Pick > { throw new Error('Not implemented'); } @@ -135,7 +135,7 @@ export class BaseServer extends Database { } if (ctx.method === 'PUT') { try { - if (ctx.requestJson == null) { + if (ctx.requestBody == null) { return { status: 400, headers: {}, @@ -143,7 +143,7 @@ export class BaseServer extends Database { } return { status: 200, - body: this.updateOnly(name, ctx.requestJson), + body: this.updateOnly(name, ctx.requestBody), headers: { 'Content-Type': 'application/json', }, @@ -157,7 +157,7 @@ export class BaseServer extends Database { } if (ctx.method === 'PATCH') { try { - if (ctx.requestJson == null) { + if (ctx.requestBody == null) { return { status: 400, headers: {}, @@ -165,7 +165,7 @@ export class BaseServer extends Database { } return { status: 200, - body: this.updateOnly(name, ctx.requestJson), + body: this.updateOnly(name, ctx.requestBody), headers: { 'Content-Type': 'application/json', }, @@ -228,14 +228,14 @@ export class BaseServer extends Database { }; } if (ctx.method === 'POST') { - if (ctx.requestJson == null) { + if (ctx.requestBody == null) { return { status: 400, headers: {}, }; } - const newResource = this.addOne(name, ctx.requestJson); + const newResource = this.addOne(name, ctx.requestBody); const newResourceURI = `${this.baseUrl}/${name}/${ newResource[this.getCollection(name).identifierName] }`; @@ -272,7 +272,7 @@ export class BaseServer extends Database { } if (ctx.method === 'PUT') { try { - if (ctx.requestJson == null) { + if (ctx.requestBody == null) { return { status: 400, headers: {}, @@ -280,7 +280,7 @@ export class BaseServer extends Database { } return { status: 200, - body: this.updateOne(name, id, ctx.requestJson), + body: this.updateOne(name, id, ctx.requestBody), headers: { 'Content-Type': 'application/json', }, @@ -294,7 +294,7 @@ export class BaseServer extends Database { } if (ctx.method === 'PATCH') { try { - if (ctx.requestJson == null) { + if (ctx.requestBody == null) { return { status: 400, headers: {}, @@ -302,7 +302,7 @@ export class BaseServer extends Database { } return { status: 200, - body: this.updateOne(name, id, ctx.requestJson), + body: this.updateOne(name, id, ctx.requestBody), headers: { 'Content-Type': 'application/json', }, @@ -357,15 +357,6 @@ export type BaseServerOptions = DatabaseOptions & { defaultQuery?: QueryFunction; }; -export type BaseRequest = { - url?: string; - method?: string; - collection?: string; - single?: string; - requestJson?: Record | undefined; - params?: { [key: string]: any }; -}; - export type BaseResponse = { status: number; body?: Record | Record[]; @@ -377,6 +368,6 @@ export type FakeRestContext = { method?: string; collection?: string; single?: string; - requestJson: Record | undefined; + requestBody: Record | undefined; params: { [key: string]: any }; }; diff --git a/src/adapters/FetchMockServer.ts b/src/adapters/FetchMockServer.ts index 6e9f11b..0eb3520 100644 --- a/src/adapters/FetchMockServer.ts +++ b/src/adapters/FetchMockServer.ts @@ -30,9 +30,9 @@ export class FetchMockServer extends BaseServer { : ''; const params = parseQueryString(queryString); const text = await req.text(); - let requestJson: Record | undefined = undefined; + let requestBody: Record | undefined = undefined; try { - requestJson = JSON.parse(text); + requestBody = JSON.parse(text); } catch (e) { // not JSON, no big deal } @@ -40,7 +40,7 @@ export class FetchMockServer extends BaseServer { return { url: req.url, params, - requestJson, + requestBody, method: req.method, }; } diff --git a/src/adapters/MswServer.ts b/src/adapters/MswServer.ts index 916e844..6b263de 100644 --- a/src/adapters/MswServer.ts +++ b/src/adapters/MswServer.ts @@ -17,10 +17,10 @@ export class MswServer extends BaseServer { ([key, value]) => [key, JSON.parse(value)], ), ); - let requestJson: Record | undefined = undefined; + let requestBody: Record | undefined = undefined; try { const text = await request.text(); - requestJson = JSON.parse(text); + requestBody = JSON.parse(text); } catch (e) { // not JSON, no big deal } @@ -28,7 +28,7 @@ export class MswServer extends BaseServer { return { url: request.url, params, - requestJson, + requestBody, method: request.method, }; } diff --git a/src/adapters/SinonServer.ts b/src/adapters/SinonServer.ts index 51a8cc4..1e38b5b 100644 --- a/src/adapters/SinonServer.ts +++ b/src/adapters/SinonServer.ts @@ -32,10 +32,10 @@ export class SinonServer extends BaseServer< ? decodeURIComponent(req.url.slice(req.url.indexOf('?') + 1)) : ''; const params = parseQueryString(queryString); - let requestJson: Record | undefined = undefined; + let requestBody: Record | undefined = undefined; if ((req as SinonFakeXMLHttpRequest).requestBody) { try { - requestJson = JSON.parse( + requestBody = JSON.parse( (req as SinonFakeXMLHttpRequest).requestBody, ); } catch (error) { @@ -46,7 +46,7 @@ export class SinonServer extends BaseServer< return { url: req.url, params, - requestJson, + requestBody, method: req.method, }; } From f96e3282f39de9f55026c3b9169e1cf7674a3599 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 16:38:59 +0200 Subject: [PATCH 24/33] Update comments and readme --- README.md | 10 +++++++--- example/sinon.ts | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7bef048..cb4a485 100644 --- a/README.md +++ b/README.md @@ -540,20 +540,24 @@ const authorsCollection = new FakeRest.Collection({ items: [], identifierName: ' ```sh # Install dependencies make install + # Run the demo with MSW make run-msw # Run the demo with fetch-mock make run-fetch-mock -# Watch source files and recompile dist/FakeRest.js when anything is modified -make watch + +# Run the demo with sinon +make run-sinon + # Run tests make test + # Build minified version make build ``` -To test the Sinon integration, build the library then run the demo to start Vite and visit http://localhost:5173/sinon.html +You can sign-in to the demo with `janedoe` and `password` ## License diff --git a/example/sinon.ts b/example/sinon.ts index 9f598a3..942f53a 100644 --- a/example/sinon.ts +++ b/example/sinon.ts @@ -60,6 +60,7 @@ export const initializeSinon = () => { server.respondWith(restServer.getHandler()); }; +// An HttpClient based on XMLHttpRequest to use with Sinon const httpClient = (url: string, options: Options = {}): Promise => { const request = new XMLHttpRequest(); request.open(options.method ?? 'GET', url); From b46a39345e938214058b09b86861ba38810edd8b Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 16:47:31 +0200 Subject: [PATCH 25/33] Simplify and document MSW --- README.md | 11 +++++++---- example/index.tsx | 9 ++------- example/msw.ts | 8 +++++++- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index cb4a485..56804aa 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Then configure it: ```js // in ./src/msw.js import { setupWorker } from "msw/browser"; -import { getMswHandlers } from "fakerest"; +import { getMswHandler } from "fakerest"; const data = { 'authors': [ @@ -41,7 +41,7 @@ const data = { } }; -export const worker = setupWorker(...getMswHandlers({ +export const worker = setupWorker(getMswHandler({ data })); ``` @@ -54,7 +54,10 @@ import ReactDom from "react-dom"; import { App } from "./App"; import { worker } from "./msw"; -worker.start().then(() => { +worker.start({ + quiet: true, // Instruct MSW to not log requests in the console + onUnhandledRequest: 'bypass', // Instruct MSW to ignore requests we don't handle +}).then(() => { ReactDom.render(, document.getElementById("root")); }); ``` @@ -86,7 +89,7 @@ const data = { const restServer = new MswServer(); restServer.init(data); -export const worker = setupWorker(...restServer.getHandlers()); +export const worker = setupWorker(restServer.getHandler()); ``` ### Sinon diff --git a/example/index.tsx b/example/index.tsx index 0b1aff5..65ba410 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -31,13 +31,8 @@ switch (import.meta.env.VITE_MOCK) { break; default: import('./msw') - .then(({ worker, dataProvider }) => { - return worker - .start({ - quiet: true, - onUnhandledRequest: 'bypass', - }) - .then(() => dataProvider); + .then(({ initializeMsw, dataProvider }) => { + return initializeMsw().then(() => dataProvider); }) .then((dataProvider) => { ReactDom.render( diff --git a/example/msw.ts b/example/msw.ts index 63cdc31..a45f914 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -42,6 +42,12 @@ restServer.addMiddleware(async (request, context, next) => { return next(request, context); }); -export const worker = setupWorker(restServer.getHandler()); +export const initializeMsw = async () => { + const worker = setupWorker(restServer.getHandler()); + return worker.start({ + quiet: true, // Instruct MSW to not log requests in the console + onUnhandledRequest: 'bypass', // Instruct MSW to ignore requests we don't handle + }); +}; export const dataProvider = defaultDataProvider; From 6628014d88d0766b2b7e1f9eaf1880c22fd2f4c5 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 16:54:18 +0200 Subject: [PATCH 26/33] Rewrite documentation --- README.md | 373 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 207 insertions(+), 166 deletions(-) diff --git a/README.md b/README.md index 56804aa..16487f8 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,23 @@ Intercept AJAX calls to fake a REST server based on JSON data. Use it on top of See it in action in the [react-admin](https://marmelab.com/react-admin/) [demo](https://marmelab.com/react-admin-demo) ([source code](https://github.com/marmelab/react-admin/tree/master/examples/demo)). -## Usage +## Installation ### MSW We recommend you use [MSW](https://mswjs.io/) to mock your API. This will allow you to inspect requests as you usually do in the devtools network tab. -First, install msw and initialize it: +First, install fakerest and MSW. Then initialize MSW: ```sh -npm install msw@latest --save-dev +npm install fakerest msw@latest --save-dev npx msw init # eg: public ``` Then configure it: ```js -// in ./src/msw.js +// in ./src/fakeServer.js import { setupWorker } from "msw/browser"; import { getMswHandler } from "fakerest"; @@ -42,6 +42,7 @@ const data = { }; export const worker = setupWorker(getMswHandler({ + baseUrl: 'http://localhost:3000', data })); ``` @@ -52,7 +53,7 @@ Finally call the `worker.start()` method before rendering your application. For import React from "react"; import ReactDom from "react-dom"; import { App } from "./App"; -import { worker } from "./msw"; +import { worker } from "./fakeServer"; worker.start({ quiet: true, // Instruct MSW to not log requests in the console @@ -62,10 +63,10 @@ worker.start({ }); ``` -Another option is to use the `MswServer` class. This is useful if you must conditionally include data: +Another option is to use the `MswServer` class. This is useful if you must conditionally include data or add middlewares: ```js -// in ./src/msw.js +// in ./src/fakeServer.js import { setupWorker } from "msw/browser"; import { MswServer } from "fakerest"; @@ -86,19 +87,24 @@ const data = { } }; -const restServer = new MswServer(); -restServer.init(data); +const restServer = new MswServer({ + baseUrl: 'http://localhost:3000', + data, +}); export const worker = setupWorker(restServer.getHandler()); ``` +FakeRest will now intercept every `fetch` requests to the REST server. + ### Sinon -```html - - - +const sinonServer = sinon.fakeServer.create(); +// this is required when doing asynchronous XmlHttpRequest +sinonServer.autoRespond = true; + +sinonServer.respondWith( + getSinonHandler({ + baseUrl: 'http://localhost:3000', + data, + }) +); ``` -Another option is to use the `SinonServer` class. This is useful if you must conditionally include data or interceptors: +Another option is to use the `SinonServer` class. This is useful if you must conditionally include data or add middlewares: -```html - - - +const sinonServer = sinon.fakeServer.create(); +// this is required when doing asynchronous XmlHttpRequest +sinonServer.autoRespond = true; + +sinonServer.respondWith( + restServer.getHandler({ + baseUrl: 'http://localhost:3000', + data, + }) +); ``` +FakeRest will now intercept every `XmlHttpRequest` requests to the REST server. + ### fetch-mock +First, install fakerest and fetch-mock: + +```sh +npm install fakerest fetch-mock --save-dev +``` + +You can then initialize the `FetchMockServer`: + ```js +// in ./src/fakeServer.js import fetchMock from 'fetch-mock'; -import FakeRest from 'fakerest'; +import { getFetchMockHandler } from "fakerest"; const data = { 'authors': [ @@ -178,15 +212,15 @@ const data = { fetchMock.mock( 'begin:http://localhost:3000', - FakeRest.getFetchMockHandler({ baseUrl: 'http://localhost:3000', data }) + getFetchMockHandler({ baseUrl: 'http://localhost:3000', data }) ); ``` -Another option is to use the `FetchMockServer` class. This is useful if you must conditionally include data or interceptors: +Another option is to use the `FetchMockServer` class. This is useful if you must conditionally include data or add middlewares: ```js import fetchMock from 'fetch-mock'; -import FakeRest from 'fakerest'; +import { FetchMockServer } from 'fakerest'; const data = { 'authors': [ @@ -204,84 +238,14 @@ const data = { preferred_format: 'hardback', } }; -const restServer = new FakeRest.FetchMockServer({ baseUrl: 'http://localhost:3000' }); -restServer.init(data); +const restServer = new FetchMockServer({ + baseUrl: 'http://localhost:3000', + data +}); fetchMock.mock('begin:http://localhost:3000', restServer.getHandler()); ``` -FakeRest will now intercept every `XmlHttpRequest` to the REST server. The handled routes for collections of items are: - -``` -GET /:resource -POST /:resource -GET /:resource/:id -PUT /:resource/:id -PATCH /:resource/:id -DELETE /:resource/:id -``` - -The handled routes for single items are: - -``` -GET /:resource -PUT /:resource -PATCH /:resource -``` - - -Let's see an example: - -```js -// Query the fake REST server -var req = new XMLHttpRequest(); -req.open("GET", "/authors", false); -req.send(null); -console.log(req.responseText); -// [ -// {"id":0,"first_name":"Leo","last_name":"Tolstoi"}, -// {"id":1,"first_name":"Jane","last_name":"Austen"} -// ] - -var req = new XMLHttpRequest(); -req.open("GET", "/books/3", false); -req.send(null); -console.log(req.responseText); -// {"id":3,"author_id":1,"title":"Sense and Sensibility"} - -var req = new XMLHttpRequest(); -req.open("GET", "/settings", false); -req.send(null); -console.log(req.responseText); -// {"language:"english","preferred_format":"hardback"} - -var req = new XMLHttpRequest(); -req.open("POST", "/books", false); -req.send(JSON.stringify({ author_id: 1, title: 'Emma' })); -console.log(req.responseText); -// {"author_id":1,"title":"Emma","id":4} - -// restore native XHR constructor -server.restore(); -``` - -*Tip*: The `fakerServer` provided by Sinon.js is [available as a standalone library](http://sinonjs.org/docs/#server), without the entire stubbing framework. Simply add the following bower dependency: - -``` -devDependencies: { - "sinon-server": "http://sinonjs.org/releases/sinon-server-1.14.1.js" -} -``` - -## Installation - -FakeRest is available through npm and Bower: - -```sh -# If you use Bower -bower install fakerest --save-dev -# If you use npm -npm install fakerest --save-dev -``` +FakeRest will now intercept every `fetch` requests to the REST server. ## REST Flavor @@ -455,69 +419,146 @@ Operators are specified as suffixes on each filtered field. For instance, applyi GET /books?filter={"price_gte":100} // return books that have a price greater or equal to 100 -## Usage and Configuration +## Middlewares + +All fake servers supports middlewares that allows you to intercept requests and simulate server features such as: + - authentication checks + - server side validation + - server dynamically generated values + - simulate response delays + +A middleware is a function that receive 3 parameters: + - The request object, specific to the chosen mocking solution (e.g. a [`Request`](https://developer.mozilla.org/fr/docs/Web/API/Request) for MSW and `fetch-mock`, a fake [`XMLHttpRequest`](https://developer.mozilla.org/fr/docs/Web/API/XMLHttpRequest) for [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/)) + - The FakeRest context, an object containing the data extracted from the request that FakeRest uses to build the response. It has the following properties: + - `url`: The request URL as a string + - `method`: The request method as a string (`GET`, `POST`, `PATCH` or `PUT`) + - `collection`: The name of the targeted [collection](#collection) (e.g. `posts`) + - `single`: The name of the targeted [single](#single) (e.g. `settings`) + - `requestJson`: The parsed request data if any + - `params`: The request parameters from the URL search (e.g. the identifier of the requested record) + - A function to call the next middleware in the chain + +**Tip**: The middleware function for MSW and `fetch-mock` must return a promise. Those for Sinon must **not** return a promise. + +A middleware must return a FakeRest response either by returning the result of the `next` function or by returning its own response. A FakeRest response is an object with the following properties: + - `status`: The response status as a number (e.g. `200`) + - `headers`: The response HTTP headers as an object where keys are header names + - `body`: The response body which will be stringified + +A middleware might also throw a response specific to the chosen mocking solution (e.g. a [`Response`](https://developer.mozilla.org/fr/docs/Web/API/Response) for MSW, a [`MockResponseObject`](https://www.wheresrhys.co.uk/fetch-mock/#api-mockingmock_response) for `fetch-mock`) for even more control. + +### Authentication Checks + +Here's to implement an authentication check for MSW or `fetch-mock`: ```js -// initialize a rest server with a custom base URL -const restServer = new FakeRest.SinonServer({ baseUrl: 'http://my.custom.domain' }); // only URLs starting with my.custom.domain will be intercepted -restServer.toggleLogging(); // logging is off by default, enable it to see network calls in the console -// Set all JSON data at once - only if identifier name is 'id' -restServer.init(json); -// modify the request before FakeRest handles it, using a request interceptor -// request is { -// url: '...', -// headers: [...], -// requestBody: '...', -// json: ..., // parsed JSON body -// queryString: '...', -// params: {...} // parsed query string -// } -restServer.addRequestInterceptor(function(request) { - var start = (request.params._start - 1) || 0; - var end = request.params._end !== undefined ? (request.params._end - 1) : 19; - request.params.range = [start, end]; - return request; // always return the modified input -}); -// modify the response before FakeRest sends it, using a response interceptor -// response is { -// status: ..., -// headers: [...], -// body: {...} -// } -restServer.addResponseInterceptor(function(response) { - response.body = { data: response.body, status: response.status }; - return response; // always return the modified input +restServer.addMiddleware(async (request, context, next) => { + if (!request.headers?.get('Authorization')) { + throw new Response(null, { status: 401 }); + } + return next(request, context); +} +``` + +Here's how to do the same with Sinon: + +```js +restServer.addMiddleware((request, context, next) => { + if (request.requestHeaders.Authorization === undefined) { + // Uses Sinon API to respond immediately + request.respond(401, {}, 'Unauthorized'); + // Avoid further processing + return null; + } + + return next(request, context); +} +``` + +### Server Side Validation + +Here's to implement server side validation for MSW or `fetch-mock`: + +```js +restServer.addMiddleware(async (request, context, next) => { + if ( + context.collection === 'books' && + context.method === 'POST' && + !context.requestJson?.title + ) { + throw new Response(null, { + status: 400, + statusText: 'Title is required', + }); + } + + return next(request, context); +} +``` + +Here's how to do the same with Sinon: + +```js +restServer.addMiddleware((request, context, next) => { + if ( + context.collection === "books" && + request.method === "POST" && + !context.requestJson?.title + ) { + // Uses Sinon API to respond immediately + request.respond(400, {}, "Title is required"); + // Avoid further processing + return null; + } + + return next(request, context); +} +``` + +### Server Dynamically Generated Values + +Here's to implement server dynamically generated values: + +```js +restServer.addMiddleware(async (request, context, next) => { + if ( + context.collection === 'books' && + context.method === 'POST' + ) { + const response = await next(request, context); + response.body.updatedAt = new Date().toISOString(); + return response; + } + + return next(request, context); +} +``` + +### Simulate Response Delays + +This only works with MSW and `fetch-mock`: + +```js +restServer.addMiddleware(async (request, context, next) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(next(request, context)); + }, delayMs); + }); }); -// set default query, e.g. to force embeds or filters -restServer.setDefaultQuery(function(resourceName) { - if (resourceName == 'authors') return { embed: ['books'] } - if (resourceName == 'books') return { filter: { published: true } } - return {}; -}) -// enable batch request handler, i.e. allow API clients to query several resources into a single request -// see [Facebook's Batch Requests philosophy](https://developers.facebook.com/docs/graph-api/making-multiple-requests) for more details. -restServer.setBatchUrl('/batch'); - -// you can create more than one fake server to listen to several domains -const restServer2 = new FakeRest.SinonServer({ baseUrl: 'http://my.other.domain' }); -// Set data collection by collection - allows to customize the identifier name -const authorsCollection = new FakeRest.Collection({ items: [], identifierName: '_id' }); -authorsCollection.addOne({ first_name: 'Leo', last_name: 'Tolstoi' }); // { _id: 0, first_name: 'Leo', last_name: 'Tolstoi' } -authorsCollection.addOne({ first_name: 'Jane', last_name: 'Austen' }); // { _id: 1, first_name: 'Jane', last_name: 'Austen' } -// collections have auto incremented identifiers by default but accept identifiers already set -authorsCollection.addOne({ _id: 3, first_name: 'Marcel', last_name: 'Proust' }); // { _id: 3, first_name: 'Marcel', last_name: 'Proust' } -restServer2.addCollection('authors', authorsCollection); -// collections are mutable -authorsCollection.updateOne(1, { last_name: 'Doe' }); // { _id: 1, first_name: 'Jane', last_name: 'Doe' } -authorsCollection.removeOne(3); // { _id: 3, first_name: 'Marcel', last_name: 'Proust' } - -const server = sinon.fakeServer.create(); -server.autoRespond = true; -server.respondWith(restServer.getHandler()); -server.respondWith(restServer2.getHandler()); ``` -## Configure Identifiers Generation +This is so common FakeRest provides the `withDelay` function for that: + +```js +import { withDelay } from 'fakerest'; + +restServer.addMiddleware(withDelay(300)); +``` + +## Configuration + +### Configure Identifiers Generation By default, FakeRest uses an auto incremented sequence for the items identifiers. If you'd rather use UUIDs for instance but would like to avoid providing them when you insert new items, you can provide your own function: From a98bedd87a5f29c5f863a5ec26e5905658e26a42 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 17:02:34 +0200 Subject: [PATCH 27/33] Update documentation --- README.md | 67 +++++++++++++------------------------------- example/fetchMock.ts | 5 +++- example/msw.ts | 5 +++- 3 files changed, 28 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 16487f8..c9363af 100644 --- a/README.md +++ b/README.md @@ -428,47 +428,34 @@ All fake servers supports middlewares that allows you to intercept requests and - simulate response delays A middleware is a function that receive 3 parameters: - - The request object, specific to the chosen mocking solution (e.g. a [`Request`](https://developer.mozilla.org/fr/docs/Web/API/Request) for MSW and `fetch-mock`, a fake [`XMLHttpRequest`](https://developer.mozilla.org/fr/docs/Web/API/XMLHttpRequest) for [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/)) - - The FakeRest context, an object containing the data extracted from the request that FakeRest uses to build the response. It has the following properties: + - The `request` object, specific to the chosen mocking solution (e.g. a [`Request`](https://developer.mozilla.org/fr/docs/Web/API/Request) for MSW and `fetch-mock`, a fake [`XMLHttpRequest`](https://developer.mozilla.org/fr/docs/Web/API/XMLHttpRequest) for [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/)) + - The FakeRest `context`, an object containing the data extracted from the request that FakeRest uses to build the response. It has the following properties: - `url`: The request URL as a string - `method`: The request method as a string (`GET`, `POST`, `PATCH` or `PUT`) - `collection`: The name of the targeted [collection](#collection) (e.g. `posts`) - `single`: The name of the targeted [single](#single) (e.g. `settings`) - `requestJson`: The parsed request data if any - `params`: The request parameters from the URL search (e.g. the identifier of the requested record) - - A function to call the next middleware in the chain - -**Tip**: The middleware function for MSW and `fetch-mock` must return a promise. Those for Sinon must **not** return a promise. + - A `next` function to call the next middleware in the chain, to which you must pass the `request` and the `context` A middleware must return a FakeRest response either by returning the result of the `next` function or by returning its own response. A FakeRest response is an object with the following properties: - `status`: The response status as a number (e.g. `200`) - `headers`: The response HTTP headers as an object where keys are header names - `body`: The response body which will be stringified -A middleware might also throw a response specific to the chosen mocking solution (e.g. a [`Response`](https://developer.mozilla.org/fr/docs/Web/API/Response) for MSW, a [`MockResponseObject`](https://www.wheresrhys.co.uk/fetch-mock/#api-mockingmock_response) for `fetch-mock`) for even more control. +Except for Sinon, a middleware might also throw a response specific to the chosen mocking solution (e.g. a [`Response`](https://developer.mozilla.org/fr/docs/Web/API/Response) for MSW, a [`MockResponseObject`](https://www.wheresrhys.co.uk/fetch-mock/#api-mockingmock_response) or a [`Response`](https://developer.mozilla.org/fr/docs/Web/API/Response) for `fetch-mock`) for even more control. ### Authentication Checks -Here's to implement an authentication check for MSW or `fetch-mock`: +Here's to implement an authentication check: ```js restServer.addMiddleware(async (request, context, next) => { - if (!request.headers?.get('Authorization')) { - throw new Response(null, { status: 401 }); - } - return next(request, context); -} -``` - -Here's how to do the same with Sinon: - -```js -restServer.addMiddleware((request, context, next) => { if (request.requestHeaders.Authorization === undefined) { - // Uses Sinon API to respond immediately - request.respond(401, {}, 'Unauthorized'); - // Avoid further processing - return null; + return { + status: 401, + headers: {}, + }; } return next(request, context); @@ -477,38 +464,24 @@ restServer.addMiddleware((request, context, next) => { ### Server Side Validation -Here's to implement server side validation for MSW or `fetch-mock`: +Here's to implement server side validation: ```js restServer.addMiddleware(async (request, context, next) => { - if ( - context.collection === 'books' && - context.method === 'POST' && - !context.requestJson?.title - ) { - throw new Response(null, { - status: 400, - statusText: 'Title is required', - }); - } - - return next(request, context); -} -``` - -Here's how to do the same with Sinon: - -```js -restServer.addMiddleware((request, context, next) => { if ( context.collection === "books" && request.method === "POST" && !context.requestJson?.title ) { - // Uses Sinon API to respond immediately - request.respond(400, {}, "Title is required"); - // Avoid further processing - return null; + return { + status: 400, + headers: {}, + body: { + errors: { + title: 'An article with this title already exists. The title must be unique.', + }, + }, + }; } return next(request, context); @@ -536,7 +509,7 @@ restServer.addMiddleware(async (request, context, next) => { ### Simulate Response Delays -This only works with MSW and `fetch-mock`: +Here's to simulate response delays: ```js restServer.addMiddleware(async (request, context, next) => { diff --git a/example/fetchMock.ts b/example/fetchMock.ts index 1ce77da..9146652 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -17,7 +17,10 @@ export const initializeFetchMock = () => { restServer.addMiddleware(withDelay(300)); restServer.addMiddleware(async (request, context, next) => { if (!request.headers?.get('Authorization')) { - throw new Response(null, { status: 401 }); + return { + status: 401, + headers: {}, + }; } return next(request, context); }); diff --git a/example/msw.ts b/example/msw.ts index a45f914..da8e059 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -11,7 +11,10 @@ const restServer = new MswServer({ restServer.addMiddleware(withDelay(300)); restServer.addMiddleware(async (request, context, next) => { if (!request.headers?.get('Authorization')) { - throw new Response(null, { status: 401 }); + return { + status: 401, + headers: {}, + }; } return next(request, context); }); From daf48e4c35e0990dfaf56d063b90ba83e55e02f0 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 17:34:46 +0200 Subject: [PATCH 28/33] Don't extend Database --- src/BaseServer.ts | 104 ++++++++++++++++++++++--------- src/Collection.spec.ts | 48 +++++++------- src/Collection.ts | 25 ++++---- src/Database.spec.ts | 2 +- src/Database.ts | 6 +- src/Single.spec.ts | 36 +++++------ src/Single.ts | 23 +++---- src/adapters/SinonServer.spec.ts | 62 +++++++++--------- 8 files changed, 179 insertions(+), 127 deletions(-) diff --git a/src/BaseServer.ts b/src/BaseServer.ts index f925627..c6ef837 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -1,19 +1,28 @@ +import type { Collection } from './Collection.js'; import { Database, type DatabaseOptions } from './Database.js'; -import type { QueryFunction } from './types.js'; +import type { Single } from './Single.js'; +import type { CollectionItem, QueryFunction } from './types.js'; -export class BaseServer extends Database { +export class BaseServer { baseUrl = ''; defaultQuery: QueryFunction = () => ({}); middlewares: Array> = []; + database: Database; constructor({ baseUrl = '', defaultQuery = () => ({}), + database, ...options }: BaseServerOptions = {}) { - super(options); this.baseUrl = baseUrl; this.defaultQuery = defaultQuery; + + if (database) { + this.database = database; + } else { + this.database = new Database(options); + } } /** @@ -23,13 +32,8 @@ export class BaseServer extends Database { this.defaultQuery = query; } - getContext( - context: Pick< - FakeRestContext, - 'url' | 'method' | 'params' | 'requestBody' - >, - ): FakeRestContext { - for (const name of this.getSingleNames()) { + getContext(context: NormalizedRequest): FakeRestContext { + for (const name of this.database.getSingleNames()) { const matches = context.url?.match( new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), ); @@ -61,11 +65,7 @@ export class BaseServer extends Database { return context; } - getNormalizedRequest( - request: RequestType, - ): Promise< - Pick - > { + getNormalizedRequest(request: RequestType): Promise { throw new Error('Not implemented'); } @@ -111,7 +111,7 @@ export class BaseServer extends Database { handleRequest(request: RequestType, ctx: FakeRestContext): BaseResponse { // Handle Single Objects - for (const name of this.getSingleNames()) { + for (const name of this.database.getSingleNames()) { const matches = ctx.url?.match( new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), ); @@ -121,7 +121,7 @@ export class BaseServer extends Database { try { return { status: 200, - body: this.getOnly(name), + body: this.database.getOnly(name), headers: { 'Content-Type': 'application/json', }, @@ -143,7 +143,7 @@ export class BaseServer extends Database { } return { status: 200, - body: this.updateOnly(name, ctx.requestBody), + body: this.database.updateOnly(name, ctx.requestBody), headers: { 'Content-Type': 'application/json', }, @@ -165,7 +165,7 @@ export class BaseServer extends Database { } return { status: 200, - body: this.updateOnly(name, ctx.requestBody), + body: this.database.updateOnly(name, ctx.requestBody), headers: { 'Content-Type': 'application/json', }, @@ -190,15 +190,15 @@ export class BaseServer extends Database { const params = Object.assign({}, this.defaultQuery(name), ctx.params); if (!matches[2]) { if (ctx.method === 'GET') { - if (!this.getCollection(name)) { + if (!this.database.getCollection(name)) { return { status: 404, headers: {} }; } - const count = this.getCount( + const count = this.database.getCount( name, params.filter ? { filter: params.filter } : {}, ); if (count > 0) { - const items = this.getAll(name, params); + const items = this.database.getAll(name, params); const first = params.range ? params.range[0] : 0; const last = params.range && params.range.length === 2 @@ -235,9 +235,11 @@ export class BaseServer extends Database { }; } - const newResource = this.addOne(name, ctx.requestBody); + const newResource = this.database.addOne(name, ctx.requestBody); const newResourceURI = `${this.baseUrl}/${name}/${ - newResource[this.getCollection(name).identifierName] + newResource[ + this.database.getCollection(name).identifierName + ] }`; return { @@ -250,7 +252,7 @@ export class BaseServer extends Database { }; } } else { - if (!this.getCollection(name)) { + if (!this.database.getCollection(name)) { return { status: 404, headers: {} }; } const id = Number.parseInt(matches[3]); @@ -258,7 +260,7 @@ export class BaseServer extends Database { try { return { status: 200, - body: this.getOne(name, id, params), + body: this.database.getOne(name, id, params), headers: { 'Content-Type': 'application/json', }, @@ -280,7 +282,11 @@ export class BaseServer extends Database { } return { status: 200, - body: this.updateOne(name, id, ctx.requestBody), + body: this.database.updateOne( + name, + id, + ctx.requestBody, + ), headers: { 'Content-Type': 'application/json', }, @@ -302,7 +308,11 @@ export class BaseServer extends Database { } return { status: 200, - body: this.updateOne(name, id, ctx.requestBody), + body: this.database.updateOne( + name, + id, + ctx.requestBody, + ), headers: { 'Content-Type': 'application/json', }, @@ -318,7 +328,7 @@ export class BaseServer extends Database { try { return { status: 200, - body: this.removeOne(name, id), + body: this.database.removeOne(name, id), headers: { 'Content-Type': 'application/json', }, @@ -340,6 +350,36 @@ export class BaseServer extends Database { addMiddleware(middleware: Middleware) { this.middlewares.push(middleware); } + + addCollection( + name: string, + collection: Collection, + ) { + this.database.addCollection(name, collection); + } + + getCollection(name: string) { + return this.database.getCollection(name); + } + + getCollectionNames() { + return this.database.getCollectionNames(); + } + + addSingle( + name: string, + single: Single, + ) { + this.database.addSingle(name, single); + } + + getSingle(name: string) { + return this.database.getSingle(name); + } + + getSingleNames() { + return this.database.getSingleNames(); + } } export type Middleware = ( @@ -352,6 +392,7 @@ export type Middleware = ( ) => Promise | BaseResponse | null; export type BaseServerOptions = DatabaseOptions & { + database?: Database; baseUrl?: string; batchUrl?: string | null; defaultQuery?: QueryFunction; @@ -371,3 +412,8 @@ export type FakeRestContext = { requestBody: Record | undefined; params: { [key: string]: any }; }; + +export type NormalizedRequest = Pick< + FakeRestContext, + 'url' | 'method' | 'params' | 'requestBody' +>; diff --git a/src/Collection.spec.ts b/src/Collection.spec.ts index b17687a..4887680 100644 --- a/src/Collection.spec.ts +++ b/src/Collection.spec.ts @@ -1,5 +1,5 @@ import { Collection } from './Collection.js'; -import { Server } from './adapters/SinonServer.js'; +import { Database } from './Database.js'; import type { CollectionItem } from './types.js'; describe('Collection', () => { @@ -627,8 +627,8 @@ describe('Collection', () => { const foos = new Collection({ items: [{ name: 'John', bar_id: 123 }], }); - const server = new Server(); - server.addCollection('foos', foos); + const database = new Database(); + database.addCollection('foos', foos); expect(() => { foos.getAll({ embed: ['bar'] }); }).toThrow( @@ -641,9 +641,9 @@ describe('Collection', () => { items: [{ name: 'John', bar_id: 123 }], }); const bars = new Collection({ items: [] }); - const server = new Server(); - server.addCollection('foos', foos); - server.addCollection('bars', bars); + const database = new Database(); + database.addCollection('foos', foos); + database.addCollection('bars', bars); const expected = [{ id: 0, name: 'John', bar_id: 123 }]; expect(foos.getAll({ embed: ['bar'] })).toEqual(expected); }); @@ -662,9 +662,9 @@ describe('Collection', () => { { id: 456, bar: 'bazz' }, ], }); - const server = new Server(); - server.addCollection('foos', foos); - server.addCollection('bars', bars); + const database = new Database(); + database.addCollection('foos', foos); + database.addCollection('bars', bars); const expected = [ { id: 0, @@ -686,8 +686,8 @@ describe('Collection', () => { const foos = new Collection({ items: [{ name: 'John', bar_id: 123 }], }); - const server = new Server(); - server.addCollection('foos', foos); + const database = new Database(); + database.addCollection('foos', foos); expect(() => { foos.getAll({ embed: ['bars'] }); }).toThrow( @@ -702,9 +702,9 @@ describe('Collection', () => { const bars = new Collection({ items: [{ id: 1, bar: 'nobody wants me' }], }); - const server = new Server(); - server.addCollection('foos', foos); - server.addCollection('bars', bars); + const database = new Database(); + database.addCollection('foos', foos); + database.addCollection('bars', bars); const expected = [{ id: 1, bar: 'nobody wants me', foos: [] }]; expect(bars.getAll({ embed: ['foos'] })).toEqual(expected); }); @@ -724,9 +724,9 @@ describe('Collection', () => { { id: 456, bar: 'bazz' }, ], }); - const server = new Server(); - server.addCollection('foos', foos); - server.addCollection('bars', bars); + const database = new Database(); + database.addCollection('foos', foos); + database.addCollection('bars', bars); const expected = [ { id: 1, bar: 'nobody wants me', foos: [] }, { @@ -761,9 +761,9 @@ describe('Collection', () => { { id: 456, bar: 'bazz', foos: [2, 3] }, ], }); - const server = new Server(); - server.addCollection('foos', foos); - server.addCollection('bars', bars); + const database = new Database(); + database.addCollection('foos', foos); + database.addCollection('bars', bars); const expected = [ { id: 1, bar: 'nobody wants me', foos: [] }, { id: 123, bar: 'baz', foos: [{ id: 1, name: 'John' }] }, @@ -809,10 +809,10 @@ describe('Collection', () => { { id: 2, name: 'Russia' }, ], }); - const server = new Server(); - server.addCollection('books', books); - server.addCollection('authors', authors); - server.addCollection('countrys', countries); // nevermind the plural + const database = new Database(); + database.addCollection('books', books); + database.addCollection('authors', authors); + database.addCollection('countrys', countries); // nevermind the plural const expected = [ { id: 1, diff --git a/src/Collection.ts b/src/Collection.ts index 203466e..f03112c 100644 --- a/src/Collection.ts +++ b/src/Collection.ts @@ -14,7 +14,7 @@ import type { export class Collection { sequence = 0; items: T[] = []; - server: Database | null = null; + database: Database | null = null; name: string | null = null; identifierName = 'id'; getNewId: () => number | string; @@ -40,10 +40,10 @@ export class Collection { /** * A Collection may need to access other collections (e.g. for embedding references) - * This is done through a reference to the parent server. + * This is done through a reference to the parent database. */ - setServer(server: Database) { - this.server = server; + setDatabase(database: Database) { + this.database = database; } setName(name: string) { @@ -66,10 +66,10 @@ export class Collection { const singularResourceName = this.name.slice(0, -1); const referenceName = `${singularResourceName}_id`; return (item: T) => { - if (this.server == null) { - throw new Error("Can't embed references without a server"); + if (this.database == null) { + throw new Error("Can't embed references without a database"); } - const otherCollection = this.server.collections[resourceName]; + const otherCollection = this.database.collections[resourceName]; if (!otherCollection) throw new Error( `Can't embed a non-existing collection ${resourceName}`, @@ -108,10 +108,11 @@ export class Collection { const pluralResourceName = `${resourceName}s`; const referenceName = `${resourceName}_id`; return (item: T) => { - if (this.server == null) { - throw new Error("Can't embed references without a server"); + if (this.database == null) { + throw new Error("Can't embed references without a database"); } - const otherCollection = this.server.collections[pluralResourceName]; + const otherCollection = + this.database.collections[pluralResourceName]; if (!otherCollection) throw new Error( `Can't embed a non-existing collection ${resourceName}`, @@ -163,7 +164,7 @@ export class Collection { items = rangeItems(items, query.range); } items = items.map((item) => Object.assign({}, item)); // clone item to avoid returning the original - if (query.embed && this.server) { + if (query.embed && this.database) { items = items.map(this._itemEmbedder(query.embed)); // embed reference } } @@ -184,7 +185,7 @@ export class Collection { } let item = this.items[index]; item = Object.assign({}, item); // clone item to avoid returning the original - if (query?.embed && this.server) { + if (query?.embed && this.database) { item = this._itemEmbedder(query.embed)(item); // embed reference } return item; diff --git a/src/Database.spec.ts b/src/Database.spec.ts index ae500e0..da112cd 100644 --- a/src/Database.spec.ts +++ b/src/Database.spec.ts @@ -2,7 +2,7 @@ import { Database } from './Database.js'; import { Single } from './Single.js'; import { Collection } from './Collection.js'; -describe('AbstractBaseServer', () => { +describe('Database', () => { describe('init', () => { it('should populate several collections', () => { const server = new Database(); diff --git a/src/Database.ts b/src/Database.ts index a1cd19e..1f8f66a 100644 --- a/src/Database.ts +++ b/src/Database.ts @@ -47,7 +47,7 @@ export class Database { collection: Collection, ) { this.collections[name] = collection; - collection.setServer(this); + collection.setDatabase(this); collection.setName(name); } @@ -64,7 +64,7 @@ export class Database { single: Single, ) { this.singles[name] = single; - single.setServer(this); + single.setDatabase(this); single.setName(name); } @@ -102,7 +102,7 @@ export class Database { name, new Collection({ items: [], - identifierName: 'id', + identifierName: this.identifierName, getNewId: this.getNewId, }), ); diff --git a/src/Single.spec.ts b/src/Single.spec.ts index f9107b5..625bcca 100644 --- a/src/Single.spec.ts +++ b/src/Single.spec.ts @@ -1,6 +1,6 @@ import { Single } from './Single.js'; import { Collection } from './Collection.js'; -import { Server } from './adapters/SinonServer.js'; +import { Database } from './Database.js'; describe('Single', () => { describe('constructor', () => { @@ -19,8 +19,8 @@ describe('Single', () => { describe('embed query', () => { it('should throw an error when trying to embed a non-existing collection', () => { const foo = new Single({ name: 'foo', bar_id: 123 }); - const server = new Server(); - server.addSingle('foo', foo); + const database = new Database(); + database.addSingle('foo', foo); expect(() => { foo.getOnly({ embed: ['bar'] }); }).toThrow( @@ -31,9 +31,9 @@ describe('Single', () => { it('should return the original object for missing embed one', () => { const foo = new Single({ name: 'foo', bar_id: 123 }); const bars = new Collection({ items: [] }); - const server = new Server(); - server.addSingle('foo', foo); - server.addCollection('bars', bars); + const database = new Database(); + database.addSingle('foo', foo); + database.addCollection('bars', bars); const expected = { name: 'foo', bar_id: 123 }; expect(foo.getOnly({ embed: ['bar'] })).toEqual(expected); }); @@ -47,9 +47,9 @@ describe('Single', () => { { id: 456, bar: 'bazz' }, ], }); - const server = new Server(); - server.addSingle('foo', foo); - server.addCollection('bars', bars); + const database = new Database(); + database.addSingle('foo', foo); + database.addCollection('bars', bars); const expected = { name: 'foo', bar_id: 123, @@ -60,8 +60,8 @@ describe('Single', () => { it('should throw an error when trying to embed many a non-existing collection', () => { const foo = new Single({ name: 'foo', bar_id: 123 }); - const server = new Server(); - server.addSingle('foo', foo); + const database = new Database(); + database.addSingle('foo', foo); expect(() => { foo.getOnly({ embed: ['bars'] }); }).toThrow( @@ -78,9 +78,9 @@ describe('Single', () => { { id: 3, bar: 'boz' }, ], }); - const server = new Server(); - server.addSingle('foo', foo); - server.addCollection('bars', bars); + const database = new Database(); + database.addSingle('foo', foo); + database.addCollection('bars', bars); const expected = { name: 'foo', bars: [ @@ -111,10 +111,10 @@ describe('Single', () => { { id: 6, name: 'baz3' }, ], }); - const server = new Server(); - server.addSingle('foo', foo); - server.addCollection('bars', bars); - server.addCollection('bazs', bazs); + const database = new Database(); + database.addSingle('foo', foo); + database.addCollection('bars', bars); + database.addCollection('bazs', bazs); const expected = { name: 'foo', bars: [ diff --git a/src/Single.ts b/src/Single.ts index 2d5a87b..cc66a6f 100644 --- a/src/Single.ts +++ b/src/Single.ts @@ -3,7 +3,7 @@ import type { CollectionItem, Embed, Query } from './types.js'; export class Single { obj: T | null = null; - server: Database | null = null; + database: Database | null = null; name: string | null = null; constructor(obj: T) { @@ -17,10 +17,10 @@ export class Single { /** * A Single may need to access other collections (e.g. for embedded - * references) This is done through a reference to the parent server. + * references) This is done through a reference to the parent database. */ - setServer(server: Database) { - this.server = server; + setDatabase(database: Database) { + this.database = database; } setName(name: string) { @@ -32,10 +32,10 @@ export class Single { // it is by definition a singleton _oneToManyEmbedder(resourceName: string) { return (item: T) => { - if (this.server == null) { - throw new Error("Can't embed references without a server"); + if (this.database == null) { + throw new Error("Can't embed references without a database"); } - const otherCollection = this.server.collections[resourceName]; + const otherCollection = this.database.collections[resourceName]; if (!otherCollection) throw new Error( `Can't embed a non-existing collection ${resourceName}`, @@ -57,10 +57,11 @@ export class Single { const pluralResourceName = `${resourceName}s`; const referenceName = `${resourceName}_id`; return (item: T) => { - if (this.server == null) { - throw new Error("Can't embed references without a server"); + if (this.database == null) { + throw new Error("Can't embed references without a database"); } - const otherCollection = this.server.collections[pluralResourceName]; + const otherCollection = + this.database.collections[pluralResourceName]; if (!otherCollection) throw new Error( `Can't embed a non-existing collection ${resourceName}`, @@ -93,7 +94,7 @@ export class Single { getOnly(query?: Query) { let item = this.obj; - if (query?.embed && this.server) { + if (query?.embed && this.database) { item = Object.assign({}, item); // Clone item = this._itemEmbedder(query.embed)(item); } diff --git a/src/adapters/SinonServer.spec.ts b/src/adapters/SinonServer.spec.ts index d433c7e..d7de34a 100644 --- a/src/adapters/SinonServer.spec.ts +++ b/src/adapters/SinonServer.spec.ts @@ -40,7 +40,7 @@ describe('SinonServer', () => { context.params.range = [start, end]; return next(request, context); }); - server.addCollection( + server.database.addCollection( 'foo', new Collection({ items: [ @@ -85,7 +85,7 @@ describe('SinonServer', () => { }; return response; }); - server.addCollection( + server.database.addCollection( 'foo', new Collection({ items: [ @@ -111,7 +111,7 @@ describe('SinonServer', () => { requestUrl = request.url; return next(request, context); }); - server.addCollection('foo', new Collection()); + server.database.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); @@ -132,7 +132,7 @@ describe('SinonServer', () => { it('should respond to GET /foo by sending all items in collection foo', async () => { const server = new SinonServer(); - server.addCollection( + server.database.addCollection( 'foo', new Collection({ items: [ @@ -159,7 +159,7 @@ describe('SinonServer', () => { it('should respond to GET /foo?queryString by sending all items in collection foo satisfying query', async () => { const server = new SinonServer(); - server.addCollection( + server.database.addCollection( 'foos', new Collection({ items: [ @@ -169,7 +169,7 @@ describe('SinonServer', () => { ], }), ); - server.addCollection( + server.database.addCollection( 'bars', new Collection({ items: [{ id: 0, name: 'a', foo_id: 1 }] }), ); @@ -194,7 +194,7 @@ describe('SinonServer', () => { it('should respond to GET /foo?queryString with pagination by sending the correct content-range header', async () => { const server = new SinonServer(); - server.addCollection( + server.database.addCollection( 'foo', new Collection({ items: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}], @@ -233,7 +233,7 @@ describe('SinonServer', () => { it('should respond to GET /foo on an empty collection with a []', async () => { const server = new SinonServer(); - server.addCollection('foo', new Collection()); + server.database.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); await server.handle(request); @@ -247,7 +247,7 @@ describe('SinonServer', () => { it('should respond to POST /foo by adding an item to collection foo', async () => { const server = new SinonServer(); - server.addCollection( + server.database.addCollection( 'foo', new Collection({ items: [ @@ -270,7 +270,7 @@ describe('SinonServer', () => { 'application/json', ); expect(request.getResponseHeader('Location')).toEqual('/foo/3'); - expect(server.getAll('foo')).toEqual([ + expect(server.database.getAll('foo')).toEqual([ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, { id: 3, name: 'baz' }, @@ -293,12 +293,14 @@ describe('SinonServer', () => { 'application/json', ); expect(request.getResponseHeader('Location')).toEqual('/foo/0'); - expect(server.getAll('foo')).toEqual([{ id: 0, name: 'baz' }]); + expect(server.database.getAll('foo')).toEqual([ + { id: 0, name: 'baz' }, + ]); }); it('should respond to GET /foo/:id by sending element of identifier id in collection foo', async () => { const server = new SinonServer(); - server.addCollection( + server.database.addCollection( 'foo', new Collection({ items: [ @@ -320,7 +322,7 @@ describe('SinonServer', () => { it('should respond to GET /foo/:id on a non-existing id with a 404', async () => { const server = new SinonServer(); - server.addCollection('foo', new Collection()); + server.database.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo/3'); if (request == null) throw new Error('request is null'); await server.handle(request); @@ -329,7 +331,7 @@ describe('SinonServer', () => { it('should respond to PUT /foo/:id by updating element of identifier id in collection foo', async () => { const server = new SinonServer(); - server.addCollection( + server.database.addCollection( 'foo', new Collection({ items: [ @@ -351,7 +353,7 @@ describe('SinonServer', () => { expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.getAll('foo')).toEqual([ + expect(server.database.getAll('foo')).toEqual([ { id: 1, name: 'foo' }, { id: 2, name: 'baz' }, ]); @@ -359,7 +361,7 @@ describe('SinonServer', () => { it('should respond to PUT /foo/:id on a non-existing id with a 404', async () => { const server = new SinonServer(); - server.addCollection('foo', new Collection({ items: [] })); + server.database.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest( 'PUT', '/foo/3', @@ -372,7 +374,7 @@ describe('SinonServer', () => { it('should respond to PATCH /foo/:id by updating element of identifier id in collection foo', async () => { const server = new SinonServer(); - server.addCollection( + server.database.addCollection( 'foo', new Collection({ items: [ @@ -394,7 +396,7 @@ describe('SinonServer', () => { expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.getAll('foo')).toEqual([ + expect(server.database.getAll('foo')).toEqual([ { id: 1, name: 'foo' }, { id: 2, name: 'baz' }, ]); @@ -402,7 +404,7 @@ describe('SinonServer', () => { it('should respond to PATCH /foo/:id on a non-existing id with a 404', async () => { const server = new SinonServer(); - server.addCollection('foo', new Collection({ items: [] })); + server.database.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest( 'PATCH', '/foo/3', @@ -415,7 +417,7 @@ describe('SinonServer', () => { it('should respond to DELETE /foo/:id by removing element of identifier id in collection foo', async () => { const server = new SinonServer(); - server.addCollection( + server.database.addCollection( 'foo', new Collection({ items: [ @@ -433,12 +435,14 @@ describe('SinonServer', () => { expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.getAll('foo')).toEqual([{ id: 1, name: 'foo' }]); + expect(server.database.getAll('foo')).toEqual([ + { id: 1, name: 'foo' }, + ]); }); it('should respond to DELETE /foo/:id on a non-existing id with a 404', async () => { const server = new SinonServer(); - server.addCollection('foo', new Collection({ items: [] })); + server.database.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest('DELETE', '/foo/3'); if (request == null) throw new Error('request is null'); await server.handle(request); @@ -447,7 +451,7 @@ describe('SinonServer', () => { it('should respond to GET /foo/ with single item', async () => { const server = new SinonServer(); - server.addSingle('foo', new Single({ name: 'foo' })); + server.database.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); @@ -462,7 +466,7 @@ describe('SinonServer', () => { it('should respond to PUT /foo/ by updating the singleton record', async () => { const server = new SinonServer(); - server.addSingle('foo', new Single({ name: 'foo' })); + server.database.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest( 'PUT', @@ -477,12 +481,12 @@ describe('SinonServer', () => { expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.getOnly('foo')).toEqual({ name: 'baz' }); + expect(server.database.getOnly('foo')).toEqual({ name: 'baz' }); }); it('should respond to PATCH /foo/ by updating the singleton record', async () => { const server = new SinonServer(); - server.addSingle('foo', new Single({ name: 'foo' })); + server.database.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest( 'PATCH', @@ -497,14 +501,14 @@ describe('SinonServer', () => { expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.getOnly('foo')).toEqual({ name: 'baz' }); + expect(server.database.getOnly('foo')).toEqual({ name: 'baz' }); }); }); describe('setDefaultQuery', () => { it('should set the default query string', async () => { const server = new SinonServer(); - server.addCollection( + server.database.addCollection( 'foo', new Collection({ items: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}], @@ -527,7 +531,7 @@ describe('SinonServer', () => { it('should not override any provided query string', async () => { const server = new SinonServer(); - server.addCollection( + server.database.addCollection( 'foo', new Collection({ items: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}], From 55c76cb736871b8b8c9fa20ac06b0ee8ab37a5e5 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 17:46:09 +0200 Subject: [PATCH 29/33] Revert unnecessary changes --- src/adapters/SinonServer.spec.ts | 44 ++++++++++++++++---------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/adapters/SinonServer.spec.ts b/src/adapters/SinonServer.spec.ts index d7de34a..ed9012e 100644 --- a/src/adapters/SinonServer.spec.ts +++ b/src/adapters/SinonServer.spec.ts @@ -40,7 +40,7 @@ describe('SinonServer', () => { context.params.range = [start, end]; return next(request, context); }); - server.database.addCollection( + server.addCollection( 'foo', new Collection({ items: [ @@ -85,7 +85,7 @@ describe('SinonServer', () => { }; return response; }); - server.database.addCollection( + server.addCollection( 'foo', new Collection({ items: [ @@ -111,7 +111,7 @@ describe('SinonServer', () => { requestUrl = request.url; return next(request, context); }); - server.database.addCollection('foo', new Collection()); + server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); @@ -132,7 +132,7 @@ describe('SinonServer', () => { it('should respond to GET /foo by sending all items in collection foo', async () => { const server = new SinonServer(); - server.database.addCollection( + server.addCollection( 'foo', new Collection({ items: [ @@ -159,7 +159,7 @@ describe('SinonServer', () => { it('should respond to GET /foo?queryString by sending all items in collection foo satisfying query', async () => { const server = new SinonServer(); - server.database.addCollection( + server.addCollection( 'foos', new Collection({ items: [ @@ -169,7 +169,7 @@ describe('SinonServer', () => { ], }), ); - server.database.addCollection( + server.addCollection( 'bars', new Collection({ items: [{ id: 0, name: 'a', foo_id: 1 }] }), ); @@ -194,7 +194,7 @@ describe('SinonServer', () => { it('should respond to GET /foo?queryString with pagination by sending the correct content-range header', async () => { const server = new SinonServer(); - server.database.addCollection( + server.addCollection( 'foo', new Collection({ items: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}], @@ -233,7 +233,7 @@ describe('SinonServer', () => { it('should respond to GET /foo on an empty collection with a []', async () => { const server = new SinonServer(); - server.database.addCollection('foo', new Collection()); + server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); await server.handle(request); @@ -247,7 +247,7 @@ describe('SinonServer', () => { it('should respond to POST /foo by adding an item to collection foo', async () => { const server = new SinonServer(); - server.database.addCollection( + server.addCollection( 'foo', new Collection({ items: [ @@ -300,7 +300,7 @@ describe('SinonServer', () => { it('should respond to GET /foo/:id by sending element of identifier id in collection foo', async () => { const server = new SinonServer(); - server.database.addCollection( + server.addCollection( 'foo', new Collection({ items: [ @@ -322,7 +322,7 @@ describe('SinonServer', () => { it('should respond to GET /foo/:id on a non-existing id with a 404', async () => { const server = new SinonServer(); - server.database.addCollection('foo', new Collection()); + server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo/3'); if (request == null) throw new Error('request is null'); await server.handle(request); @@ -331,7 +331,7 @@ describe('SinonServer', () => { it('should respond to PUT /foo/:id by updating element of identifier id in collection foo', async () => { const server = new SinonServer(); - server.database.addCollection( + server.addCollection( 'foo', new Collection({ items: [ @@ -361,7 +361,7 @@ describe('SinonServer', () => { it('should respond to PUT /foo/:id on a non-existing id with a 404', async () => { const server = new SinonServer(); - server.database.addCollection('foo', new Collection({ items: [] })); + server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest( 'PUT', '/foo/3', @@ -374,7 +374,7 @@ describe('SinonServer', () => { it('should respond to PATCH /foo/:id by updating element of identifier id in collection foo', async () => { const server = new SinonServer(); - server.database.addCollection( + server.addCollection( 'foo', new Collection({ items: [ @@ -404,7 +404,7 @@ describe('SinonServer', () => { it('should respond to PATCH /foo/:id on a non-existing id with a 404', async () => { const server = new SinonServer(); - server.database.addCollection('foo', new Collection({ items: [] })); + server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest( 'PATCH', '/foo/3', @@ -417,7 +417,7 @@ describe('SinonServer', () => { it('should respond to DELETE /foo/:id by removing element of identifier id in collection foo', async () => { const server = new SinonServer(); - server.database.addCollection( + server.addCollection( 'foo', new Collection({ items: [ @@ -442,7 +442,7 @@ describe('SinonServer', () => { it('should respond to DELETE /foo/:id on a non-existing id with a 404', async () => { const server = new SinonServer(); - server.database.addCollection('foo', new Collection({ items: [] })); + server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest('DELETE', '/foo/3'); if (request == null) throw new Error('request is null'); await server.handle(request); @@ -451,7 +451,7 @@ describe('SinonServer', () => { it('should respond to GET /foo/ with single item', async () => { const server = new SinonServer(); - server.database.addSingle('foo', new Single({ name: 'foo' })); + server.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); @@ -466,7 +466,7 @@ describe('SinonServer', () => { it('should respond to PUT /foo/ by updating the singleton record', async () => { const server = new SinonServer(); - server.database.addSingle('foo', new Single({ name: 'foo' })); + server.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest( 'PUT', @@ -486,7 +486,7 @@ describe('SinonServer', () => { it('should respond to PATCH /foo/ by updating the singleton record', async () => { const server = new SinonServer(); - server.database.addSingle('foo', new Single({ name: 'foo' })); + server.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest( 'PATCH', @@ -508,7 +508,7 @@ describe('SinonServer', () => { describe('setDefaultQuery', () => { it('should set the default query string', async () => { const server = new SinonServer(); - server.database.addCollection( + server.addCollection( 'foo', new Collection({ items: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}], @@ -531,7 +531,7 @@ describe('SinonServer', () => { it('should not override any provided query string', async () => { const server = new SinonServer(); - server.database.addCollection( + server.addCollection( 'foo', new Collection({ items: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}], From f1c7732a217266c416a0a90759f7e74cad2a8923 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 17:48:16 +0200 Subject: [PATCH 30/33] Fix examples middlewares --- example/fetchMock.ts | 2 +- example/msw.ts | 2 +- example/sinon.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/example/fetchMock.ts b/example/fetchMock.ts index 9146652..e7d8fd1 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -27,7 +27,7 @@ export const initializeFetchMock = () => { restServer.addMiddleware(async (request, context, next) => { if (context.collection === 'books' && request.method === 'POST') { if ( - restServer.collections[context.collection].getCount({ + restServer.database.getCount(context.collection, { filter: { title: context.requestBody?.title, }, diff --git a/example/msw.ts b/example/msw.ts index da8e059..f52a448 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -22,7 +22,7 @@ restServer.addMiddleware(async (request, context, next) => { restServer.addMiddleware(async (request, context, next) => { if (context.collection === 'books' && request.method === 'POST') { if ( - restServer.collections[context.collection].getCount({ + restServer.database.getCount(context.collection, { filter: { title: context.requestBody?.title, }, diff --git a/example/sinon.ts b/example/sinon.ts index 942f53a..abf95a8 100644 --- a/example/sinon.ts +++ b/example/sinon.ts @@ -26,7 +26,7 @@ export const initializeSinon = () => { restServer.addMiddleware(async (request, context, next) => { if (context.collection === 'books' && request.method === 'POST') { if ( - restServer.collections[context.collection].getCount({ + restServer.database.getCount(context.collection, { filter: { title: context.requestBody?.title, }, From e0f62b95f999ddd138013f85fefeeeac621e102e Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 17:49:02 +0200 Subject: [PATCH 31/33] Fix exports --- src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/index.ts b/src/index.ts index 34b54ad..4a7c59d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,11 +9,15 @@ import { FetchMockServer, } from './adapters/FetchMockServer.js'; import { getMswHandler, MswServer } from './adapters/MswServer.js'; +import { Database } from './Database.js'; +import { BaseServer } from './BaseServer.js'; import { Collection } from './Collection.js'; import { Single } from './Single.js'; import { withDelay } from './withDelay.js'; export { + BaseServer, + Database, getSinonHandler, getFetchMockHandler, getMswHandler, @@ -28,6 +32,8 @@ export { }; export default { + BaseServer, + Database, getSinonHandler, getFetchMockHandler, getMswHandler, From e993351737c6bf1f5a08be343a2b26ab3f40c41e Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 17:53:09 +0200 Subject: [PATCH 32/33] Add configuration section in docs --- README.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c9363af..de2ea86 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,18 @@ fetchMock.mock('begin:http://localhost:3000', restServer.getHandler()); FakeRest will now intercept every `fetch` requests to the REST server. +## Concepts + +### Server + +### Database + +### Collections + +### Single + +### Embeds + ## REST Flavor FakeRest defines a REST flavor, described below. It is inspired by commonly used ways how to handle aspects like filtering and sorting. @@ -531,25 +543,75 @@ restServer.addMiddleware(withDelay(300)); ## Configuration +### Configure Identifiers + +By default, FakeRest assume all records have a unique `id` field. +Some database such as [MongoDB](https://www.mongodb.com) use `_id` instead of `id` for collection identifiers. +You can customize FakeRest to do the same by using the `identifierName` option: + +```js +import { MswServer } from 'fakerest'; + +const restServer = new MswServer({ + baseUrl: 'http://my.custom.domain', + identifierName: '_id' +}); +``` + +This can also be specified at the collection level: + +```js +import { MswServer, Collection } from 'fakerest'; + +const restServer = new MswServer({ baseUrl: 'http://my.custom.domain' }); +const authorsCollection = new Collection({ items: [], identifierName: '_id' }); +restServer.addCollection('authors', authorsCollection); +``` + ### Configure Identifiers Generation -By default, FakeRest uses an auto incremented sequence for the items identifiers. If you'd rather use UUIDs for instance but would like to avoid providing them when you insert new items, you can provide your own function: +By default, FakeRest uses an auto incremented sequence for the items identifiers. +If you'd rather use UUIDs for instance but would like to avoid providing them when you insert new items, you can provide your own function: ```js -import FakeRest from 'fakerest'; +import { MswServer } from 'fakerest'; import uuid from 'uuid'; -const restServer = new FakeRest.SinonServer({ baseUrl: 'http://my.custom.domain', getNewId: () => uuid.v5() }); +const restServer = new MswServer({ + baseUrl: 'http://my.custom.domain', + getNewId: () => uuid.v5() +}); ``` This can also be specified at the collection level: ```js -import FakeRest from 'fakerest'; +import { MswServer, Collection } from 'fakerest'; +import uuid from 'uuid'; + +const restServer = new MswServer({ baseUrl: 'http://my.custom.domain' }); +const authorsCollection = new Collection({ items: [], getNewId: () => uuid.v5() }); +restServer.addCollection('authors', authorsCollection); +``` + +### Configure Default Queries + +Some APIs might enforce some parameters on queries. For instance, an API might always include an [embed](#embed) or enforce a query filter. +You can simulate this using the `defaultQuery` parameter: + +```js +import { MswServer } from 'fakerest'; import uuid from 'uuid'; -const restServer = new FakeRest.SinonServer({ baseUrl: 'http://my.custom.domain' }); -const authorsCollection = new FakeRest.Collection({ items: [], identifierName: '_id', getNewId: () => uuid.v5() }); +const restServer = new MswServer({ + baseUrl: 'http://my.custom.domain', + getNewId: () => uuid.v5(), + defaultQuery: (collection) => { + if (resourceName == 'authors') return { embed: ['books'] } + if (resourceName == 'books') return { filter: { published: true } } + return {}; + } +}); ``` ## Development From f6ef7c2ffa3f86d0f78f380a79d270683d02db92 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 6 Jun 2024 18:06:52 +0200 Subject: [PATCH 33/33] Add concepts --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index de2ea86..0bdf5a1 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ FakeRest will now intercept every `XmlHttpRequest` requests to the REST server. ### fetch-mock -First, install fakerest and fetch-mock: +First, install fakerest and [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/): ```sh npm install fakerest fetch-mock --save-dev @@ -251,14 +251,28 @@ FakeRest will now intercept every `fetch` requests to the REST server. ### Server +A fake server implementation. FakeRest provide the following: + +- `MswServer`: Based on [MSW](https://mswjs.io/) +- `FetchMockServer`: Based on [`fetch-mock`](https://www.wheresrhys.co.uk/fetch-mock/) +- `SinonServer`: Based on [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/) + ### Database +FakeRest internal database, that contains [collections](#collections) and [single](#single). + ### Collections +The equivalent to a classic database table or document collection. It supports filtering. + ### Single +Represent an API endpoint that returns a single entity. Useful for things such as user profile routes (`/me`) or global settings (`/settings`). + ### Embeds +FakeRest support embedding other resources in a main resource query result. For instance, embedding the author of a book. + ## REST Flavor FakeRest defines a REST flavor, described below. It is inspired by commonly used ways how to handle aspects like filtering and sorting.