From 9f89825fbd832d675da23efa05a4d22393ee0fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Thu, 6 Jun 2024 23:42:02 +0200 Subject: [PATCH 01/16] Make fetch-mock use baseServer as a provider instead of parent --- example/fetchMock.ts | 73 +++++++++--------- src/BaseServer.ts | 128 ++++++++++++++++---------------- src/adapters/FetchMockServer.ts | 73 ++++++++++-------- src/withDelay.ts | 6 +- 4 files changed, 143 insertions(+), 137 deletions(-) diff --git a/example/fetchMock.ts b/example/fetchMock.ts index e7d8fd1..b055de4 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -8,47 +8,50 @@ export const initializeFetchMock = () => { baseUrl: 'http://localhost:3000', data, loggingEnabled: true, + middlewares: [ + withDelay(300), + async (context, next) => { + if (!context.headers?.get('Authorization')) { + return { + status: 401, + headers: {}, + }; + } + return next(context); + }, + async (context, next) => { + if ( + context.collection === 'books' && + context.method === 'POST' + ) { + if ( + data[context.collection].some( + (book) => book.title === context.requestBody?.title, + ) + ) { + 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(context); + }, + ], }); if (window) { // @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 { - status: 401, - headers: {}, - }; - } - return next(request, context); - }); - restServer.addMiddleware(async (request, context, next) => { - if (context.collection === 'books' && request.method === 'POST') { - if ( - restServer.database.getCount(context.collection, { - filter: { - title: context.requestBody?.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); - }); fetchMock.mock('begin:http://localhost:3000', restServer.getHandler()); }; diff --git a/src/BaseServer.ts b/src/BaseServer.ts index c6ef837..5c0c033 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -3,20 +3,22 @@ import { Database, type DatabaseOptions } from './Database.js'; import type { Single } from './Single.js'; import type { CollectionItem, QueryFunction } from './types.js'; -export class BaseServer { +export class BaseServer { baseUrl = ''; defaultQuery: QueryFunction = () => ({}); - middlewares: Array> = []; + middlewares: Array; database: Database; constructor({ baseUrl = '', defaultQuery = () => ({}), database, + middlewares, ...options }: BaseServerOptions = {}) { this.baseUrl = baseUrl; this.defaultQuery = defaultQuery; + this.middlewares = middlewares || []; if (database) { this.database = database; @@ -32,19 +34,19 @@ export class BaseServer { this.defaultQuery = query; } - getContext(context: NormalizedRequest): FakeRestContext { + getContext(normalizedRequest: NormalizedRequest): FakeRestContext { for (const name of this.database.getSingleNames()) { - const matches = context.url?.match( + const matches = normalizedRequest.url?.match( new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), ); if (!matches) continue; return { - ...context, + ...normalizedRequest, single: name, }; } - const matches = context.url?.match( + const matches = normalizedRequest.url?.match( new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), ); if (matches) { @@ -52,72 +54,55 @@ export class BaseServer { const params = Object.assign( {}, this.defaultQuery(name), - context.params, + normalizedRequest.params, ); return { - ...context, + ...normalizedRequest, collection: name, params, }; } - return context; + return normalizedRequest; } - getNormalizedRequest(request: RequestType): Promise { - throw new Error('Not implemented'); - } - - respond( - response: BaseResponse | null, - request: RequestType, - context: FakeRestContext, - ): Promise { - throw new Error('Not implemented'); - } - - async handle(request: RequestType): Promise { - const context = this.getContext( - await this.getNormalizedRequest(request), - ); - + async handle(normalizedRequest: NormalizedRequest): Promise { + const context = this.getContext(normalizedRequest); // Call middlewares let index = 0; const middlewares = [...this.middlewares]; - const next = (req: RequestType, ctx: FakeRestContext) => { + const next = (context: FakeRestContext) => { const middleware = middlewares[index++]; if (middleware) { - return middleware(req, ctx, next); + return middleware(context, next); } - return this.handleRequest(req, ctx); + return this.handleRequest(context); }; try { - const response = await next(request, context); - if (response != null) { - return this.respond(response, request, context); - } + const response = await next(context); + return response; } catch (error) { if (error instanceof Error) { throw error; } - return error as ResponseType; + return error as BaseResponse; } } - handleRequest(request: RequestType, ctx: FakeRestContext): BaseResponse { + handleRequest(context: FakeRestContext): BaseResponse { // Handle Single Objects for (const name of this.database.getSingleNames()) { - const matches = ctx.url?.match( + const matches = context.url?.match( new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), ); if (!matches) continue; - if (ctx.method === 'GET') { + if (context.method === 'GET') { try { return { status: 200, @@ -133,9 +118,9 @@ export class BaseServer { }; } } - if (ctx.method === 'PUT') { + if (context.method === 'PUT') { try { - if (ctx.requestBody == null) { + if (context.requestBody == null) { return { status: 400, headers: {}, @@ -143,7 +128,10 @@ export class BaseServer { } return { status: 200, - body: this.database.updateOnly(name, ctx.requestBody), + body: this.database.updateOnly( + name, + context.requestBody, + ), headers: { 'Content-Type': 'application/json', }, @@ -155,9 +143,9 @@ export class BaseServer { }; } } - if (ctx.method === 'PATCH') { + if (context.method === 'PATCH') { try { - if (ctx.requestBody == null) { + if (context.requestBody == null) { return { status: 400, headers: {}, @@ -165,7 +153,10 @@ export class BaseServer { } return { status: 200, - body: this.database.updateOnly(name, ctx.requestBody), + body: this.database.updateOnly( + name, + context.requestBody, + ), headers: { 'Content-Type': 'application/json', }, @@ -180,16 +171,20 @@ export class BaseServer { } // handle collections - const matches = ctx.url?.match( + const matches = context.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); + const params = Object.assign( + {}, + this.defaultQuery(name), + context.params, + ); if (!matches[2]) { - if (ctx.method === 'GET') { + if (context.method === 'GET') { if (!this.database.getCollection(name)) { return { status: 404, headers: {} }; } @@ -227,15 +222,18 @@ export class BaseServer { }, }; } - if (ctx.method === 'POST') { - if (ctx.requestBody == null) { + if (context.method === 'POST') { + if (context.requestBody == null) { return { status: 400, headers: {}, }; } - const newResource = this.database.addOne(name, ctx.requestBody); + const newResource = this.database.addOne( + name, + context.requestBody, + ); const newResourceURI = `${this.baseUrl}/${name}/${ newResource[ this.database.getCollection(name).identifierName @@ -256,7 +254,7 @@ export class BaseServer { return { status: 404, headers: {} }; } const id = Number.parseInt(matches[3]); - if (ctx.method === 'GET') { + if (context.method === 'GET') { try { return { status: 200, @@ -272,9 +270,9 @@ export class BaseServer { }; } } - if (ctx.method === 'PUT') { + if (context.method === 'PUT') { try { - if (ctx.requestBody == null) { + if (context.requestBody == null) { return { status: 400, headers: {}, @@ -285,7 +283,7 @@ export class BaseServer { body: this.database.updateOne( name, id, - ctx.requestBody, + context.requestBody, ), headers: { 'Content-Type': 'application/json', @@ -298,9 +296,9 @@ export class BaseServer { }; } } - if (ctx.method === 'PATCH') { + if (context.method === 'PATCH') { try { - if (ctx.requestBody == null) { + if (context.requestBody == null) { return { status: 400, headers: {}, @@ -311,7 +309,7 @@ export class BaseServer { body: this.database.updateOne( name, id, - ctx.requestBody, + context.requestBody, ), headers: { 'Content-Type': 'application/json', @@ -324,7 +322,7 @@ export class BaseServer { }; } } - if (ctx.method === 'DELETE') { + if (context.method === 'DELETE') { try { return { status: 200, @@ -347,7 +345,7 @@ export class BaseServer { }; } - addMiddleware(middleware: Middleware) { + addMiddleware(middleware: Middleware) { this.middlewares.push(middleware); } @@ -382,20 +380,17 @@ export class BaseServer { } } -export type Middleware = ( - request: RequestType, +export type Middleware = ( context: FakeRestContext, - next: ( - req: RequestType, - ctx: FakeRestContext, - ) => Promise | BaseResponse | null, -) => Promise | BaseResponse | null; + next: (context: FakeRestContext) => Promise | BaseResponse, +) => Promise | BaseResponse; export type BaseServerOptions = DatabaseOptions & { database?: Database; baseUrl?: string; batchUrl?: string | null; defaultQuery?: QueryFunction; + middlewares?: Array; }; export type BaseResponse = { @@ -406,6 +401,7 @@ export type BaseResponse = { export type FakeRestContext = { url?: string; + headers?: Headers; method?: string; collection?: string; single?: string; @@ -415,5 +411,5 @@ export type FakeRestContext = { export type NormalizedRequest = Pick< FakeRestContext, - 'url' | 'method' | 'params' | 'requestBody' + 'url' | 'method' | 'params' | 'requestBody' | 'headers' >; diff --git a/src/adapters/FetchMockServer.ts b/src/adapters/FetchMockServer.ts index 0eb3520..ec22b41 100644 --- a/src/adapters/FetchMockServer.ts +++ b/src/adapters/FetchMockServer.ts @@ -1,28 +1,39 @@ -import type { MockResponseObject } from 'fetch-mock'; -import { - type BaseResponse, - BaseServer, - type FakeRestContext, - type BaseServerOptions, -} from '../BaseServer.js'; +import { BaseServer } from '../BaseServer.js'; import { parseQueryString } from '../parseQueryString.js'; +import type { + BaseResponse, + FakeRestContext, + BaseServerOptions, + NormalizedRequest, +} from '../BaseServer.js'; +import type { MockResponseObject } from 'fetch-mock'; -export class FetchMockServer extends BaseServer { +export class FetchMockServer { loggingEnabled = false; + server; constructor({ loggingEnabled = false, + server, ...options }: FetchMockServerOptions = {}) { - super(options); + this.server = server || new BaseServer(options); this.loggingEnabled = loggingEnabled; } - toggleLogging() { - this.loggingEnabled = !this.loggingEnabled; + getHandler() { + const handler = async (url: string, options: RequestInit) => { + const request = new Request(url, options); + const normalizedRequest = await this.getNormalizedRequest(request); + const response = await this.server.handle(normalizedRequest); + this.log(request, response, normalizedRequest); + return response as MockResponseObject; + }; + + return handler; } - async getNormalizedRequest(request: Request) { + async getNormalizedRequest(request: Request): Promise { const req = typeof request === 'string' ? new Request(request) : request; const queryString = req.url @@ -39,32 +50,28 @@ export class FetchMockServer extends BaseServer { return { url: req.url, + headers: req.headers, params, requestBody, method: req.method, }; } - async respond( - response: BaseResponse, - request: FetchMockFakeRestRequest, - context: FakeRestContext, - ) { - this.log(request, response, context); - return response; - } - log( request: FetchMockFakeRestRequest, - response: MockResponseObject, - context: FakeRestContext, + response: BaseResponse, + normalizedRequest: NormalizedRequest, ) { if (!this.loggingEnabled) return; if (console.group) { // Better logging in Chrome - console.groupCollapsed(context.method, context.url, '(FakeRest)'); + console.groupCollapsed( + normalizedRequest.method, + normalizedRequest.url, + '(FakeRest)', + ); console.group('request'); - console.log(context.method, context.url); + console.log(normalizedRequest.method, normalizedRequest.url); console.log('headers', request.headers); console.log('body ', request.requestJson); console.groupEnd(); @@ -76,8 +83,8 @@ export class FetchMockServer extends BaseServer { } else { console.log( 'FakeRest request ', - context.method, - context.url, + normalizedRequest.method, + normalizedRequest.url, 'headers', request.headers, 'body', @@ -94,12 +101,8 @@ export class FetchMockServer extends BaseServer { } } - getHandler() { - const handler = (url: string, options: RequestInit) => { - return this.handle(new Request(url, options)); - }; - - return handler; + toggleLogging() { + this.loggingEnabled = !this.loggingEnabled; } } @@ -122,5 +125,9 @@ export type FetchMockFakeRestRequest = Partial & { }; export type FetchMockServerOptions = BaseServerOptions & { + server?: { + getContext: (context: NormalizedRequest) => FakeRestContext; + handle: (context: FakeRestContext) => Promise; + }; loggingEnabled?: boolean; }; diff --git a/src/withDelay.ts b/src/withDelay.ts index dd22c1e..c18d3d0 100644 --- a/src/withDelay.ts +++ b/src/withDelay.ts @@ -1,11 +1,11 @@ import type { Middleware } from './BaseServer.js'; export const withDelay = - (delayMs: number): Middleware => - (request, context, next) => { + (delayMs: number): Middleware => + (context, next) => { return new Promise((resolve) => { setTimeout(() => { - resolve(next(request, context)); + resolve(next(context)); }, delayMs); }); }; From 442c457c361185b0248ef2d74d6da6eab097fb99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Fri, 7 Jun 2024 00:00:20 +0200 Subject: [PATCH 02/16] Implement MSW --- example/msw.ts | 84 ++++++++++++++++----------------- src/BaseServer.ts | 1 - src/adapters/FetchMockServer.ts | 1 - src/adapters/MswServer.ts | 53 ++++++++++++++------- 4 files changed, 79 insertions(+), 60 deletions(-) diff --git a/example/msw.ts b/example/msw.ts index f52a448..256530a 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -3,49 +3,49 @@ import { MswServer, withDelay } from '../src'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; -const restServer = new MswServer({ - baseUrl: 'http://localhost:3000', - data, -}); - -restServer.addMiddleware(withDelay(300)); -restServer.addMiddleware(async (request, context, next) => { - if (!request.headers?.get('Authorization')) { - return { - status: 401, - headers: {}, - }; - } - return next(request, context); -}); - -restServer.addMiddleware(async (request, context, next) => { - if (context.collection === 'books' && request.method === 'POST') { - if ( - restServer.database.getCount(context.collection, { - filter: { - title: context.requestBody?.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); -}); - export const initializeMsw = async () => { + const restServer = new MswServer({ + baseUrl: 'http://localhost:3000', + data, + middlewares: [ + withDelay(300), + async (context, next) => { + if (!context.headers?.get('Authorization')) { + return { + status: 401, + headers: {}, + }; + } + return next(context); + }, + async (context, next) => { + if ( + context.collection === 'books' && + context.method === 'POST' + ) { + if ( + data[context.collection].some( + (book) => book.title === context.requestBody?.title, + ) + ) { + 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(context); + }, + ], + }); const worker = setupWorker(restServer.getHandler()); return worker.start({ quiet: true, // Instruct MSW to not log requests in the console diff --git a/src/BaseServer.ts b/src/BaseServer.ts index 5c0c033..fe75fca 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -78,7 +78,6 @@ export class BaseServer { if (middleware) { return middleware(context, next); } - return this.handleRequest(context); }; diff --git a/src/adapters/FetchMockServer.ts b/src/adapters/FetchMockServer.ts index ec22b41..3e62113 100644 --- a/src/adapters/FetchMockServer.ts +++ b/src/adapters/FetchMockServer.ts @@ -126,7 +126,6 @@ export type FetchMockFakeRestRequest = Partial & { export type FetchMockServerOptions = BaseServerOptions & { server?: { - getContext: (context: NormalizedRequest) => FakeRestContext; handle: (context: FakeRestContext) => Promise; }; loggingEnabled?: boolean; diff --git a/src/adapters/MswServer.ts b/src/adapters/MswServer.ts index 6b263de..a30e0af 100644 --- a/src/adapters/MswServer.ts +++ b/src/adapters/MswServer.ts @@ -1,16 +1,37 @@ import { http, HttpResponse } from 'msw'; -import { type BaseResponse, BaseServer } from '../BaseServer.js'; +import { BaseServer } from '../BaseServer.js'; +import type { + BaseResponse, + FakeRestContext, + BaseServerOptions, + NormalizedRequest, +} 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, - }); +export class MswServer { + server; + + constructor({ server, ...options }: MswServerOptions) { + this.server = server || new BaseServer(options); + } + + getHandler() { + return http.all( + // Using a regex ensures we match all URLs that start with the collection name + new RegExp(`${this.server.baseUrl}`), + async ({ request }) => { + const normalizedRequest = + await this.getNormalizedRequest(request); + const response = await this.server.handle(normalizedRequest); + return HttpResponse.json(response.body, { + status: response.status, + headers: response.headers, + }); + }, + ); } - async getNormalizedRequest(request: Request) { + async getNormalizedRequest(request: Request): Promise { const url = new URL(request.url); const params = Object.fromEntries( Array.from(new URLSearchParams(url.search).entries()).map( @@ -27,22 +48,22 @@ export class MswServer extends BaseServer { return { url: request.url, + headers: request.headers, params, requestBody, 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(); }; + +export type MswServerOptions = BaseServerOptions & { + server?: { + baseUrl?: string; + handle: (context: FakeRestContext) => Promise; + }; +}; From e980758f2aa40e66e732ca7977bf53a0e83732d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Fri, 7 Jun 2024 00:15:25 +0200 Subject: [PATCH 03/16] extract types --- example/msw.ts | 6 +++--- src/BaseServer.ts | 23 ++++++----------------- src/adapters/FetchMockServer.ts | 14 ++++---------- src/adapters/MswServer.ts | 18 +++++------------- src/types.ts | 21 +++++++++++++++++++++ 5 files changed, 39 insertions(+), 43 deletions(-) diff --git a/example/msw.ts b/example/msw.ts index 256530a..471d2aa 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -1,10 +1,10 @@ import { setupWorker } from 'msw/browser'; -import { MswServer, withDelay } from '../src'; +import { getMswHandler, withDelay } from '../src'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; export const initializeMsw = async () => { - const restServer = new MswServer({ + const handler = getMswHandler({ baseUrl: 'http://localhost:3000', data, middlewares: [ @@ -46,7 +46,7 @@ export const initializeMsw = async () => { }, ], }); - const worker = setupWorker(restServer.getHandler()); + const worker = setupWorker(handler); 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 diff --git a/src/BaseServer.ts b/src/BaseServer.ts index fe75fca..fa9fa3a 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -1,7 +1,12 @@ import type { Collection } from './Collection.js'; import { Database, type DatabaseOptions } from './Database.js'; import type { Single } from './Single.js'; -import type { CollectionItem, QueryFunction } from './types.js'; +import type { + BaseResponse, + FakeRestContext, + CollectionItem, + QueryFunction, +} from './types.js'; export class BaseServer { baseUrl = ''; @@ -392,22 +397,6 @@ export type BaseServerOptions = DatabaseOptions & { middlewares?: Array; }; -export type BaseResponse = { - status: number; - body?: Record | Record[]; - headers: { [key: string]: string }; -}; - -export type FakeRestContext = { - url?: string; - headers?: Headers; - method?: string; - collection?: string; - single?: string; - requestBody: Record | undefined; - params: { [key: string]: any }; -}; - export type NormalizedRequest = Pick< FakeRestContext, 'url' | 'method' | 'params' | 'requestBody' | 'headers' diff --git a/src/adapters/FetchMockServer.ts b/src/adapters/FetchMockServer.ts index 3e62113..0fd4703 100644 --- a/src/adapters/FetchMockServer.ts +++ b/src/adapters/FetchMockServer.ts @@ -1,16 +1,12 @@ import { BaseServer } from '../BaseServer.js'; import { parseQueryString } from '../parseQueryString.js'; -import type { - BaseResponse, - FakeRestContext, - BaseServerOptions, - NormalizedRequest, -} from '../BaseServer.js'; +import type { BaseServerOptions, NormalizedRequest } from '../BaseServer.js'; +import type { BaseResponse, APIServer } from '../types.js'; import type { MockResponseObject } from 'fetch-mock'; export class FetchMockServer { loggingEnabled = false; - server; + server: APIServer; constructor({ loggingEnabled = false, @@ -125,8 +121,6 @@ export type FetchMockFakeRestRequest = Partial & { }; export type FetchMockServerOptions = BaseServerOptions & { - server?: { - handle: (context: FakeRestContext) => Promise; - }; + server?: APIServer; loggingEnabled?: boolean; }; diff --git a/src/adapters/MswServer.ts b/src/adapters/MswServer.ts index a30e0af..6bc7b26 100644 --- a/src/adapters/MswServer.ts +++ b/src/adapters/MswServer.ts @@ -1,15 +1,10 @@ import { http, HttpResponse } from 'msw'; import { BaseServer } from '../BaseServer.js'; -import type { - BaseResponse, - FakeRestContext, - BaseServerOptions, - NormalizedRequest, -} from '../BaseServer.js'; -import type { DatabaseOptions } from '../Database.js'; +import type { BaseServerOptions, NormalizedRequest } from '../BaseServer.js'; +import type { APIServer } from '../types.js'; export class MswServer { - server; + server: APIServer; constructor({ server, ...options }: MswServerOptions) { this.server = server || new BaseServer(options); @@ -56,14 +51,11 @@ export class MswServer { } } -export const getMswHandler = (options: DatabaseOptions) => { +export const getMswHandler = (options: MswServerOptions) => { const server = new MswServer(options); return server.getHandler(); }; export type MswServerOptions = BaseServerOptions & { - server?: { - baseUrl?: string; - handle: (context: FakeRestContext) => Promise; - }; + server?: APIServer; }; diff --git a/src/types.ts b/src/types.ts index 6df14e1..d27aefb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,3 +31,24 @@ export type Predicate = ( ) => boolean; export type Embed = string | string[]; + +export type BaseResponse = { + status: number; + body?: Record | Record[]; + headers: { [key: string]: string }; +}; + +export type FakeRestContext = { + url?: string; + headers?: Headers; + method?: string; + collection?: string; + single?: string; + requestBody: Record | undefined; + params: { [key: string]: any }; +}; + +export type APIServer = { + baseUrl?: string; + handle: (context: FakeRestContext) => Promise; +}; From d77bafaeea3355350a1360fcb8150aa516ea3f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Fri, 7 Jun 2024 00:30:59 +0200 Subject: [PATCH 04/16] Make sinon work --- example/sinon.ts | 49 +++++++++---------------------- src/BaseServer.ts | 6 +--- src/adapters/FetchMockServer.ts | 4 +-- src/adapters/MswServer.ts | 4 +-- src/adapters/SinonServer.ts | 51 +++++++++++++++++---------------- src/types.ts | 5 ++++ 6 files changed, 49 insertions(+), 70 deletions(-) diff --git a/example/sinon.ts b/example/sinon.ts index abf95a8..941eb8d 100644 --- a/example/sinon.ts +++ b/example/sinon.ts @@ -9,42 +9,19 @@ export const initializeSinon = () => { baseUrl: 'http://localhost:3000', data, loggingEnabled: true, - }); - - restServer.addMiddleware(withDelay(300)); - restServer.addMiddleware(async (request, context, next) => { - if (request.requestHeaders.Authorization === undefined) { - return { - status: 401, - headers: {}, - }; - } - - return next(request, context); - }); - - restServer.addMiddleware(async (request, context, next) => { - if (context.collection === 'books' && request.method === 'POST') { - if ( - restServer.database.getCount(context.collection, { - filter: { - title: context.requestBody?.title, - }, - }) > 0 - ) { - return { - status: 400, - headers: {}, - body: { - errors: { - title: 'An article with this title already exists. The title must be unique.', - }, - }, - }; - } - } - - return next(request, context); + middlewares: [ + withDelay(300), + async (context, next) => { + if (!context.headers?.get('Authorization')) { + return { + status: 401, + headers: {}, + }; + } + return next(context); + }, + // FIXME: add validation middleware + ], }); // use sinon.js to monkey-patch XmlHttpRequest diff --git a/src/BaseServer.ts b/src/BaseServer.ts index fa9fa3a..5a79288 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -6,6 +6,7 @@ import type { FakeRestContext, CollectionItem, QueryFunction, + NormalizedRequest, } from './types.js'; export class BaseServer { @@ -396,8 +397,3 @@ export type BaseServerOptions = DatabaseOptions & { defaultQuery?: QueryFunction; middlewares?: Array; }; - -export type NormalizedRequest = Pick< - FakeRestContext, - 'url' | 'method' | 'params' | 'requestBody' | 'headers' ->; diff --git a/src/adapters/FetchMockServer.ts b/src/adapters/FetchMockServer.ts index 0fd4703..afcf813 100644 --- a/src/adapters/FetchMockServer.ts +++ b/src/adapters/FetchMockServer.ts @@ -1,7 +1,7 @@ import { BaseServer } from '../BaseServer.js'; import { parseQueryString } from '../parseQueryString.js'; -import type { BaseServerOptions, NormalizedRequest } from '../BaseServer.js'; -import type { BaseResponse, APIServer } from '../types.js'; +import type { BaseServerOptions } from '../BaseServer.js'; +import type { BaseResponse, APIServer, NormalizedRequest } from '../types.js'; import type { MockResponseObject } from 'fetch-mock'; export class FetchMockServer { diff --git a/src/adapters/MswServer.ts b/src/adapters/MswServer.ts index 6bc7b26..ee8f134 100644 --- a/src/adapters/MswServer.ts +++ b/src/adapters/MswServer.ts @@ -1,7 +1,7 @@ import { http, HttpResponse } from 'msw'; import { BaseServer } from '../BaseServer.js'; -import type { BaseServerOptions, NormalizedRequest } from '../BaseServer.js'; -import type { APIServer } from '../types.js'; +import type { BaseServerOptions } from '../BaseServer.js'; +import type { APIServer, NormalizedRequest } from '../types.js'; export class MswServer { server: APIServer; diff --git a/src/adapters/SinonServer.ts b/src/adapters/SinonServer.ts index 1e38b5b..4d4f226 100644 --- a/src/adapters/SinonServer.ts +++ b/src/adapters/SinonServer.ts @@ -1,30 +1,39 @@ import type { SinonFakeXMLHttpRequest } from 'sinon'; -import { - type BaseResponse, - BaseServer, - type BaseServerOptions, -} from '../BaseServer.js'; +import { BaseServer, type BaseServerOptions } from '../BaseServer.js'; import { parseQueryString } from '../parseQueryString.js'; +import type { BaseResponse, APIServer, NormalizedRequest } from '../types.js'; -export class SinonServer extends BaseServer< - SinonFakeXMLHttpRequest, - SinonFakeRestResponse -> { +export class SinonServer { loggingEnabled = false; + server: APIServer; constructor({ loggingEnabled = false, + server, ...options }: SinonServerOptions = {}) { - super(options); + this.server = server || new BaseServer(options); this.loggingEnabled = loggingEnabled; } - toggleLogging() { - this.loggingEnabled = !this.loggingEnabled; + getHandler() { + return (request: SinonFakeXMLHttpRequest) => { + // 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; + const normalizedRequest = this.getNormalizedRequest(request); + this.server + .handle(normalizedRequest) + .then((response) => this.respond(response, request)); + // Let Sinon know we've handled the request + return true; + }; } - async getNormalizedRequest(request: SinonFakeXMLHttpRequest) { + getNormalizedRequest(request: SinonFakeXMLHttpRequest): NormalizedRequest { const req: Request | SinonFakeXMLHttpRequest = typeof request === 'string' ? new Request(request) : request; @@ -45,6 +54,7 @@ export class SinonServer extends BaseServer< return { url: req.url, + headers: new Headers(request.requestHeaders), params, requestBody, method: req.method, @@ -135,18 +145,8 @@ export class SinonServer extends BaseServer< } } - getHandler() { - return (request: SinonFakeXMLHttpRequest) => { - // 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; - }; + toggleLogging() { + this.loggingEnabled = !this.loggingEnabled; } } @@ -167,5 +167,6 @@ export type SinonFakeRestResponse = { }; export type SinonServerOptions = BaseServerOptions & { + server?: APIServer; loggingEnabled?: boolean; }; diff --git a/src/types.ts b/src/types.ts index d27aefb..465df28 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,6 +48,11 @@ export type FakeRestContext = { params: { [key: string]: any }; }; +export type NormalizedRequest = Pick< + FakeRestContext, + 'url' | 'method' | 'params' | 'requestBody' | 'headers' +>; + export type APIServer = { baseUrl?: string; handle: (context: FakeRestContext) => Promise; From 29c88b9cc2409733a01ebd93759c16f4655a37d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Fri, 7 Jun 2024 00:33:24 +0200 Subject: [PATCH 05/16] Rename BaseServer --- src/{BaseServer.ts => SimpleRestServer.ts} | 2 +- src/adapters/FetchMockServer.ts | 6 +++--- src/adapters/MswServer.ts | 6 +++--- src/adapters/SinonServer.spec.ts | 2 +- src/adapters/SinonServer.ts | 7 +++++-- src/index.ts | 6 +++--- src/withDelay.ts | 2 +- 7 files changed, 17 insertions(+), 14 deletions(-) rename src/{BaseServer.ts => SimpleRestServer.ts} (99%) diff --git a/src/BaseServer.ts b/src/SimpleRestServer.ts similarity index 99% rename from src/BaseServer.ts rename to src/SimpleRestServer.ts index 5a79288..1aba889 100644 --- a/src/BaseServer.ts +++ b/src/SimpleRestServer.ts @@ -9,7 +9,7 @@ import type { NormalizedRequest, } from './types.js'; -export class BaseServer { +export class SimpleRestServer { baseUrl = ''; defaultQuery: QueryFunction = () => ({}); middlewares: Array; diff --git a/src/adapters/FetchMockServer.ts b/src/adapters/FetchMockServer.ts index afcf813..f38c300 100644 --- a/src/adapters/FetchMockServer.ts +++ b/src/adapters/FetchMockServer.ts @@ -1,6 +1,6 @@ -import { BaseServer } from '../BaseServer.js'; +import { SimpleRestServer } from '../SimpleRestServer.js'; import { parseQueryString } from '../parseQueryString.js'; -import type { BaseServerOptions } from '../BaseServer.js'; +import type { BaseServerOptions } from '../SimpleRestServer.js'; import type { BaseResponse, APIServer, NormalizedRequest } from '../types.js'; import type { MockResponseObject } from 'fetch-mock'; @@ -13,7 +13,7 @@ export class FetchMockServer { server, ...options }: FetchMockServerOptions = {}) { - this.server = server || new BaseServer(options); + this.server = server || new SimpleRestServer(options); this.loggingEnabled = loggingEnabled; } diff --git a/src/adapters/MswServer.ts b/src/adapters/MswServer.ts index ee8f134..66ad20f 100644 --- a/src/adapters/MswServer.ts +++ b/src/adapters/MswServer.ts @@ -1,13 +1,13 @@ import { http, HttpResponse } from 'msw'; -import { BaseServer } from '../BaseServer.js'; -import type { BaseServerOptions } from '../BaseServer.js'; +import { SimpleRestServer } from '../SimpleRestServer.js'; +import type { BaseServerOptions } from '../SimpleRestServer.js'; import type { APIServer, NormalizedRequest } from '../types.js'; export class MswServer { server: APIServer; constructor({ server, ...options }: MswServerOptions) { - this.server = server || new BaseServer(options); + this.server = server || new SimpleRestServer(options); } getHandler() { diff --git a/src/adapters/SinonServer.spec.ts b/src/adapters/SinonServer.spec.ts index ed9012e..5fac77d 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 '../BaseServer.js'; +import type { BaseResponse } from '../SimpleRestServer.js'; function getFakeXMLHTTPRequest( method: string, diff --git a/src/adapters/SinonServer.ts b/src/adapters/SinonServer.ts index 4d4f226..12d8b5c 100644 --- a/src/adapters/SinonServer.ts +++ b/src/adapters/SinonServer.ts @@ -1,5 +1,8 @@ import type { SinonFakeXMLHttpRequest } from 'sinon'; -import { BaseServer, type BaseServerOptions } from '../BaseServer.js'; +import { + SimpleRestServer, + type BaseServerOptions, +} from '../SimpleRestServer.js'; import { parseQueryString } from '../parseQueryString.js'; import type { BaseResponse, APIServer, NormalizedRequest } from '../types.js'; @@ -12,7 +15,7 @@ export class SinonServer { server, ...options }: SinonServerOptions = {}) { - this.server = server || new BaseServer(options); + this.server = server || new SimpleRestServer(options); this.loggingEnabled = loggingEnabled; } diff --git a/src/index.ts b/src/index.ts index 4a7c59d..b89b11c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,13 +10,13 @@ import { } from './adapters/FetchMockServer.js'; import { getMswHandler, MswServer } from './adapters/MswServer.js'; import { Database } from './Database.js'; -import { BaseServer } from './BaseServer.js'; +import { SimpleRestServer } from './SimpleRestServer.js'; import { Collection } from './Collection.js'; import { Single } from './Single.js'; import { withDelay } from './withDelay.js'; export { - BaseServer, + SimpleRestServer, Database, getSinonHandler, getFetchMockHandler, @@ -32,7 +32,7 @@ export { }; export default { - BaseServer, + SimpleRestServer, Database, getSinonHandler, getFetchMockHandler, diff --git a/src/withDelay.ts b/src/withDelay.ts index c18d3d0..fed5bc3 100644 --- a/src/withDelay.ts +++ b/src/withDelay.ts @@ -1,4 +1,4 @@ -import type { Middleware } from './BaseServer.js'; +import type { Middleware } from './SimpleRestServer.js'; export const withDelay = (delayMs: number): Middleware => From 15d85d02ec6dbcd66a1fed8920267ec5cc81c9c6 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 7 Jun 2024 09:50:39 +0200 Subject: [PATCH 06/16] Fix middlewares in Sinon and MWS --- .gitignore | 2 -- .prettierrc | 3 +++ Makefile | 1 - example/fetchMock.ts | 40 ++-------------------------------------- example/middlewares.ts | 35 +++++++++++++++++++++++++++++++++++ example/msw.ts | 40 ++-------------------------------------- example/sinon.ts | 19 ++++--------------- 7 files changed, 46 insertions(+), 94 deletions(-) create mode 100644 .prettierrc create mode 100644 example/middlewares.ts diff --git a/.gitignore b/.gitignore index 328ffa6..f06235c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,2 @@ node_modules -bower_components dist -example/ng-admin/bower-components diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ab28f3e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "tabWidth": 4 +} \ No newline at end of file diff --git a/Makefile b/Makefile index a93f5bf..6293ec9 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,6 @@ install: @npm install - @bower install build-dev: @NODE_ENV=development npm run build diff --git a/example/fetchMock.ts b/example/fetchMock.ts index b055de4..ff2a658 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -2,50 +2,14 @@ import fetchMock from 'fetch-mock'; import { FetchMockServer, withDelay } from '../src'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; +import { middlewares } from './middlewares'; export const initializeFetchMock = () => { const restServer = new FetchMockServer({ baseUrl: 'http://localhost:3000', data, loggingEnabled: true, - middlewares: [ - withDelay(300), - async (context, next) => { - if (!context.headers?.get('Authorization')) { - return { - status: 401, - headers: {}, - }; - } - return next(context); - }, - async (context, next) => { - if ( - context.collection === 'books' && - context.method === 'POST' - ) { - if ( - data[context.collection].some( - (book) => book.title === context.requestBody?.title, - ) - ) { - 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(context); - }, - ], + middlewares, }); if (window) { // @ts-ignore diff --git a/example/middlewares.ts b/example/middlewares.ts new file mode 100644 index 0000000..6406a62 --- /dev/null +++ b/example/middlewares.ts @@ -0,0 +1,35 @@ +import { withDelay } from '../src'; +import { data } from './data'; + +export const middlewares = [ + withDelay(300), + async (context, next) => { + if (!context.headers?.get('Authorization')) { + return { + status: 401, + headers: {}, + }; + } + return next(context); + }, + async (context, next) => { + if (context.collection === 'books' && context.method === 'POST') { + if ( + data[context.collection].some( + (book) => book.title === context.requestBody?.title, + ) + ) { + return { + body: { + errors: { + title: 'An article with this title already exists. The title must be unique.', + }, + }, + status: 400, + headers: {}, + }; + } + } + return next(context); + }, +]; diff --git a/example/msw.ts b/example/msw.ts index 471d2aa..ffc465d 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -2,49 +2,13 @@ import { setupWorker } from 'msw/browser'; import { getMswHandler, withDelay } from '../src'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; +import { middlewares } from './middlewares'; export const initializeMsw = async () => { const handler = getMswHandler({ baseUrl: 'http://localhost:3000', data, - middlewares: [ - withDelay(300), - async (context, next) => { - if (!context.headers?.get('Authorization')) { - return { - status: 401, - headers: {}, - }; - } - return next(context); - }, - async (context, next) => { - if ( - context.collection === 'books' && - context.method === 'POST' - ) { - if ( - data[context.collection].some( - (book) => book.title === context.requestBody?.title, - ) - ) { - 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(context); - }, - ], + middlewares, }); const worker = setupWorker(handler); return worker.start({ diff --git a/example/sinon.ts b/example/sinon.ts index 941eb8d..987ea6f 100644 --- a/example/sinon.ts +++ b/example/sinon.ts @@ -1,27 +1,16 @@ import sinon from 'sinon'; +import simpleRestProvider from 'ra-data-simple-rest'; +import { HttpError, type Options } from 'react-admin'; import { SinonServer, withDelay } from '../src'; import { data } from './data'; -import { HttpError, type Options } from 'react-admin'; -import simpleRestProvider from 'ra-data-simple-rest'; +import { middlewares } from './middlewares'; export const initializeSinon = () => { const restServer = new SinonServer({ baseUrl: 'http://localhost:3000', data, loggingEnabled: true, - middlewares: [ - withDelay(300), - async (context, next) => { - if (!context.headers?.get('Authorization')) { - return { - status: 401, - headers: {}, - }; - } - return next(context); - }, - // FIXME: add validation middleware - ], + middlewares, }); // use sinon.js to monkey-patch XmlHttpRequest From cfdd55e27a47fa364ae15acb0432bd0e59824e65 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 7 Jun 2024 09:53:54 +0200 Subject: [PATCH 07/16] cleanup --- .prettierrc | 3 ++- example/App.tsx | 3 ++- example/fetchMock.ts | 2 +- example/msw.ts | 2 +- example/sinon.ts | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.prettierrc b/.prettierrc index ab28f3e..4bce94d 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,4 @@ { - "tabWidth": 4 + "tabWidth": 4, + "singleQuote": true } \ No newline at end of file diff --git a/example/App.tsx b/example/App.tsx index 0a91491..eba3ed4 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -47,9 +47,10 @@ export const App = ({ dataProvider }: { dataProvider: DataProvider }) => { ); }; -import { Edit, ReferenceInput, SimpleForm, TextInput } from 'react-admin'; +import { ReferenceInput, SimpleForm, TextInput } from 'react-admin'; import authProvider from './authProvider'; +// The default value for the title field should cause a server validation error as it's not unique export const BookCreate = () => ( diff --git a/example/fetchMock.ts b/example/fetchMock.ts index ff2a658..1480ccc 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -1,5 +1,5 @@ import fetchMock from 'fetch-mock'; -import { FetchMockServer, withDelay } from '../src'; +import { FetchMockServer } from '../src'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; import { middlewares } from './middlewares'; diff --git a/example/msw.ts b/example/msw.ts index ffc465d..46c6228 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -1,5 +1,5 @@ import { setupWorker } from 'msw/browser'; -import { getMswHandler, withDelay } from '../src'; +import { getMswHandler } from '../src'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; import { middlewares } from './middlewares'; diff --git a/example/sinon.ts b/example/sinon.ts index 987ea6f..c7c6a50 100644 --- a/example/sinon.ts +++ b/example/sinon.ts @@ -1,7 +1,7 @@ import sinon from 'sinon'; import simpleRestProvider from 'ra-data-simple-rest'; import { HttpError, type Options } from 'react-admin'; -import { SinonServer, withDelay } from '../src'; +import { SinonServer } from '../src'; import { data } from './data'; import { middlewares } from './middlewares'; From ab4ad19b1cc5cb7c2cd2130dae80e1438577bfdf Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 7 Jun 2024 11:54:16 +0200 Subject: [PATCH 08/16] Fix Sinon tests --- src/adapters/SinonServer.spec.ts | 357 +++++++++++++++---------------- src/adapters/SinonServer.ts | 17 +- 2 files changed, 173 insertions(+), 201 deletions(-) diff --git a/src/adapters/SinonServer.spec.ts b/src/adapters/SinonServer.spec.ts index 5fac77d..e91a9b9 100644 --- a/src/adapters/SinonServer.spec.ts +++ b/src/adapters/SinonServer.spec.ts @@ -1,9 +1,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 '../SimpleRestServer.js'; +import type { BaseResponse } from '../types.js'; function getFakeXMLHTTPRequest( method: string, @@ -25,34 +23,35 @@ function getFakeXMLHTTPRequest( describe('SinonServer', () => { describe('addMiddleware', () => { it('should allow request transformation', async () => { - const server = new SinonServer(); - server.addMiddleware((request, context, next) => { - const start = context.params?._start - ? context.params._start - 1 - : 0; - const end = - context.params?._end !== undefined - ? context.params._end - 1 - : 19; - if (!context.params) { - context.params = {}; - } - context.params.range = [start, end]; - return next(request, context); - }); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonServer({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + middlewares: [ + (context, next) => { + const start = context.params?._start + ? context.params._start - 1 + : 0; + const end = + context.params?._end !== undefined + ? context.params._end - 1 + : 19; + if (!context.params) { + context.params = {}; + } + context.params.range = [start, end]; + return next(context); + }, + ], + }); + const handle = server.getHandler(); 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); + await handle(request); expect(request?.status).toEqual(206); // @ts-ignore expect(request.responseText).toEqual('[{"id":1,"name":"foo"}]'); @@ -61,7 +60,7 @@ describe('SinonServer', () => { ); request = getFakeXMLHTTPRequest('GET', '/foo?_start=2&_end=2'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request?.status).toEqual(206); // @ts-ignore expect(request?.responseText).toEqual('[{"id":2,"name":"bar"}]'); @@ -71,79 +70,64 @@ describe('SinonServer', () => { }); it('should allow response transformation', async () => { - const server = new SinonServer(); - server.addMiddleware((request, context, next) => { - const response = next(request, context); - (response as BaseResponse).status = 418; - return response; - }); - server.addMiddleware((request, context, next) => { - const response = next(request, context) as BaseResponse; - response.body = { - data: response.body, - status: response.status, - }; - return response; - }); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonServer({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + middlewares: [ + (context, next) => { + const response = next(context); + (response as BaseResponse).status = 418; + return response; + }, + (context, next) => { + const response = next(context) as BaseResponse; + response.body = { + data: response.body, + status: response.status, + }; + return response; + }, + ], + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(418); // @ts-ignore expect(request.responseText).toEqual( '{"data":[{"id":1,"name":"foo"},{"id":2,"name":"bar"}],"status":200}', ); }); - - it('should pass request in response interceptor', async () => { - const server = new SinonServer(); - let requestUrl: string | undefined; - server.addMiddleware((request, context, next) => { - requestUrl = request.url; - return next(request, context); - }); - server.addCollection('foo', new Collection()); - - const request = getFakeXMLHTTPRequest('GET', '/foo'); - if (request == null) throw new Error('request is null'); - await server.handle(request); - - expect(requestUrl).toEqual('/foo'); - }); }); describe('handle', () => { it('should respond a 404 to GET /whatever on non existing collection', async () => { const server = new SinonServer(); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(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 SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonServer({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual( @@ -158,27 +142,23 @@ 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( - 'foos', - new Collection({ - items: [ + const server = new SinonServer({ + data: { + foos: [ { id: 0, name: 'c', arg: false }, { id: 1, name: 'b', arg: true }, { id: 2, name: 'a', arg: true }, ], - }), - ); - server.addCollection( - 'bars', - new Collection({ items: [{ id: 0, name: 'a', foo_id: 1 }] }), - ); + bars: [{ id: 0, name: 'a', foo_id: 1 }], + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'GET', '/foos?filter={"arg":true}&sort=name&slice=[0,10]&embed=["bars"]', ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual( @@ -193,38 +173,35 @@ 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( - 'foo', - new Collection({ - items: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}], - }), - ); // 11 items + const server = new SinonServer({ + data: { foo: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}] }, // 11 items + }); + const handle = server.getHandler(); let request: SinonFakeXMLHttpRequest | null; request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await 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'); - await server.handle(request); + await 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'); - await server.handle(request); + await 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'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 10-10/11', @@ -232,11 +209,13 @@ describe('SinonServer', () => { }); it('should respond to GET /foo on an empty collection with a []', async () => { - const server = new SinonServer(); - server.addCollection('foo', new Collection()); + const server = new SinonServer({ + data: { foo: [] }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('[]'); @@ -246,23 +225,22 @@ describe('SinonServer', () => { }); it('should respond to POST /foo by adding an item to collection foo', async () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonServer({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'POST', '/foo', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(201); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz","id":3}'); @@ -270,7 +248,8 @@ describe('SinonServer', () => { 'application/json', ); expect(request.getResponseHeader('Location')).toEqual('/foo/3'); - expect(server.database.getAll('foo')).toEqual([ + // @ts-ignore + expect(server.server.database.getAll('foo')).toEqual([ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, { id: 3, name: 'baz' }, @@ -279,13 +258,14 @@ describe('SinonServer', () => { 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 handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'POST', '/foo', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(201); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz","id":0}'); @@ -293,25 +273,25 @@ describe('SinonServer', () => { 'application/json', ); expect(request.getResponseHeader('Location')).toEqual('/foo/0'); - expect(server.database.getAll('foo')).toEqual([ + // @ts-ignore + expect(server.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( - 'foo', - new Collection({ - items: [ + const server = new SinonServer({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo/2'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"bar"}'); @@ -321,39 +301,39 @@ 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()); + const server = new SinonServer({ data: { foo: [] } }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo/3'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(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 SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonServer({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PUT', '/foo/2', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.database.getAll('foo')).toEqual([ + // @ts-ignore + expect(server.server.database.getAll('foo')).toEqual([ { id: 1, name: 'foo' }, { id: 2, name: 'baz' }, ]); @@ -361,101 +341,102 @@ 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: [] })); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PUT', '/foo/3', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(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 SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonServer({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PATCH', '/foo/2', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.database.getAll('foo')).toEqual([ + // @ts-ignore + expect(server.server.database.getAll('foo')).toEqual([ { id: 1, name: 'foo' }, { id: 2, name: 'baz' }, ]); }); 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 server = new SinonServer({ data: { foo: [] } }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PATCH', '/foo/3', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(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 SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonServer({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('DELETE', '/foo/2'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"bar"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.database.getAll('foo')).toEqual([ + // @ts-ignore + expect(server.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: [] })); + const server = new SinonServer({ data: { foo: [] } }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('DELETE', '/foo/3'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(404); }); it('should respond to GET /foo/ with single item', async () => { - const server = new SinonServer(); - server.addSingle('foo', new Single({ name: 'foo' })); - + const server = new SinonServer({ + data: { foo: { name: 'foo' } }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"name":"foo"}'); @@ -465,61 +446,64 @@ 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' })); - + const server = new SinonServer({ + data: { foo: { name: 'foo' } }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PUT', '/foo/', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.database.getOnly('foo')).toEqual({ name: 'baz' }); + // @ts-ignore + expect(server.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' })); - + const server = new SinonServer({ + data: { foo: { name: 'foo' } }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PATCH', '/foo/', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.database.getOnly('foo')).toEqual({ name: 'baz' }); + // @ts-ignore + expect(server.server.database.getOnly('foo')).toEqual({ + name: 'baz', + }); }); }); describe('setDefaultQuery', () => { it('should set the default query string', async () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}], - }), - ); // 10 items - server.setDefaultQuery(() => { - return { range: [2, 4] }; + const server = new SinonServer({ + data: { foo: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}] }, // 10 items + defaultQuery: () => ({ range: [2, 4] }), }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 2-4/10', @@ -530,17 +514,14 @@ describe('SinonServer', () => { }); it('should not override any provided query string', async () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}], - }), - ); // 10 items - server.setDefaultQuery((name) => ({ range: [2, 4] })); + const server = new SinonServer({ + data: { foo: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}] }, // 10 items + defaultQuery: () => ({ range: [2, 4] }), + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await 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 12d8b5c..a1857cd 100644 --- a/src/adapters/SinonServer.ts +++ b/src/adapters/SinonServer.ts @@ -20,7 +20,7 @@ export class SinonServer { } getHandler() { - return (request: SinonFakeXMLHttpRequest) => { + return async (request: SinonFakeXMLHttpRequest) => { // 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. @@ -28,11 +28,8 @@ export class SinonServer { // @ts-expect-error request.readyState = 4; const normalizedRequest = this.getNormalizedRequest(request); - this.server - .handle(normalizedRequest) - .then((response) => this.respond(response, request)); - // Let Sinon know we've handled the request - return true; + const response = await this.server.handle(normalizedRequest); + this.respond(response, request); }; } @@ -64,7 +61,7 @@ export class SinonServer { }; } - async respond(response: BaseResponse, request: SinonFakeXMLHttpRequest) { + respond(response: BaseResponse, request: SinonFakeXMLHttpRequest) { const sinonResponse = { status: response.status, body: response.body ?? '', @@ -104,12 +101,6 @@ export class SinonServer { ); this.log(request, sinonResponse); - - return { - status: response.status, - body: response.body, - headers: response.headers, - }; } log(request: SinonFakeXMLHttpRequest, response: SinonFakeRestResponse) { From c4a5a52e12ea99d66c098a33443fa055cf4bd1c3 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 7 Jun 2024 12:07:53 +0200 Subject: [PATCH 09/16] Simplify usage doc --- README.md | 229 ++++++++++++++++-------------------------------------- 1 file changed, 66 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index 0bdf5a1..a8496c2 100644 --- a/README.md +++ b/README.md @@ -4,47 +4,48 @@ 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)). -## Installation +## Usage + +FakeRest lets you create a handler function that you can pass to an API mocking library. FakeRest supports [MSW](https://mswjs.io/), [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/), and [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/). If you have the choice, we recommend using MSW. ### 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 fakerest and MSW. Then initialize MSW: +Install FakeRest and MSW, then initialize MSW: ```sh npm install fakerest msw@latest --save-dev npx msw init # eg: public ``` -Then configure it: +Then configure a MSW worker: ```js // in ./src/fakeServer.js import { setupWorker } from "msw/browser"; import { getMswHandler } from "fakerest"; -const 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' } - ], - 'settings': { - language: 'english', - preferred_format: 'hardback', - } -}; - -export const worker = setupWorker(getMswHandler({ +const handler = getMswHandler({ baseUrl: 'http://localhost:3000', - data -})); + 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' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + } +}); +export const worker = setupWorker(handler); ``` Finally call the `worker.start()` method before rendering your application. For instance, in a Vite React application: @@ -63,117 +64,48 @@ worker.start({ }); ``` -Another option is to use the `MswServer` class. This is useful if you must conditionally include data or add middlewares: - -```js -// in ./src/fakeServer.js -import { setupWorker } from "msw/browser"; -import { MswServer } from "fakerest"; - -const 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' } - ], - 'settings': { - language: 'english', - preferred_format: 'hardback', - } -}; - -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 -```js -// in ./src/fakeServer.js -import sinon from 'sinon'; -import { getSinonHandler } from "fakerest"; - -const 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' } - ], - 'settings': { - language: 'english', - preferred_format: 'hardback', - } -}; +Install FakeRest and [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/): -// use sinon.js to monkey-patch XmlHttpRequest -const sinonServer = sinon.fakeServer.create(); -// this is required when doing asynchronous XmlHttpRequest -sinonServer.autoRespond = true; - -sinonServer.respondWith( - getSinonHandler({ - baseUrl: 'http://localhost:3000', - data, - }) -); +```sh +npm install fakerest sinon --save-dev ``` -Another option is to use the `SinonServer` class. This is useful if you must conditionally include data or add middlewares: +Then, configure a Sinon server: ```js // in ./src/fakeServer.js import sinon from 'sinon'; -import { SinonServer } from "fakerest"; - -const 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' } - ], - 'settings': { - language: 'english', - preferred_format: 'hardback', - } -}; +import { getSinonHandler } from "fakerest"; -const restServer = new SinonServer({ +const handler = getSinonHandler({ baseUrl: 'http://localhost:3000', - data, + 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' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + }, }); // use sinon.js to monkey-patch XmlHttpRequest const sinonServer = sinon.fakeServer.create(); // this is required when doing asynchronous XmlHttpRequest sinonServer.autoRespond = true; - -sinonServer.respondWith( - restServer.getHandler({ - baseUrl: 'http://localhost:3000', - data, - }) -); +sinonServer.respondWith(handler); ``` FakeRest will now intercept every `XmlHttpRequest` requests to the REST server. @@ -193,56 +125,27 @@ You can then initialize the `FetchMockServer`: import fetchMock from 'fetch-mock'; import { getFetchMockHandler } from "fakerest"; -const 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' } - ], - 'settings': { - language: 'english', - preferred_format: 'hardback', - } -}; - -fetchMock.mock( - 'begin:http://localhost:3000', - getFetchMockHandler({ baseUrl: 'http://localhost:3000', data }) -); -``` - -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 { FetchMockServer } from 'fakerest'; - -const 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' } - ], - 'settings': { - language: 'english', - preferred_format: 'hardback', - } -}; -const restServer = new FetchMockServer({ +const handler = getFetchMockHandler({ baseUrl: 'http://localhost:3000', - data + 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' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + } }); -fetchMock.mock('begin:http://localhost:3000', restServer.getHandler()); + +fetchMock.mock('begin:http://localhost:3000', handler); ``` FakeRest will now intercept every `fetch` requests to the REST server. From cee04e17880d92def52d23d8d57accd416625a0f Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 7 Jun 2024 12:08:16 +0200 Subject: [PATCH 10/16] Move fetch-mock up --- README.md | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index a8496c2..225e7b8 100644 --- a/README.md +++ b/README.md @@ -66,22 +66,22 @@ worker.start({ FakeRest will now intercept every `fetch` requests to the REST server. -### Sinon +### fetch-mock -Install FakeRest and [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/): +First, install fakerest and [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/): ```sh -npm install fakerest sinon --save-dev +npm install fakerest fetch-mock --save-dev ``` -Then, configure a Sinon server: +You can then initialize the `FetchMockServer`: ```js // in ./src/fakeServer.js -import sinon from 'sinon'; -import { getSinonHandler } from "fakerest"; +import fetchMock from 'fetch-mock'; +import { getFetchMockHandler } from "fakerest"; -const handler = getSinonHandler({ +const handler = getFetchMockHandler({ baseUrl: 'http://localhost:3000', data: { 'authors': [ @@ -98,34 +98,30 @@ const handler = getSinonHandler({ language: 'english', preferred_format: 'hardback', } - }, + } }); -// use sinon.js to monkey-patch XmlHttpRequest -const sinonServer = sinon.fakeServer.create(); -// this is required when doing asynchronous XmlHttpRequest -sinonServer.autoRespond = true; -sinonServer.respondWith(handler); +fetchMock.mock('begin:http://localhost:3000', handler); ``` -FakeRest will now intercept every `XmlHttpRequest` requests to the REST server. +FakeRest will now intercept every `fetch` requests to the REST server. -### fetch-mock +### Sinon -First, install fakerest and [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/): +Install FakeRest and [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/): ```sh -npm install fakerest fetch-mock --save-dev +npm install fakerest sinon --save-dev ``` -You can then initialize the `FetchMockServer`: +Then, configure a Sinon server: ```js // in ./src/fakeServer.js -import fetchMock from 'fetch-mock'; -import { getFetchMockHandler } from "fakerest"; +import sinon from 'sinon'; +import { getSinonHandler } from "fakerest"; -const handler = getFetchMockHandler({ +const handler = getSinonHandler({ baseUrl: 'http://localhost:3000', data: { 'authors': [ @@ -142,13 +138,17 @@ const handler = getFetchMockHandler({ language: 'english', preferred_format: 'hardback', } - } + }, }); -fetchMock.mock('begin:http://localhost:3000', handler); +// use sinon.js to monkey-patch XmlHttpRequest +const sinonServer = sinon.fakeServer.create(); +// this is required when doing asynchronous XmlHttpRequest +sinonServer.autoRespond = true; +sinonServer.respondWith(handler); ``` -FakeRest will now intercept every `fetch` requests to the REST server. +FakeRest will now intercept every `XmlHttpRequest` requests to the REST server. ## Concepts From c9b46980a2ec65b48135432e361fdaf3897d4cfe Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 7 Jun 2024 14:02:45 +0200 Subject: [PATCH 11/16] Refactor docs --- README.md | 598 ++++++++++++------ example/fetchMock.ts | 4 +- example/sinon.ts | 4 +- ...FetchMockServer.ts => FetchMockAdapter.ts} | 12 +- src/adapters/{MswServer.ts => MswAdapter.ts} | 10 +- ...nonServer.spec.ts => SinonAdapter.spec.ts} | 46 +- .../{SinonServer.ts => SinonAdapter.ts} | 12 +- src/index.ts | 22 +- 8 files changed, 450 insertions(+), 258 deletions(-) rename src/adapters/{FetchMockServer.ts => FetchMockAdapter.ts} (92%) rename src/adapters/{MswServer.ts => MswAdapter.ts} (87%) rename src/adapters/{SinonServer.spec.ts => SinonAdapter.spec.ts} (94%) rename src/adapters/{SinonServer.ts => SinonAdapter.ts} (95%) diff --git a/README.md b/README.md index 225e7b8..a26e2fb 100644 --- a/README.md +++ b/README.md @@ -150,159 +150,154 @@ sinonServer.respondWith(handler); FakeRest will now intercept every `XmlHttpRequest` requests to the REST server. -## Concepts +## REST Syntax -### Server +FakeRest uses a simple a REST syntax described below. -A fake server implementation. FakeRest provide the following: +### Get A Collection of records -- `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/) +`GET /[name]` returns an array or records in the `name` collection. It accepts 4 query parameters: `filter`, `sort`, `range`, and `embed`. It responds with a status 200 if there is no pagination, or 206 if the list of items is paginated. The response contains a mention of the total count in the `Content-Range` header. -### Database + GET /books?filter={"author_id":1}&embed=["author"]&sort=["title","desc"]&range=[0-9] -FakeRest internal database, that contains [collections](#collections) and [single](#single). + HTTP 1.1 200 OK + Content-Range: items 0-1/2 + Content-Type: application/json + [ + { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }, + { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } + ] + +The `filter` param must be a serialized object literal describing the criteria to apply to the search query. See the [supported filters](#supported-filters) for more details. + + GET /books?filter={"author_id":1} // return books where author_id is equal to 1 + HTTP 1.1 200 OK + Content-Range: items 0-1/2 + Content-Type: application/json + [ + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, + { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } + ] + + // array values are possible + GET /books?filter={"id":[2,3]} // return books where id is in [2,3] + HTTP 1.1 200 OK + Content-Range: items 0-1/2 + Content-Type: application/json + [ + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, + { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } + ] + + // use the special "q" filter to make a full-text search on all text fields + GET /books?filter={"q":"and"} // return books where any of the book properties contains the string 'and' + + HTTP 1.1 200 OK + Content-Range: items 0-2/3 + Content-Type: application/json + [ + { "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" } + ] + + // use _gt, _gte, _lte, _lt, or _neq suffix on filter names to make range queries + GET /books?filter={"price_lte":20} // return books where price is less than or equal to 20 + GET /books?filter={"price_gt":20} // return books where price is greater than 20 -### Collections + // when the filter object contains more than one property, the criteria combine with an AND logic + GET /books?filter={"published_at_gte":"2015-06-12","published_at_lte":"2015-06-15"} // return books published between two dates -The equivalent to a classic database table or document collection. It supports filtering. +The `sort` param must be a serialized array literal defining first the property used for sorting, then the sorting direction. -### Single + GET /author?sort=["date_of_birth","asc"] // return authors, the oldest first + GET /author?sort=["date_of_birth","desc"] // return authors, the youngest first -Represent an API endpoint that returns a single entity. Useful for things such as user profile routes (`/me`) or global settings (`/settings`). +The `range` param defines the number of results by specifying the rank of the first and last result. The first result is #0. -### Embeds + GET /books?range=[0-9] // return the first 10 books + GET /books?range=[10-19] // return the 10 next books -FakeRest support embedding other resources in a main resource query result. For instance, embedding the author of a book. +The `embed` param sets the related objects or collections to be embedded in the response. -## REST Flavor - -FakeRest defines a REST flavor, described below. It is inspired by commonly used ways how to handle aspects like filtering and sorting. - -* `GET /foo` returns a JSON array. It accepts three query parameters: `filter`, `sort`, and `range`. It responds with a status 200 if there is no pagination, or 206 if the list of items is paginated. The response contains a mention of the total count in the `Content-Range` header. - - GET /books?filter={"author_id":1}&embed=["author"]&sort=["title","desc"]&range=[0-9] - - HTTP 1.1 200 OK - Content-Range: items 0-1/2 - Content-Type: application/json - [ - { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }, - { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } - ] + // embed author in books + GET /books?embed=["author"] + HTTP 1.1 200 OK + Content-Range: items 0-3/4 + Content-Type: application/json + [ + { "id": 0, "author_id": 0, "title": "Anna Karenina", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } }, + { "id": 1, "author_id": 0, "title": "War and Peace", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } }, + { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }, + { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } + ] - The `filter` param must be a serialized object literal describing the criteria to apply to the search query. + // embed books in author + GET /authors?embed=["books"] + HTTP 1.1 200 OK + Content-Range: items 0-1/2 + Content-Type: application/json + [ + { id: 0, first_name: 'Leo', last_name: 'Tolstoi', books: [{ id: 0, author_id: 0, title: 'Anna Karenina' }, { id: 1, author_id: 0, title: 'War and Peace' }] }, + { id: 1, first_name: 'Jane', last_name: 'Austen', books: [{ id: 2, author_id: 1, title: 'Pride and Prejudice' }, { id: 3, author_id: 1, title: 'Sense and Sensibility' }] } + ] - GET /books?filter={"author_id":1} // return books where author_id is equal to 1 - HTTP 1.1 200 OK - Content-Range: items 0-1/2 - Content-Type: application/json - [ - { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, - { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } - ] + // you can embed several objects + GET /authors?embed=["books","country"] - // array values are possible - GET /books?filter={"id":[2,3]} // return books where id is in [2,3] - HTTP 1.1 200 OK - Content-Range: items 0-1/2 - Content-Type: application/json - [ - { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, - { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } - ] +### Get A Single Record - // use the special "q" filter to make a full-text search on all text fields - GET /books?filter={"q":"and"} // return books where any of the book properties contains the string 'and' +`GET /[name]/:id` returns a JSON object, and a status 200, unless the resource doesn't exist. - HTTP 1.1 200 OK - Content-Range: items 0-2/3 - Content-Type: application/json - [ - { "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 /books/2 - // use _gt, _gte, _lte, _lt, or _neq suffix on filter names to make range queries - GET /books?filter={"price_lte":20} // return books where price is less than or equal to 20 - GET /books?filter={"price_gt":20} // return books where price is greater than 20 + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } - // when the filter object contains more than one property, the criteria combine with an AND logic - GET /books?filter={"published_at_gte":"2015-06-12","published_at_lte":"2015-06-15"} // return books published between two dates +The `embed` param sets the related objects or collections to be embedded in the response. - The `embed` param sets the related objects or collections to be embedded in the response. + GET /books/2?embed=['author'] - // embed author in books - GET /books?embed=["author"] - HTTP 1.1 200 OK - Content-Range: items 0-3/4 - Content-Type: application/json - [ - { "id": 0, "author_id": 0, "title": "Anna Karenina", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } }, - { "id": 1, "author_id": 0, "title": "War and Peace", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } }, - { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }, - { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } - ] + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } - // embed books in author - GET /authors?embed=["books"] - HTTP 1.1 200 OK - Content-Range: items 0-1/2 - Content-Type: application/json - [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi', books: [{ id: 0, author_id: 0, title: 'Anna Karenina' }, { id: 1, author_id: 0, title: 'War and Peace' }] }, - { id: 1, first_name: 'Jane', last_name: 'Austen', books: [{ id: 2, author_id: 1, title: 'Pride and Prejudice' }, { id: 3, author_id: 1, title: 'Sense and Sensibility' }] } - ] +### Create A Record - // you can embed several objects - GET /authors?embed=["books","country"] +`POST /[name]` returns a status 201 with a `Location` header for the newly created resource, and the new resource in the body. - The `sort` param must be a serialized array literal defining first the property used for sorting, then the sorting direction. + POST /books + { "author_id": 1, "title": "Emma" } - GET /author?sort=["date_of_birth","asc"] // return authors, the oldest first - GET /author?sort=["date_of_birth","desc"] // return authors, the youngest first + HTTP 1.1 201 Created + Location: /books/4 + Content-Type: application/json + { "author_id": 1, "title": "Emma", "id": 4 } - The `range` param defines the number of results by specifying the rank of the first and last result. The first result is #0. - GET /books?range=[0-9] // return the first 10 books - GET /books?range=[10-19] // return the 10 next books +### Update A Record -* `POST /foo` returns a status 201 with a `Location` header for the newly created resource, and the new resource in the body. +`PUT /[name]/:id` returns the modified JSON object, and a status 200, unless the resource doesn't exist. - POST /books - { "author_id": 1, "title": "Emma" } + PUT /books/2 + { "author_id": 1, "title": "Pride and Prejudice" } - HTTP 1.1 201 Created - Location: /books/4 - Content-Type: application/json - { "author_id": 1, "title": "Emma", "id": 4 } - -* `GET /foo/:id` returns a JSON object, and a status 200, unless the resource doesn't exist - - GET /books/2 - - HTTP 1.1 200 OK - Content-Type: application/json - { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } - - The `embed` param sets the related objects or collections to be embedded in the response. - - GET /books/2?embed=['author'] - - HTTP 1.1 200 OK - Content-Type: application/json - { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } + +### Delete A Single Record -* `PUT /foo/:id` returns the modified JSON object, and a status 200, unless the resource doesn't exist -* `DELETE /foo/:id` returns the deleted JSON object, and a status 200, unless the resource doesn't exist +`DELETE /[name]/:id` returns the deleted JSON object, and a status 200, unless the resource doesn't exist. -If the REST flavor you want to simulate differs from the one chosen for FakeRest, no problem: request and response interceptors will do the conversion (see below). + DELETE /books/2 -Note that all of the above apply only to collections. Single objects respond to `GET /bar`, `PUT /bar` and `PATCH /bar` in a manner identical to those operations for `/foo/:id`, including embedding. `POST /bar` and `DELETE /bar` are not enabled. + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } -## Supported Filters +### Supported Filters Operators are specified as suffixes on each filtered field. For instance, applying the `_lte` operator on the `price` field for the `books` resource is done by like this: @@ -348,181 +343,257 @@ 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 +### Single Elements + +FakeRest allows you to define a single element, such as a user profile or global settings, that can be fetched, updated, or deleted. + + GET /settings + + HTTP 1.1 200 OK + Content-Type: application/json + { "language": "english", "preferred_format": "hardback" } + + PUT /settings + { "language": "french", "preferred_format": "paperback" } + + HTTP 1.1 200 OK + Content-Type: application/json + { "language": "french", "preferred_format": "paperback" } + + DELETE /settings + + HTTP 1.1 200 OK + Content-Type: application/json + { "language": "french", "preferred_format": "paperback" } + ## Middlewares -All fake servers supports middlewares that allows you to intercept requests and simulate server features such as: +Middlewares let 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/)) +You can define middlewares on all handlers, by passing a `middlewares` option: + +```js +import { getMswHandler } from 'fakerest'; +import { data } from './data'; + +const handler = getMswHandler({ + baseUrl: 'http://my.custom.domain', + data, + middlewares: [ + async (context, next) => { + if (context.headers.Authorization === undefined) { + return { + status: 401, + headers: {}, + }; + } + + return next(context); + }, + withDelay(300), + ], +}); +``` + +A middleware is a function that receive 2 parameters: - 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`) + - `url`: The request URL as a string + - `headers`: The request headers as an object where keys are header names + - `requestBody`: The parsed request data if any + - `params`: The request parameters from the URL search (e.g. the identifier of the requested record) - `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 `next` function to call the next middleware in the chain, to which you must pass the `request` and the `context` + - A `next` function to call the next middleware in the chain, to which you must pass 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 -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: +Here's how to implement an authentication check: ```js -restServer.addMiddleware(async (request, context, next) => { - if (request.requestHeaders.Authorization === undefined) { - return { - status: 401, - headers: {}, - }; - } - - return next(request, context); -} +const handler = getMswHandler({ + baseUrl: 'http://my.custom.domain', + data, + middlewares: [ + async (context, next) => { + if (context.headers.Authorization === undefined) { + return { status: 401, headers: {} }; + } + return next(context); + } + ] +}); ``` -### Server Side Validation +### Server-Side Validation -Here's to implement server side validation: +Here's how to implement server-side validation: ```js -restServer.addMiddleware(async (request, context, next) => { - if ( - context.collection === "books" && - request.method === "POST" && - !context.requestJson?.title - ) { - return { - status: 400, - headers: {}, - body: { - errors: { - title: 'An article with this title already exists. The title must be unique.', - }, - }, - }; - } - - return next(request, context); -} +const handler = getMswHandler({ + baseUrl: 'http://my.custom.domain', + data, + middlewares: [ + async (context, next) => { + if ( + context.collection === "books" && + request.method === "POST" && + !context.requestBody?.title + ) { + return { + status: 400, + headers: {}, + body: { + errors: { + title: 'An article with this title already exists. The title must be unique.', + }, + }, + }; + } + + return next(context); + } + ] +}); ``` -### Server Dynamically Generated Values +### Dynamically Generated Values -Here's to implement server dynamically generated values: +Here's how to implement dynamically generated values on creation: ```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); -} +const handler = getMswHandler({ + baseUrl: 'http://my.custom.domain', + data, + middlewares: [ + async (context, next) => { + if ( + context.collection === 'books' && + context.method === 'POST' + ) { + const response = await next(context); + response.body.updatedAt = new Date().toISOString(); + return response; + } + + return next(context); + } + ] +}); ``` ### Simulate Response Delays -Here's to simulate response delays: +Here's how to simulate response delays: ```js -restServer.addMiddleware(async (request, context, next) => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(next(request, context)); - }, delayMs); - }); +const handler = getMswHandler({ + baseUrl: 'http://my.custom.domain', + data, + middlewares: [ + async (context, next) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(next(context)); + }, 500); + }); + } + ] }); ``` This is so common FakeRest provides the `withDelay` function for that: ```js -import { withDelay } from 'fakerest'; +import { getMswHandler, withDelay } from 'fakerest'; -restServer.addMiddleware(withDelay(300)); +const handler = getMswHandler({ + baseUrl: 'http://my.custom.domain', + data, + middlewares: [ + withDelay(500), // delay in ms + ] +}); ``` ## Configuration -### Configure Identifiers +All handlers can be customized to accomodate your API structure. + +### 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: +You can customize FakeRest to do the same by setting the `identifierName` option: ```js -import { MswServer } from 'fakerest'; - -const restServer = new MswServer({ +const handler = getMswHandler({ baseUrl: 'http://my.custom.domain', + data, identifierName: '_id' }); ``` -This can also be specified at the collection level: +You can also specify that on a per-collection basis: ```js -import { MswServer, Collection } from 'fakerest'; +import { MswAdapter, Collection } from 'fakerest'; -const restServer = new MswServer({ baseUrl: 'http://my.custom.domain' }); +const adapter = new MswAdapter({ baseUrl: 'http://my.custom.domain', data }); const authorsCollection = new Collection({ items: [], identifierName: '_id' }); -restServer.addCollection('authors', authorsCollection); +adapter.server.addCollection('authors', authorsCollection); +const handler = adapter.getHandler(); ``` -### Configure Identifiers Generation +### Primary Keys 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 { MswServer } from 'fakerest'; +import { getMswHandler } from 'fakerest'; import uuid from 'uuid'; -const restServer = new MswServer({ +const handler = new getMswHandler({ baseUrl: 'http://my.custom.domain', + data, getNewId: () => uuid.v5() }); ``` -This can also be specified at the collection level: +You can also specify that on a per-collection basis: ```js -import { MswServer, Collection } from 'fakerest'; +import { MswAdapter, Collection } from 'fakerest'; import uuid from 'uuid'; -const restServer = new MswServer({ baseUrl: 'http://my.custom.domain' }); +const adapter = new MswAdapter({ baseUrl: 'http://my.custom.domain', data }); const authorsCollection = new Collection({ items: [], getNewId: () => uuid.v5() }); -restServer.addCollection('authors', authorsCollection); +adapter.server.addCollection('authors', authorsCollection); +const handler = adapter.getHandler(); ``` -### Configure Default Queries +### 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 { getMswHandler } from 'fakerest'; import uuid from 'uuid'; -const restServer = new MswServer({ +const handler = getMswHandler({ baseUrl: 'http://my.custom.domain', - getNewId: () => uuid.v5(), + data, defaultQuery: (collection) => { if (resourceName == 'authors') return { embed: ['books'] } if (resourceName == 'books') return { filter: { published: true } } @@ -531,6 +602,127 @@ const restServer = new MswServer({ }); ``` +## Concepts + +Behind a simple API (`getXXXHandler`), FakeRest uses a modular architecture that lets you combine different components to build a fake REST server that fits your needs. + +### Mocking Adapter + +`getXXXHandler` is a shortcut top an object-oriented API: + +```js +export const getMswHandler = (options: MswAdapterOptions) => { + const server = new MswAdapter(options); + return server.getHandler(); +}; +``` + +FakeRest provides 3 adapter classes: + +- `MswAdapter`: Based on [MSW](https://mswjs.io/) +- `FetchMockAdapter`: Based on [`fetch-mock`](https://www.wheresrhys.co.uk/fetch-mock/) +- `SinonAdapter`: Based on [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/) + +You can use the adapter class directly, e.g. if you want to store the adapter instance in a variable: + +```js +import { MsWAdapter } from 'fakerest'; + +const adapter = new MswAdapter({ + baseUrl: 'http://my.custom.domain', + 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' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + } +}); +window.fakerest = adapter; +const handler = adapter.getHandler(); +``` + +### REST Server + +The REST syntax is implemented by a Server object. It takes a normalized request and returns a normalized response. Adapters rely on the server to handle the REST API. + +FakeRest currently provides only one server implementation: `SimpleRestServer`. + +You can specify the server to use in an adapter by passing the `server` option: + +```js +const server = new SimpleRestServer({ + baseUrl: 'http://my.custom.domain', + 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' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + } +}); +const adapter = new MswAdapter({ server }); +const handler = adapter.getHandler(); +``` + +### Database + +The querying logic is implemented in a class called `Database`, which is independent of the server. It contains [collections](#collections) and [single](#single). + +You can specify the database used by a server by setting its `database` property: + +```js +const database = new Database({ + 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' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + } +}); +const server = new SimpleRestServer({ baseUrl: 'http://my.custom.domain', database }); +``` + +### 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. + ## Development ```sh diff --git a/example/fetchMock.ts b/example/fetchMock.ts index 1480ccc..09bd9ba 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -1,11 +1,11 @@ import fetchMock from 'fetch-mock'; -import { FetchMockServer } from '../src'; +import { FetchMockAdapter } from '../src'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; import { middlewares } from './middlewares'; export const initializeFetchMock = () => { - const restServer = new FetchMockServer({ + const restServer = new FetchMockAdapter({ baseUrl: 'http://localhost:3000', data, loggingEnabled: true, diff --git a/example/sinon.ts b/example/sinon.ts index c7c6a50..f1062c9 100644 --- a/example/sinon.ts +++ b/example/sinon.ts @@ -1,12 +1,12 @@ import sinon from 'sinon'; import simpleRestProvider from 'ra-data-simple-rest'; import { HttpError, type Options } from 'react-admin'; -import { SinonServer } from '../src'; +import { SinonAdapter } from '../src'; import { data } from './data'; import { middlewares } from './middlewares'; export const initializeSinon = () => { - const restServer = new SinonServer({ + const restServer = new SinonAdapter({ baseUrl: 'http://localhost:3000', data, loggingEnabled: true, diff --git a/src/adapters/FetchMockServer.ts b/src/adapters/FetchMockAdapter.ts similarity index 92% rename from src/adapters/FetchMockServer.ts rename to src/adapters/FetchMockAdapter.ts index f38c300..487cad6 100644 --- a/src/adapters/FetchMockServer.ts +++ b/src/adapters/FetchMockAdapter.ts @@ -4,7 +4,7 @@ import type { BaseServerOptions } from '../SimpleRestServer.js'; import type { BaseResponse, APIServer, NormalizedRequest } from '../types.js'; import type { MockResponseObject } from 'fetch-mock'; -export class FetchMockServer { +export class FetchMockAdapter { loggingEnabled = false; server: APIServer; @@ -12,7 +12,7 @@ export class FetchMockServer { loggingEnabled = false, server, ...options - }: FetchMockServerOptions = {}) { + }: FetchMockAdapterOptions = {}) { this.server = server || new SimpleRestServer(options); this.loggingEnabled = loggingEnabled; } @@ -102,15 +102,15 @@ export class FetchMockServer { } } -export const getFetchMockHandler = (options: FetchMockServerOptions) => { - const server = new FetchMockServer(options); +export const getFetchMockHandler = (options: FetchMockAdapterOptions) => { + const server = new FetchMockAdapter(options); return server.getHandler(); }; /** * @deprecated Use FetchServer instead */ -export const FetchServer = FetchMockServer; +export const FetchServer = FetchMockAdapter; export type FetchMockFakeRestRequest = Partial & { requestBody?: string; @@ -120,7 +120,7 @@ export type FetchMockFakeRestRequest = Partial & { params?: { [key: string]: any }; }; -export type FetchMockServerOptions = BaseServerOptions & { +export type FetchMockAdapterOptions = BaseServerOptions & { server?: APIServer; loggingEnabled?: boolean; }; diff --git a/src/adapters/MswServer.ts b/src/adapters/MswAdapter.ts similarity index 87% rename from src/adapters/MswServer.ts rename to src/adapters/MswAdapter.ts index 66ad20f..9d334fb 100644 --- a/src/adapters/MswServer.ts +++ b/src/adapters/MswAdapter.ts @@ -3,10 +3,10 @@ import { SimpleRestServer } from '../SimpleRestServer.js'; import type { BaseServerOptions } from '../SimpleRestServer.js'; import type { APIServer, NormalizedRequest } from '../types.js'; -export class MswServer { +export class MswAdapter { server: APIServer; - constructor({ server, ...options }: MswServerOptions) { + constructor({ server, ...options }: MswAdapterOptions) { this.server = server || new SimpleRestServer(options); } @@ -51,11 +51,11 @@ export class MswServer { } } -export const getMswHandler = (options: MswServerOptions) => { - const server = new MswServer(options); +export const getMswHandler = (options: MswAdapterOptions) => { + const server = new MswAdapter(options); return server.getHandler(); }; -export type MswServerOptions = BaseServerOptions & { +export type MswAdapterOptions = BaseServerOptions & { server?: APIServer; }; diff --git a/src/adapters/SinonServer.spec.ts b/src/adapters/SinonAdapter.spec.ts similarity index 94% rename from src/adapters/SinonServer.spec.ts rename to src/adapters/SinonAdapter.spec.ts index e91a9b9..41e1fae 100644 --- a/src/adapters/SinonServer.spec.ts +++ b/src/adapters/SinonAdapter.spec.ts @@ -1,6 +1,6 @@ import sinon, { type SinonFakeXMLHttpRequest } from 'sinon'; -import { SinonServer } from './SinonServer.js'; +import { SinonAdapter } from './SinonAdapter.js'; import type { BaseResponse } from '../types.js'; function getFakeXMLHTTPRequest( @@ -23,7 +23,7 @@ function getFakeXMLHTTPRequest( describe('SinonServer', () => { describe('addMiddleware', () => { it('should allow request transformation', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: [ { id: 1, name: 'foo' }, @@ -70,7 +70,7 @@ describe('SinonServer', () => { }); it('should allow response transformation', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: [ { id: 1, name: 'foo' }, @@ -107,7 +107,7 @@ describe('SinonServer', () => { describe('handle', () => { it('should respond a 404 to GET /whatever on non existing collection', async () => { - const server = new SinonServer(); + const server = new SinonAdapter(); const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); @@ -116,7 +116,7 @@ describe('SinonServer', () => { }); it('should respond to GET /foo by sending all items in collection foo', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: [ { id: 1, name: 'foo' }, @@ -142,7 +142,7 @@ describe('SinonServer', () => { }); it('should respond to GET /foo?queryString by sending all items in collection foo satisfying query', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foos: [ { id: 0, name: 'c', arg: false }, @@ -173,7 +173,7 @@ describe('SinonServer', () => { }); it('should respond to GET /foo?queryString with pagination by sending the correct content-range header', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}] }, // 11 items }); const handle = server.getHandler(); @@ -209,7 +209,7 @@ describe('SinonServer', () => { }); it('should respond to GET /foo on an empty collection with a []', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: [] }, }); const handle = server.getHandler(); @@ -225,7 +225,7 @@ describe('SinonServer', () => { }); it('should respond to POST /foo by adding an item to collection foo', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: [ { id: 1, name: 'foo' }, @@ -257,7 +257,7 @@ describe('SinonServer', () => { }); 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 server = new SinonAdapter(); const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'POST', @@ -280,7 +280,7 @@ describe('SinonServer', () => { }); it('should respond to GET /foo/:id by sending element of identifier id in collection foo', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: [ { id: 1, name: 'foo' }, @@ -301,7 +301,7 @@ describe('SinonServer', () => { }); it('should respond to GET /foo/:id on a non-existing id with a 404', async () => { - const server = new SinonServer({ data: { foo: [] } }); + const server = new SinonAdapter({ data: { foo: [] } }); const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo/3'); if (request == null) throw new Error('request is null'); @@ -310,7 +310,7 @@ describe('SinonServer', () => { }); it('should respond to PUT /foo/:id by updating element of identifier id in collection foo', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: [ { id: 1, name: 'foo' }, @@ -340,7 +340,7 @@ describe('SinonServer', () => { }); it('should respond to PUT /foo/:id on a non-existing id with a 404', async () => { - const server = new SinonServer(); + const server = new SinonAdapter(); const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PUT', @@ -353,7 +353,7 @@ describe('SinonServer', () => { }); it('should respond to PATCH /foo/:id by updating element of identifier id in collection foo', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: [ { id: 1, name: 'foo' }, @@ -383,7 +383,7 @@ describe('SinonServer', () => { }); it('should respond to PATCH /foo/:id on a non-existing id with a 404', async () => { - const server = new SinonServer({ data: { foo: [] } }); + const server = new SinonAdapter({ data: { foo: [] } }); const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PATCH', @@ -396,7 +396,7 @@ describe('SinonServer', () => { }); it('should respond to DELETE /foo/:id by removing element of identifier id in collection foo', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: [ { id: 1, name: 'foo' }, @@ -421,7 +421,7 @@ describe('SinonServer', () => { }); it('should respond to DELETE /foo/:id on a non-existing id with a 404', async () => { - const server = new SinonServer({ data: { foo: [] } }); + const server = new SinonAdapter({ data: { foo: [] } }); const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('DELETE', '/foo/3'); if (request == null) throw new Error('request is null'); @@ -430,7 +430,7 @@ describe('SinonServer', () => { }); it('should respond to GET /foo/ with single item', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: { name: 'foo' } }, }); const handle = server.getHandler(); @@ -446,7 +446,7 @@ describe('SinonServer', () => { }); it('should respond to PUT /foo/ by updating the singleton record', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: { name: 'foo' } }, }); const handle = server.getHandler(); @@ -470,7 +470,7 @@ describe('SinonServer', () => { }); it('should respond to PATCH /foo/ by updating the singleton record', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: { name: 'foo' } }, }); const handle = server.getHandler(); @@ -496,7 +496,7 @@ describe('SinonServer', () => { describe('setDefaultQuery', () => { it('should set the default query string', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}] }, // 10 items defaultQuery: () => ({ range: [2, 4] }), }); @@ -514,7 +514,7 @@ describe('SinonServer', () => { }); it('should not override any provided query string', async () => { - const server = new SinonServer({ + const server = new SinonAdapter({ data: { foo: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}] }, // 10 items defaultQuery: () => ({ range: [2, 4] }), }); diff --git a/src/adapters/SinonServer.ts b/src/adapters/SinonAdapter.ts similarity index 95% rename from src/adapters/SinonServer.ts rename to src/adapters/SinonAdapter.ts index a1857cd..23121a8 100644 --- a/src/adapters/SinonServer.ts +++ b/src/adapters/SinonAdapter.ts @@ -6,7 +6,7 @@ import { import { parseQueryString } from '../parseQueryString.js'; import type { BaseResponse, APIServer, NormalizedRequest } from '../types.js'; -export class SinonServer { +export class SinonAdapter { loggingEnabled = false; server: APIServer; @@ -14,7 +14,7 @@ export class SinonServer { loggingEnabled = false, server, ...options - }: SinonServerOptions = {}) { + }: SinonAdapterOptions = {}) { this.server = server || new SimpleRestServer(options); this.loggingEnabled = loggingEnabled; } @@ -144,15 +144,15 @@ export class SinonServer { } } -export const getSinonHandler = (options: SinonServerOptions) => { - const server = new SinonServer(options); +export const getSinonHandler = (options: SinonAdapterOptions) => { + const server = new SinonAdapter(options); return server.getHandler(); }; /** * @deprecated Use SinonServer instead */ -export const Server = SinonServer; +export const Server = SinonAdapter; export type SinonFakeRestResponse = { status: number; @@ -160,7 +160,7 @@ export type SinonFakeRestResponse = { headers: Record; }; -export type SinonServerOptions = BaseServerOptions & { +export type SinonAdapterOptions = BaseServerOptions & { server?: APIServer; loggingEnabled?: boolean; }; diff --git a/src/index.ts b/src/index.ts index b89b11c..5f2538a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,14 @@ import { getSinonHandler, Server, - SinonServer, -} from './adapters/SinonServer.js'; + SinonAdapter, +} from './adapters/SinonAdapter.js'; import { getFetchMockHandler, FetchServer, - FetchMockServer, -} from './adapters/FetchMockServer.js'; -import { getMswHandler, MswServer } from './adapters/MswServer.js'; + FetchMockAdapter, +} from './adapters/FetchMockAdapter.js'; +import { getMswHandler, MswAdapter } from './adapters/MswAdapter.js'; import { Database } from './Database.js'; import { SimpleRestServer } from './SimpleRestServer.js'; import { Collection } from './Collection.js'; @@ -22,10 +22,10 @@ export { getFetchMockHandler, getMswHandler, Server, - SinonServer, + SinonAdapter, FetchServer, - FetchMockServer, - MswServer, + FetchMockAdapter, + MswAdapter, Collection, Single, withDelay, @@ -38,10 +38,10 @@ export default { getFetchMockHandler, getMswHandler, Server, - SinonServer, + SinonAdapter, FetchServer, - FetchMockServer, - MswServer, + FetchMockAdapter, + MswAdapter, Collection, Single, withDelay, From bc1783b5a35aa50118443c72f2a7f14c7f2670af Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 7 Jun 2024 14:05:20 +0200 Subject: [PATCH 12/16] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a26e2fb..216b46e 100644 --- a/README.md +++ b/README.md @@ -608,7 +608,7 @@ Behind a simple API (`getXXXHandler`), FakeRest uses a modular architecture that ### Mocking Adapter -`getXXXHandler` is a shortcut top an object-oriented API: +`getXXXHandler` is a shortcut to an object-oriented API: ```js export const getMswHandler = (options: MswAdapterOptions) => { From 5703fac9fb975950c990674a85c5d4a6aa048083 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 7 Jun 2024 15:12:43 +0200 Subject: [PATCH 13/16] Review --- README.md | 105 +++++++++++++++++++++++++++++++++------- src/SimpleRestServer.ts | 3 +- 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 216b46e..58d7f48 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,17 @@ # FakeRest -Intercept AJAX calls to fake a REST server based on JSON data. Use it on top of [Sinon.js](http://sinonjs.org/) (for `XMLHTTPRequest`) or [fetch-mock](https://github.com/wheresrhys/fetch-mock) (for `fetch`) to test JavaScript REST clients on the browser side (e.g. single page apps) without a server. +A browser library that intercepts AJAX calls to mock a REST server based on JSON data. + +Use it on top of [Sinon.js](http://sinonjs.org/) (for `XMLHTTPRequest`) or [fetch-mock](https://github.com/wheresrhys/fetch-mock) (for `fetch`) to test JavaScript REST clients on the browser side (e.g. single page apps) without a server. 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)). +## Installation + +```sh +npm install fakerest --save-dev +``` + ## Usage FakeRest lets you create a handler function that you can pass to an API mocking library. FakeRest supports [MSW](https://mswjs.io/), [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/), and [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/). If you have the choice, we recommend using MSW. @@ -12,10 +20,10 @@ FakeRest lets you create a handler function that you can pass to an API mocking 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. -Install FakeRest and MSW, then initialize MSW: +Install MSW, then initialize it: ```sh -npm install fakerest msw@latest --save-dev +npm install msw@latest --save-dev npx msw init # eg: public ``` @@ -68,13 +76,13 @@ FakeRest will now intercept every `fetch` requests to the REST server. ### fetch-mock -First, install fakerest and [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/): +Install [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/): ```sh -npm install fakerest fetch-mock --save-dev +npm install fetch-mock --save-dev ``` -You can then initialize the `FetchMockServer`: +You can then create a handler and pass it to fetch-mock: ```js // in ./src/fakeServer.js @@ -108,10 +116,10 @@ FakeRest will now intercept every `fetch` requests to the REST server. ### Sinon -Install FakeRest and [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/): +Install [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/): ```sh -npm install fakerest sinon --save-dev +npm install sinon --save-dev ``` Then, configure a Sinon server: @@ -602,13 +610,13 @@ const handler = getMswHandler({ }); ``` -## Concepts +## Architecture Behind a simple API (`getXXXHandler`), FakeRest uses a modular architecture that lets you combine different components to build a fake REST server that fits your needs. ### Mocking Adapter -`getXXXHandler` is a shortcut to an object-oriented API: +`getXXXHandler` is a shortcut to an object-oriented API of adapter classes: ```js export const getMswHandler = (options: MswAdapterOptions) => { @@ -623,7 +631,7 @@ FakeRest provides 3 adapter classes: - `FetchMockAdapter`: Based on [`fetch-mock`](https://www.wheresrhys.co.uk/fetch-mock/) - `SinonAdapter`: Based on [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/) -You can use the adapter class directly, e.g. if you want to store the adapter instance in a variable: +You can use the adapter class directly, e.g. if you want to make the adapter instance available in the global scope for debugging purposes: ```js import { MsWAdapter } from 'fakerest'; @@ -653,9 +661,9 @@ const handler = adapter.getHandler(); ### REST Server -The REST syntax is implemented by a Server object. It takes a normalized request and returns a normalized response. Adapters rely on the server to handle the REST API. +Adapters transform requests to a normalized format, pass them to a server object, and transform the normalized server response into the format expected by the mocking library. -FakeRest currently provides only one server implementation: `SimpleRestServer`. +The server object implements the REST syntax. It takes a normalized request and exposes a `handle` method that returns a normalized response. FakeRest currently provides only one server implementation: `SimpleRestServer`. You can specify the server to use in an adapter by passing the `server` option: @@ -683,6 +691,33 @@ const adapter = new MswAdapter({ server }); const handler = adapter.getHandler(); ``` +You can provide an alternative server implementation. This class must implement the `APIServer` type: + +```ts +export type APIServer = { + baseUrl?: string; + handle: (context: FakeRestContext) => Promise; +}; + +export type BaseResponse = { + status: number; + body?: Record | Record[]; + headers: { [key: string]: string }; +}; + +export type FakeRestContext = { + url?: string; + headers?: Headers; + method?: string; + collection?: string; + single?: string; + requestBody: Record | undefined; + params: { [key: string]: any }; +}; +``` + +The `FakerRestContext` type describes the normalized request. It's usually the adapter's job to transform the request from the mocking library to this format. + ### Database The querying logic is implemented in a class called `Database`, which is independent of the server. It contains [collections](#collections) and [single](#single). @@ -711,18 +746,54 @@ const database = new Database({ const server = new SimpleRestServer({ baseUrl: 'http://my.custom.domain', database }); ``` -### Collections +You can even use the database object, if you want to manipulate the data: -The equivalent to a classic database table or document collection. It supports filtering. +```js +database.updateOne('authors', 0, { first_name: 'Lev' }); +``` -### Single +### Collections & Singles -Represent an API endpoint that returns a single entity. Useful for things such as user profile routes (`/me`) or global settings (`/settings`). +The Database mays contain collections ans singles. In the following example, `authors` and `books` are collections, and `settings` is a single. + +```js +const handler = getMswHandler({ + baseUrl: 'http://localhost:3000', + 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' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + } +}); +``` + +A collection is the equivalent of a classic database table. It supports filtering and direct access to records by their identifier. + +A single represents an API endpoint that returns a single entity. It's 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. + GET /books/2?embed=['author'] + + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } + +Embeds are defined by the query, they require no setup in the database. + ## Development ```sh diff --git a/src/SimpleRestServer.ts b/src/SimpleRestServer.ts index 1aba889..a3dce08 100644 --- a/src/SimpleRestServer.ts +++ b/src/SimpleRestServer.ts @@ -2,6 +2,7 @@ import type { Collection } from './Collection.js'; import { Database, type DatabaseOptions } from './Database.js'; import type { Single } from './Single.js'; import type { + APIServer, BaseResponse, FakeRestContext, CollectionItem, @@ -9,7 +10,7 @@ import type { NormalizedRequest, } from './types.js'; -export class SimpleRestServer { +export class SimpleRestServer implements APIServer { baseUrl = ''; defaultQuery: QueryFunction = () => ({}); middlewares: Array; From 9c1e994b68b9dcd730cd3b4f1dca8b5abbcc5e9a Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 7 Jun 2024 15:28:11 +0200 Subject: [PATCH 14/16] Correct spelling and grammar --- README.md | 528 +++++++++++++++++++++++++++--------------------------- 1 file changed, 262 insertions(+), 266 deletions(-) diff --git a/README.md b/README.md index 58d7f48..80ca7d0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A browser library that intercepts AJAX calls to mock a REST server based on JSON data. -Use it on top of [Sinon.js](http://sinonjs.org/) (for `XMLHTTPRequest`) or [fetch-mock](https://github.com/wheresrhys/fetch-mock) (for `fetch`) to test JavaScript REST clients on the browser side (e.g. single page apps) without a server. +Use it on top of [Sinon.js](http://sinonjs.org/) (for `XMLHttpRequest`) or [fetch-mock](https://github.com/wheresrhys/fetch-mock) (for `fetch`) to test JavaScript REST clients on the browser side (e.g. single page apps) without a server. 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)). @@ -14,20 +14,18 @@ npm install fakerest --save-dev ## Usage -FakeRest lets you create a handler function that you can pass to an API mocking library. FakeRest supports [MSW](https://mswjs.io/), [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/), and [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/). If you have the choice, we recommend using MSW. +FakeRest lets you create a handler function that you can pass to an API mocking library. FakeRest supports [MSW](https://mswjs.io/), [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/), and [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/). If you have the choice, we recommend using MSW, as it will allow you to inspect requests as you usually do in the dev tools network tab. ### 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. - -Install MSW, then initialize it: +Install [MSW](https://mswjs.io/) and initialize it: ```sh npm install msw@latest --save-dev npx msw init # eg: public ``` -Then configure a MSW worker: +Then configure an MSW worker: ```js // in ./src/fakeServer.js @@ -38,25 +36,25 @@ const handler = getMswHandler({ baseUrl: 'http://localhost:3000', data: { 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], + { 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' } - ], + { 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' } + ], 'settings': { language: 'english', preferred_format: 'hardback', - } - } + } + } }); export const worker = setupWorker(handler); ``` -Finally call the `worker.start()` method before rendering your application. For instance, in a Vite React application: +Finally, call the `worker.start()` method before rendering your application. For instance, in a Vite React application: ```js import React from "react"; @@ -72,7 +70,7 @@ worker.start({ }); ``` -FakeRest will now intercept every `fetch` requests to the REST server. +FakeRest will now intercept every `fetch` request to the REST server. ### fetch-mock @@ -85,7 +83,6 @@ npm install fetch-mock --save-dev You can then create a handler and pass it to fetch-mock: ```js -// in ./src/fakeServer.js import fetchMock from 'fetch-mock'; import { getFetchMockHandler } from "fakerest"; @@ -93,26 +90,26 @@ const handler = getFetchMockHandler({ baseUrl: 'http://localhost:3000', data: { 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], + { 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' } - ], + { 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' } + ], 'settings': { language: 'english', preferred_format: 'hardback', - } - } + } + } }); fetchMock.mock('begin:http://localhost:3000', handler); ``` -FakeRest will now intercept every `fetch` requests to the REST server. +FakeRest will now intercept every `fetch` request to the REST server. ### Sinon @@ -125,7 +122,6 @@ npm install sinon --save-dev Then, configure a Sinon server: ```js -// in ./src/fakeServer.js import sinon from 'sinon'; import { getSinonHandler } from "fakerest"; @@ -133,20 +129,20 @@ const handler = getSinonHandler({ baseUrl: 'http://localhost:3000', data: { 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], + { 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' } - ], + { 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' } + ], 'settings': { language: 'english', preferred_format: 'hardback', - } - }, + } + }, }); // use sinon.js to monkey-patch XmlHttpRequest @@ -156,231 +152,231 @@ sinonServer.autoRespond = true; sinonServer.respondWith(handler); ``` -FakeRest will now intercept every `XmlHttpRequest` requests to the REST server. +FakeRest will now intercept every `XMLHttpRequest` request to the REST server. ## REST Syntax -FakeRest uses a simple a REST syntax described below. +FakeRest uses a simple REST syntax described below. ### Get A Collection of records -`GET /[name]` returns an array or records in the `name` collection. It accepts 4 query parameters: `filter`, `sort`, `range`, and `embed`. It responds with a status 200 if there is no pagination, or 206 if the list of items is paginated. The response contains a mention of the total count in the `Content-Range` header. +`GET /[name]` returns an array of records in the `name` collection. It accepts 4 query parameters: `filter`, `sort`, `range`, and `embed`. It responds with a status 200 if there is no pagination, or 206 if the list of items is paginated. The response mentions the total count in the `Content-Range` header. - GET /books?filter={"author_id":1}&embed=["author"]&sort=["title","desc"]&range=[0-9] + GET /books?filter={"author_id":1}&embed=["author"]&sort=["title","desc"]&range=[0-9] - HTTP 1.1 200 OK - Content-Range: items 0-1/2 - Content-Type: application/json - [ - { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }, - { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } - ] + HTTP 1.1 200 OK + Content-Range: items 0-1/2 + Content-Type: application/json + [ + { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }, + { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } + ] The `filter` param must be a serialized object literal describing the criteria to apply to the search query. See the [supported filters](#supported-filters) for more details. - GET /books?filter={"author_id":1} // return books where author_id is equal to 1 - HTTP 1.1 200 OK - Content-Range: items 0-1/2 - Content-Type: application/json - [ - { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, - { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } - ] - - // array values are possible - GET /books?filter={"id":[2,3]} // return books where id is in [2,3] - HTTP 1.1 200 OK - Content-Range: items 0-1/2 - Content-Type: application/json - [ - { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, - { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } - ] - - // use the special "q" filter to make a full-text search on all text fields - GET /books?filter={"q":"and"} // return books where any of the book properties contains the string 'and' - - HTTP 1.1 200 OK - Content-Range: items 0-2/3 - Content-Type: application/json - [ - { "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" } - ] - - // use _gt, _gte, _lte, _lt, or _neq suffix on filter names to make range queries - GET /books?filter={"price_lte":20} // return books where price is less than or equal to 20 - GET /books?filter={"price_gt":20} // return books where price is greater than 20 - - // when the filter object contains more than one property, the criteria combine with an AND logic - GET /books?filter={"published_at_gte":"2015-06-12","published_at_lte":"2015-06-15"} // return books published between two dates + GET /books?filter={"author_id":1} // return books where author_id is equal to 1 + HTTP 1.1 200 OK + Content-Range: items 0-1/2 + Content-Type: application/json + [ + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, + { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } + ] + + // array values are possible + GET /books?filter={"id":[2,3]} // return books where id is in [2,3] + HTTP 1.1 200 OK + Content-Range: items 0-1/2 + Content-Type: application/json + [ + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, + { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } + ] + + // use the special "q" filter to make a full-text search on all text fields + GET /books?filter={"q":"and"} // return books where any of the book properties contains the string 'and' + + HTTP 1.1 200 OK + Content-Range: items 0-2/3 + Content-Type: application/json + [ + { "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" } + ] + + // use _gt, _gte, _lte, _lt, or _neq suffix on filter names to make range queries + GET /books?filter={"price_lte":20} // return books where the price is less than or equal to 20 + GET /books?filter={"price_gt":20} // return books where the price is greater than 20 + + // when the filter object contains more than one property, the criteria combine with an AND logic + GET /books?filter={"published_at_gte":"2015-06-12","published_at_lte":"2015-06-15"} // return books published between two dates The `sort` param must be a serialized array literal defining first the property used for sorting, then the sorting direction. - GET /author?sort=["date_of_birth","asc"] // return authors, the oldest first - GET /author?sort=["date_of_birth","desc"] // return authors, the youngest first + GET /author?sort=["date_of_birth","asc"] // return authors, the oldest first + GET /author?sort=["date_of_birth","desc"] // return authors, the youngest first -The `range` param defines the number of results by specifying the rank of the first and last result. The first result is #0. +The `range` param defines the number of results by specifying the rank of the first and last results. The first result is #0. - GET /books?range=[0-9] // return the first 10 books - GET /books?range=[10-19] // return the 10 next books + GET /books?range=[0-9] // return the first 10 books + GET /books?range=[10-19] // return the 10 next books The `embed` param sets the related objects or collections to be embedded in the response. - // embed author in books - GET /books?embed=["author"] - HTTP 1.1 200 OK - Content-Range: items 0-3/4 - Content-Type: application/json - [ - { "id": 0, "author_id": 0, "title": "Anna Karenina", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } }, - { "id": 1, "author_id": 0, "title": "War and Peace", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } }, - { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }, - { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } - ] - - // embed books in author - GET /authors?embed=["books"] - HTTP 1.1 200 OK - Content-Range: items 0-1/2 - Content-Type: application/json - [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi', books: [{ id: 0, author_id: 0, title: 'Anna Karenina' }, { id: 1, author_id: 0, title: 'War and Peace' }] }, - { id: 1, first_name: 'Jane', last_name: 'Austen', books: [{ id: 2, author_id: 1, title: 'Pride and Prejudice' }, { id: 3, author_id: 1, title: 'Sense and Sensibility' }] } - ] - - // you can embed several objects - GET /authors?embed=["books","country"] + // embed author in books + GET /books?embed=["author"] + HTTP 1.1 200 OK + Content-Range: items 0-3/4 + Content-Type: application/json + [ + { "id": 0, "author_id": 0, "title": "Anna Karenina", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } }, + { "id": 1, "author_id": 0, "title": "War and Peace", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } }, + { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }, + { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } + ] + + // embed books in author + GET /authors?embed=["books"] + HTTP 1.1 200 OK + Content-Range: items 0-1/2 + Content-Type: application/json + [ + { id: 0, first_name: 'Leo', last_name: 'Tolstoi', books: [{ id: 0, author_id: 0, title: 'Anna Karenina' }, { id: 1, author_id: 0, title: 'War and Peace' }] }, + { id: 1, first_name: 'Jane', last_name: 'Austen', books: [{ id: 2, author_id: 1, title: 'Pride and Prejudice' }, { id: 3, author_id: 1, title: 'Sense and Sensibility' }] } + ] + + // you can embed several objects + GET /authors?embed=["books","country"] ### Get A Single Record `GET /[name]/:id` returns a JSON object, and a status 200, unless the resource doesn't exist. - GET /books/2 + GET /books/2 - HTTP 1.1 200 OK - Content-Type: application/json - { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } The `embed` param sets the related objects or collections to be embedded in the response. - GET /books/2?embed=['author'] + GET /books/2?embed=['author'] - HTTP 1.1 200 OK - Content-Type: application/json - { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } ### Create A Record `POST /[name]` returns a status 201 with a `Location` header for the newly created resource, and the new resource in the body. - POST /books - { "author_id": 1, "title": "Emma" } + POST /books + { "author_id": 1, "title": "Emma" } - HTTP 1.1 201 Created - Location: /books/4 - Content-Type: application/json - { "author_id": 1, "title": "Emma", "id": 4 } + HTTP 1.1 201 Created + Location: /books/4 + Content-Type: application/json + { "author_id": 1, "title": "Emma", "id": 4 } ### Update A Record `PUT /[name]/:id` returns the modified JSON object, and a status 200, unless the resource doesn't exist. - PUT /books/2 - { "author_id": 1, "title": "Pride and Prejudice" } + PUT /books/2 + { "author_id": 1, "title": "Pride and Prejudice" } - HTTP 1.1 200 OK - Content-Type: application/json - { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } ### Delete A Single Record `DELETE /[name]/:id` returns the deleted JSON object, and a status 200, unless the resource doesn't exist. - DELETE /books/2 + DELETE /books/2 - HTTP 1.1 200 OK - Content-Type: application/json - { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } ### Supported Filters -Operators are specified as suffixes on each filtered field. For instance, applying the `_lte` operator on the `price` field for the `books` resource is done by like this: +Operators are specified as suffixes on each filtered field. For instance, applying the `_lte` operator on the `price` field for the `books` resource is done like this: - GET /books?filter={"price_lte":20} // return books where price is less than or equal to 20 + GET /books?filter={"price_lte":20} // return books where the price is less than or equal to 20 - `_eq`: check for equality on simple values: - GET /books?filter={"price_eq":20} // return books where price is equal to 20 + GET /books?filter={"price_eq":20} // return books where the price is equal to 20 - `_neq`: check for inequality on simple values - GET /books?filter={"price_neq":20} // return books where price is not equal to 20 + GET /books?filter={"price_neq":20} // return books where the price is not equal to 20 - `_eq_any`: check for equality on any passed values - GET /books?filter={"price_eq_any":[20, 30]} // return books where price is equal to 20 or 30 + GET /books?filter={"price_eq_any":[20, 30]} // return books where the price is equal to 20 or 30 - `_neq_any`: check for inequality on any passed values - GET /books?filter={"price_neq_any":[20, 30]} // return books where price is not equal to 20 nor 30 + GET /books?filter={"price_neq_any":[20, 30]} // return books where the price is not equal to 20 nor 30 -- `_inc_any`: check for items that includes any of the passed values +- `_inc_any`: check for items that include any of the passed values - GET /books?filter={"authors_inc_any":['William Gibson', 'Pat Cadigan']} // return books where authors includes either 'William Gibson' or 'Pat Cadigan' or both + GET /books?filter={"authors_inc_any":['William Gibson', 'Pat Cadigan']} // return books where authors include either 'William Gibson' or 'Pat Cadigan' or both -- `_q`: check for items that contains the provided text +- `_q`: check for items that contain the provided text - GET /books?filter={"author_q":['Gibson']} // return books where author includes 'Gibson' not considering the other fields + GET /books?filter={"author_q":['Gibson']} // return books where the author includes 'Gibson' not considering the other fields -- `_lt`: check for items that has a value lower than the provided value +- `_lt`: check for items that have a value lower than the provided value - GET /books?filter={"price_lte":100} // return books that have a price lower that 100 + GET /books?filter={"price_lte":100} // return books that have a price lower that 100 -- `_lte`: check for items that has a value lower or equal than the provided value +- `_lte`: check for items that have a value lower than or equal to the provided value - GET /books?filter={"price_lte":100} // return books that have a price lower or equal to 100 + GET /books?filter={"price_lte":100} // return books that have a price lower or equal to 100 -- `_gt`: check for items that has a value greater than the provided value +- `_gt`: check for items that have a value greater than the provided value - GET /books?filter={"price_gte":100} // return books that have a price greater that 100 + GET /books?filter={"price_gte":100} // return books that have a price greater that 100 -- `_gte`: check for items that has a value greater or equal than the provided value +- `_gte`: check for items that have a value greater than or equal to the provided value - GET /books?filter={"price_gte":100} // return books that have a price greater or equal to 100 + GET /books?filter={"price_gte":100} // return books that have a price greater or equal to 100 ### Single Elements FakeRest allows you to define a single element, such as a user profile or global settings, that can be fetched, updated, or deleted. - GET /settings + GET /settings - HTTP 1.1 200 OK - Content-Type: application/json - { "language": "english", "preferred_format": "hardback" } + HTTP 1.1 200 OK + Content-Type: application/json + { "language": "english", "preferred_format": "hardback" } - PUT /settings - { "language": "french", "preferred_format": "paperback" } + PUT /settings + { "language": "french", "preferred_format": "paperback" } - HTTP 1.1 200 OK - Content-Type: application/json - { "language": "french", "preferred_format": "paperback" } + HTTP 1.1 200 OK + Content-Type: application/json + { "language": "french", "preferred_format": "paperback" } - DELETE /settings + DELETE /settings - HTTP 1.1 200 OK - Content-Type: application/json - { "language": "french", "preferred_format": "paperback" } + HTTP 1.1 200 OK + Content-Type: application/json + { "language": "french", "preferred_format": "paperback" } ## Middlewares -Middlewares let you to intercept requests and simulate server features such as: - - authentication checks - - server side validation - - server dynamically generated values - - simulate response delays +Middlewares let you intercept requests and simulate server features such as: + - authentication checks + - server-side validation + - server dynamically generated values + - simulate response delays You can define middlewares on all handlers, by passing a `middlewares` option: @@ -397,31 +393,31 @@ const handler = getMswHandler({ return { status: 401, headers: {}, - }; - } + }; + } return next(context); - }, + }, withDelay(300), - ], + ], }); ``` -A middleware is a function that receive 2 parameters: - - The FakeRest `context`, an object containing the data extracted from the request that FakeRest uses to build the response. It has the following properties: - - `method`: The request method as a string (`GET`, `POST`, `PATCH` or `PUT`) - - `url`: The request URL as a string - - `headers`: The request headers as an object where keys are header names - - `requestBody`: The parsed request data if any - - `params`: The request parameters from the URL search (e.g. the identifier of the requested record) - - `collection`: The name of the targeted [collection](#collection) (e.g. `posts`) - - `single`: The name of the targeted [single](#single) (e.g. `settings`) - - A `next` function to call the next middleware in the chain, to which you must pass the `context` +A middleware is a function that receives 2 parameters: + - The FakeRest `context`, an object containing the data extracted from the request that FakeRest uses to build the response. It has the following properties: + - `method`: The request method as a string (`GET`, `POST`, `PATCH` or `PUT`) + - `url`: The request URL as a string + - `headers`: The request headers as an object where keys are header names + - `requestBody`: The parsed request data if any + - `params`: The request parameters from the URL search (e.g. the identifier of the requested record) + - `collection`: The name of the targeted [collection](#collection) (e.g. `posts`) + - `single`: The name of the targeted [single](#single) (e.g. `settings`) + - A `next` function to call the next middleware in the chain, to which you must pass 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 + - `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 ### Authentication Checks @@ -435,10 +431,10 @@ const handler = getMswHandler({ async (context, next) => { if (context.headers.Authorization === undefined) { return { status: 401, headers: {} }; - } + } return next(context); - } - ] + } + ] }); ``` @@ -455,22 +451,22 @@ const handler = getMswHandler({ if ( context.collection === "books" && request.method === "POST" && - !context.requestBody?.title - ) { + !context.requestBody?.title + ) { return { status: 400, headers: {}, body: { errors: { title: 'An article with this title already exists. The title must be unique.', - }, - }, - }; - } + }, + }, + }; + } return next(context); - } - ] + } + ] }); ``` @@ -487,15 +483,15 @@ const handler = getMswHandler({ if ( context.collection === 'books' && context.method === 'POST' - ) { + ) { const response = await next(context); response.body.updatedAt = new Date().toISOString(); return response; - } + } return next(context); - } - ] + } + ] }); ``` @@ -512,10 +508,10 @@ const handler = getMswHandler({ return new Promise((resolve) => { setTimeout(() => { resolve(next(context)); - }, 500); - }); - } - ] + }, 500); + }); + } + ] }); ``` @@ -529,18 +525,18 @@ const handler = getMswHandler({ data, middlewares: [ withDelay(500), // delay in ms - ] + ] }); ``` ## Configuration -All handlers can be customized to accomodate your API structure. +All handlers can be customized to accommodate your API structure. ### 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. +By default, FakeRest assumes all records have a unique `id` field. +Some databases such as [MongoDB](https://www.mongodb.com) use `_id` instead of `id` for collection identifiers. You can customize FakeRest to do the same by setting the `identifierName` option: ```js @@ -564,7 +560,7 @@ const handler = adapter.getHandler(); ### Primary Keys -By default, FakeRest uses an auto incremented sequence for the items identifiers. +By default, FakeRest uses an auto-incremented sequence for the item 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 @@ -606,7 +602,7 @@ const handler = getMswHandler({ if (resourceName == 'authors') return { embed: ['books'] } if (resourceName == 'books') return { filter: { published: true } } return {}; - } + } }); ``` @@ -640,20 +636,20 @@ const adapter = new MswAdapter({ baseUrl: 'http://my.custom.domain', data: { 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], + { 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' } - ], + { 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' } + ], 'settings': { language: 'english', preferred_format: 'hardback', - } - } + } + } }); window.fakerest = adapter; const handler = adapter.getHandler(); @@ -672,20 +668,20 @@ const server = new SimpleRestServer({ baseUrl: 'http://my.custom.domain', data: { 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], + { 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' } - ], + { 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' } + ], 'settings': { language: 'english', preferred_format: 'hardback', - } - } + } + } }); const adapter = new MswAdapter({ server }); const handler = adapter.getHandler(); @@ -728,25 +724,25 @@ You can specify the database used by a server by setting its `database` property const database = new Database({ data: { 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], + { 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' } - ], + { 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' } + ], 'settings': { language: 'english', preferred_format: 'hardback', - } - } + } + } }); const server = new SimpleRestServer({ baseUrl: 'http://my.custom.domain', database }); ``` -You can even use the database object, if you want to manipulate the data: +You can even use the database object if you want to manipulate the data: ```js database.updateOne('authors', 0, { first_name: 'Lev' }); @@ -754,27 +750,27 @@ database.updateOne('authors', 0, { first_name: 'Lev' }); ### Collections & Singles -The Database mays contain collections ans singles. In the following example, `authors` and `books` are collections, and `settings` is a single. +The Database may contain collections and singles. In the following example, `authors` and `books` are collections, and `settings` is a single. ```js const handler = getMswHandler({ baseUrl: 'http://localhost:3000', data: { 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], + { 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' } - ], + { 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' } + ], 'settings': { language: 'english', preferred_format: 'hardback', - } - } + } + } }); ``` @@ -784,13 +780,13 @@ A single represents an API endpoint that returns a single entity. It's useful fo ### Embeds -FakeRest support embedding other resources in a main resource query result. For instance, embedding the author of a book. +FakeRest supports embedding other resources in a main resource query result. For instance, embedding the author of a book. - GET /books/2?embed=['author'] + GET /books/2?embed=['author'] - HTTP 1.1 200 OK - Content-Type: application/json - { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } Embeds are defined by the query, they require no setup in the database. From c5c42b0958c6262cf0863e03976d78e5cc406e6f Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 7 Jun 2024 15:43:31 +0200 Subject: [PATCH 15/16] Update build --- UPGRADE.md | 76 +++++++++++++++++++++----------------------------- src/index.ts | 57 ++++++------------------------------- vite.config.ts | 1 + 3 files changed, 42 insertions(+), 92 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index b6026e9..f9fe4fc 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,49 +1,39 @@ # Upgrading to 4.0.0 -## Renamed `Server` And `FetchServer` +## Dropped bower support -The `Server` class has been renamed to `SinonServer`. +Fakerest no longer supports bower. You can still use it in your project by installing it via npm: -```diff --import { Server } from 'fakerest'; -+import { SinonServer } from 'fakerest'; - --const server = new Server('http://myapi.com'); -+const server = new SinonServer({ baseUrl: 'http://myapi.com' }); +```bash +npm install fakerest ``` -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' }); -``` +## Renamed `Server` to `SinonAdapter` -## Constructors Of `SinonServer` and `FetchMockServer` Take An Object - -For `SinonServer`: +The `Server` class has been renamed to `SinonAdapter` and now expects a configuration object instead of a URL. ```diff -import { SinonServer } from 'fakerest'; +-import { Server } from 'fakerest'; ++import { SinonAdapter } from 'fakerest'; import { data } from './data'; --const server = new SinonServer('http://myapi.com'); -+const server = new SinonServer({ baseUrl: 'http://myapi.com' }); -server.init(data); +-const server = new Server('http://myapi.com'); +-server.init(data); ++const server = new SinonAdapter({ baseUrl: 'http://myapi.com', data }); ``` -For `FetchServer`: +## Renamed `FetchServer` to `FetchMockAdapter` + +The `FetchServer` class has been renamed to `FetchMockAdapter` and now expects a configuration object instead of a URL. ```diff -import { FetchMockServer } from 'fakerest'; +-import { FetchServer } from 'fakerest'; ++import { FetchMockAdapter } from 'fakerest'; import { data } from './data'; --const server = new FetchMockServer('http://myapi.com'); -+const server = new FetchMockServer({ baseUrl: 'http://myapi.com' }); -server.init(data); +-const server = new FetchServer('http://myapi.com'); +-server.init(data); ++const server = new FetchMockAdapter({ baseUrl: 'http://myapi.com', data }); ``` ## Constructor Of `Collection` Takes An Object @@ -67,26 +57,24 @@ server.init(data); Fakerest used to have request and response interceptors. We replaced those with middlewares. They allow much more use cases. -Migrate your request interceptors: +Migrate your request interceptors to middlewares passed when building the handler: ```diff --restServer.addRequestInterceptor(function(request) { -+restServer.addMiddleware(async function(request, context, next) { +- const myRequestInterceptor = function(request) { ++ const myMiddleware = async function(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); +- return request; // always return the modified input ++ return next(context); +}; + +-restServer.addRequestInterceptor(myRequestInterceptor); ++const handler = new getMswHandler({ ++ baseUrl: 'http://my.custom.domain', ++ data, ++ middlewares: [myMiddleware], }); ``` -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 +Migrate your response interceptors the same way. diff --git a/src/index.ts b/src/index.ts index 5f2538a..f13cc7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,48 +1,9 @@ -import { - getSinonHandler, - Server, - SinonAdapter, -} from './adapters/SinonAdapter.js'; -import { - getFetchMockHandler, - FetchServer, - FetchMockAdapter, -} from './adapters/FetchMockAdapter.js'; -import { getMswHandler, MswAdapter } from './adapters/MswAdapter.js'; -import { Database } from './Database.js'; -import { SimpleRestServer } from './SimpleRestServer.js'; -import { Collection } from './Collection.js'; -import { Single } from './Single.js'; -import { withDelay } from './withDelay.js'; - -export { - SimpleRestServer, - Database, - getSinonHandler, - getFetchMockHandler, - getMswHandler, - Server, - SinonAdapter, - FetchServer, - FetchMockAdapter, - MswAdapter, - Collection, - Single, - withDelay, -}; - -export default { - SimpleRestServer, - Database, - getSinonHandler, - getFetchMockHandler, - getMswHandler, - Server, - SinonAdapter, - FetchServer, - FetchMockAdapter, - MswAdapter, - Collection, - Single, - withDelay, -}; +export * from './types.js'; +export * from './adapters/SinonAdapter.js'; +export * from './adapters/FetchMockAdapter.js'; +export * from './adapters/MswAdapter.js'; +export * from './Database.js'; +export * from './SimpleRestServer.js'; +export * from './Collection.js'; +export * from './Single.js'; +export * from './withDelay.js'; diff --git a/vite.config.ts b/vite.config.ts index 9ac7305..27f1ecc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ // the proper extensions will be added fileName: 'fakerest', }, + minify: false, sourcemap: true, rollupOptions: { // make sure to externalize deps that shouldn't be bundled From 76adb63be8998889b7a21eaa2abbb26a1952150f Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 7 Jun 2024 15:59:48 +0200 Subject: [PATCH 16/16] Review --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 80ca7d0..8b6498f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A browser library that intercepts AJAX calls to mock a REST server based on JSON data. -Use it on top of [Sinon.js](http://sinonjs.org/) (for `XMLHttpRequest`) or [fetch-mock](https://github.com/wheresrhys/fetch-mock) (for `fetch`) to test JavaScript REST clients on the browser side (e.g. single page apps) without a server. +Use it in conjunction with [MSW](https://mswjs.io/), [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/), or [Sinon.js](https://sinonjs.org/releases/v18/fake-xhr-and-server/) to test JavaScript REST clients on the client side (e.g. single page apps) without a server. 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)).