diff --git a/UPGRADE.md b/UPGRADE.md index 392a91d..a804d3a 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,26 +1,48 @@ # Upgrading to 4.0.0 -## Constructors Of `FetchServer` and `Server` Take An Object +## Renamed `Server` And `FetchServer` -For `Server`: +The `Server` class has been renamed to `SinonServer`. ```diff -import { Server } from 'fakerest'; -import { data } from './data'; +-import { Server } from 'fakerest'; ++import { SinonServer } from 'fakerest'; -const server = new Server('http://myapi.com'); -+const server = new Server({ baseUrl: 'http://myapi.com' }); ++const server = new SinonServer({ baseUrl: 'http://myapi.com' }); +``` + +The `FetchServer` class has been renamed to `FetchMockServer`. + +```diff +-import { FetchServer } from 'fakerest'; ++import { FetchMockServer } from 'fakerest'; + +-const server = new FetchServer('http://myapi.com'); ++const server = new FetchMockServer({ baseUrl: 'http://myapi.com' }); +``` + +## Constructors Of `SinonServer` and `FetchMockServer` Take An Object + +For `SinonServer`: + +```diff +import { SinonServer } from 'fakerest'; +import { data } from './data'; + +-const server = new SinonServer('http://myapi.com'); ++const server = new SinonServer({ baseUrl: 'http://myapi.com' }); server.init(data); ``` For `FetchServer`: ```diff -import { FetchServer } from 'fakerest'; +import { FetchMockServer } from 'fakerest'; import { data } from './data'; --const server = new FetchServer('http://myapi.com'); -+const server = new FetchServer({ baseUrl: 'http://myapi.com' }); +-const server = new FetchMockServer('http://myapi.com'); ++const server = new FetchMockServer({ baseUrl: 'http://myapi.com' }); server.init(data); ``` diff --git a/example/msw.ts b/example/msw.ts index bef2b18..d22ae47 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -1,6 +1,5 @@ import { setupWorker } from 'msw/browser'; -import { HttpResponse } from 'msw'; -import { MswServer, withDelay } from '../src/FakeRest'; +import { MswServer, withDelay } from '../src'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; @@ -43,6 +42,6 @@ restServer.addMiddleware(async (request, context, next) => { return next(request, context); }); -export const worker = setupWorker(...restServer.getHandlers()); +export const worker = setupWorker(restServer.getHandler()); export const dataProvider = defaultDataProvider; diff --git a/src/BaseServer.ts b/src/BaseServer.ts index 6d169e9..61a62d2 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -1,13 +1,67 @@ -import { - type BaseResponse, - type FakeRestContext, - AbstractBaseServer, -} from './AbstractBaseServer.js'; +import { Database, type DatabaseOptions } from './Database.js'; +import type { QueryFunction } from './types.js'; -export class BaseServer extends AbstractBaseServer { +export class BaseServer extends Database { + baseUrl = ''; + defaultQuery: QueryFunction = () => ({}); middlewares: Array> = []; - extractContext( + constructor({ + baseUrl = '', + defaultQuery = () => ({}), + ...options + }: BaseServerOptions = {}) { + super(options); + this.baseUrl = baseUrl; + this.defaultQuery = defaultQuery; + } + + /** + * @param Function ResourceName => object + */ + setDefaultQuery(query: QueryFunction) { + this.defaultQuery = query; + } + + getContext( + context: Pick< + FakeRestContext, + 'url' | 'method' | 'params' | 'requestJson' + >, + ): FakeRestContext { + for (const name of this.getSingleNames()) { + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), + ); + if (!matches) continue; + return { + ...context, + single: name, + }; + } + + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), + ); + if (matches) { + const name = matches[1]; + const params = Object.assign( + {}, + this.defaultQuery(name), + context.params, + ); + + return { + ...context, + collection: name, + params, + }; + } + + return context; + } + + getNormalizedRequest( request: RequestType, ): Promise< Pick @@ -24,7 +78,9 @@ export class BaseServer extends AbstractBaseServer { } async handle(request: RequestType): Promise { - const context = this.getContext(await this.extractContext(request)); + const context = this.getContext( + await this.getNormalizedRequest(request), + ); // Call middlewares let index = 0; @@ -294,3 +350,33 @@ export type Middleware = ( ctx: FakeRestContext, ) => Promise | BaseResponse | null, ) => Promise | BaseResponse | null; + +export type BaseServerOptions = DatabaseOptions & { + baseUrl?: string; + batchUrl?: string | null; + defaultQuery?: QueryFunction; +}; + +export type BaseRequest = { + url?: string; + method?: string; + collection?: string; + single?: string; + requestJson?: Record | undefined; + params?: { [key: string]: any }; +}; + +export type BaseResponse = { + status: number; + body?: Record | Record[]; + headers: { [key: string]: string }; +}; + +export type FakeRestContext = { + url?: string; + method?: string; + collection?: string; + single?: string; + requestJson: Record | undefined; + params: { [key: string]: any }; +}; diff --git a/src/Collection.ts b/src/Collection.ts index 08edbc2..203466e 100644 --- a/src/Collection.ts +++ b/src/Collection.ts @@ -1,6 +1,6 @@ import get from 'lodash/get.js'; import matches from 'lodash/matches.js'; -import type { AbstractBaseServer } from './AbstractBaseServer.js'; +import type { Database } from './Database.js'; import type { CollectionItem, Embed, @@ -14,7 +14,7 @@ import type { export class Collection { sequence = 0; items: T[] = []; - server: AbstractBaseServer | null = null; + server: Database | null = null; name: string | null = null; identifierName = 'id'; getNewId: () => number | string; @@ -42,7 +42,7 @@ export class Collection { * A Collection may need to access other collections (e.g. for embedding references) * This is done through a reference to the parent server. */ - setServer(server: AbstractBaseServer) { + setServer(server: Database) { this.server = server; } diff --git a/src/AbstractBaseServer.spec.ts b/src/Database.spec.ts similarity index 88% rename from src/AbstractBaseServer.spec.ts rename to src/Database.spec.ts index 534a9e9..ae500e0 100644 --- a/src/AbstractBaseServer.spec.ts +++ b/src/Database.spec.ts @@ -1,13 +1,11 @@ -import sinon, { type SinonFakeXMLHttpRequest } from 'sinon'; - -import { AbstractBaseServer } from './AbstractBaseServer.js'; +import { Database } from './Database.js'; import { Single } from './Single.js'; import { Collection } from './Collection.js'; describe('AbstractBaseServer', () => { describe('init', () => { it('should populate several collections', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.init({ foo: [{ a: 1 }, { a: 2 }, { a: 3 }], bar: [{ b: true }, { b: false }], @@ -28,7 +26,7 @@ describe('AbstractBaseServer', () => { describe('addCollection', () => { it('should add a collection and index it by name', () => { - const server = new AbstractBaseServer(); + const server = new Database(); const collection = new Collection({ items: [ { id: 1, name: 'foo' }, @@ -43,7 +41,7 @@ describe('AbstractBaseServer', () => { describe('addSingle', () => { it('should add a single object and index it by name', () => { - const server = new AbstractBaseServer(); + const server = new Database(); const single = new Single({ name: 'foo', description: 'bar' }); server.addSingle('foo', single); expect(server.getSingle('foo')).toEqual(single); @@ -52,7 +50,7 @@ describe('AbstractBaseServer', () => { describe('getAll', () => { it('should return all items for a given name', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.addCollection( 'foo', new Collection({ @@ -74,7 +72,7 @@ describe('AbstractBaseServer', () => { }); it('should support a query', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.addCollection( 'foo', new Collection({ @@ -100,7 +98,7 @@ describe('AbstractBaseServer', () => { describe('getOne', () => { it('should return an error when no collection match the identifier', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.addCollection( 'foo', new Collection({ items: [{ id: 1, name: 'foo' }] }), @@ -111,7 +109,7 @@ describe('AbstractBaseServer', () => { }); it('should return the first collection matching the identifier', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.addCollection( 'foo', new Collection({ @@ -126,7 +124,7 @@ describe('AbstractBaseServer', () => { }); it('should use the identifierName', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.addCollection( 'foo', new Collection({ @@ -144,7 +142,7 @@ describe('AbstractBaseServer', () => { describe('getOnly', () => { it('should return the single matching the identifier', () => { - const server = new AbstractBaseServer(); + const server = new Database(); server.addSingle('foo', new Single({ name: 'foo' })); expect(server.getOnly('foo')).toEqual({ name: 'foo' }); }); diff --git a/src/AbstractBaseServer.ts b/src/Database.ts similarity index 60% rename from src/AbstractBaseServer.ts rename to src/Database.ts index 93725e2..a1cd19e 100644 --- a/src/AbstractBaseServer.ts +++ b/src/Database.ts @@ -2,34 +2,19 @@ import { Collection } from './Collection.js'; import { Single } from './Single.js'; import type { CollectionItem, Query, QueryFunction } from './types.js'; -/** - * This base class does not need generics so we can reference it in Collection and Single - * without having to propagate mocking implementation generics nor requiring the user to specify them. - * The BaseServerWithMiddlewares class is the one that needs to have generic parameters which are - * provided by the mocking implementation server classes. - */ -export class AbstractBaseServer { - baseUrl = ''; +export class Database { identifierName = 'id'; - loggingEnabled = false; - defaultQuery: QueryFunction = () => ({}); collections: Record> = {}; singles: Record> = {}; getNewId?: () => number | string; constructor({ - baseUrl = '', data, - defaultQuery = () => ({}), identifierName = 'id', getNewId, - loggingEnabled = false, - }: BaseServerOptions = {}) { - this.baseUrl = baseUrl; + }: DatabaseOptions = {}) { this.getNewId = getNewId; - this.loggingEnabled = loggingEnabled; this.identifierName = identifierName; - this.defaultQuery = defaultQuery; if (data) { this.init(data); @@ -57,17 +42,6 @@ export class AbstractBaseServer { } } - toggleLogging() { - this.loggingEnabled = !this.loggingEnabled; - } - - /** - * @param Function ResourceName => object - */ - setDefaultQuery(query: QueryFunction) { - this.defaultQuery = query; - } - addCollection( name: string, collection: Collection, @@ -151,47 +125,9 @@ export class AbstractBaseServer { updateOnly(name: string, item: CollectionItem) { return this.singles[name].updateOnly(item); } - - getContext( - context: Pick< - FakeRestContext, - 'url' | 'method' | 'params' | 'requestJson' - >, - ): FakeRestContext { - for (const name of this.getSingleNames()) { - const matches = context.url?.match( - new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), - ); - if (!matches) continue; - return { - ...context, - single: name, - }; - } - - const matches = context.url?.match( - new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), - ); - if (matches) { - const name = matches[1]; - const params = Object.assign( - {}, - this.defaultQuery(name), - context.params, - ); - - return { - ...context, - collection: name, - params, - }; - } - - return context; - } } -export type BaseServerOptions = { +export type DatabaseOptions = { baseUrl?: string; batchUrl?: string | null; data?: Record; @@ -200,27 +136,3 @@ export type BaseServerOptions = { getNewId?: () => number | string; loggingEnabled?: boolean; }; - -export type BaseRequest = { - url?: string; - method?: string; - collection?: string; - single?: string; - requestJson?: Record | undefined; - params?: { [key: string]: any }; -}; - -export type BaseResponse = { - status: number; - body?: Record | Record[]; - headers: { [key: string]: string }; -}; - -export type FakeRestContext = { - url?: string; - method?: string; - collection?: string; - single?: string; - requestJson: Record | undefined; - params: { [key: string]: any }; -}; diff --git a/src/Single.ts b/src/Single.ts index 5dbb320..2d5a87b 100644 --- a/src/Single.ts +++ b/src/Single.ts @@ -1,9 +1,9 @@ -import type { AbstractBaseServer } from './AbstractBaseServer.js'; +import type { Database } from './Database.js'; import type { CollectionItem, Embed, Query } from './types.js'; export class Single { obj: T | null = null; - server: AbstractBaseServer | null = null; + server: Database | null = null; name: string | null = null; constructor(obj: T) { @@ -19,7 +19,7 @@ export class Single { * A Single may need to access other collections (e.g. for embedded * references) This is done through a reference to the parent server. */ - setServer(server: AbstractBaseServer) { + setServer(server: Database) { this.server = server; } diff --git a/src/adapters/FetchMockServer.ts b/src/adapters/FetchMockServer.ts index 3af5a9d..6e9f11b 100644 --- a/src/adapters/FetchMockServer.ts +++ b/src/adapters/FetchMockServer.ts @@ -1,14 +1,28 @@ -import type { MockResponseObject, MockMatcherFunction } from 'fetch-mock'; -import { BaseServer } from '../BaseServer.js'; -import type { - BaseResponse, - BaseServerOptions, - FakeRestContext, -} from '../AbstractBaseServer.js'; +import type { MockResponseObject } from 'fetch-mock'; +import { + type BaseResponse, + BaseServer, + type FakeRestContext, + type BaseServerOptions, +} from '../BaseServer.js'; import { parseQueryString } from '../parseQueryString.js'; export class FetchMockServer extends BaseServer { - async extractContext(request: Request) { + loggingEnabled = false; + + constructor({ + loggingEnabled = false, + ...options + }: FetchMockServerOptions = {}) { + super(options); + this.loggingEnabled = loggingEnabled; + } + + toggleLogging() { + this.loggingEnabled = !this.loggingEnabled; + } + + async getNormalizedRequest(request: Request) { const req = typeof request === 'string' ? new Request(request) : request; const queryString = req.url @@ -89,7 +103,7 @@ export class FetchMockServer extends BaseServer { } } -export const getFetchMockHandler = (options: BaseServerOptions) => { +export const getFetchMockHandler = (options: FetchMockServerOptions) => { const server = new FetchMockServer(options); return server.getHandler(); }; @@ -106,3 +120,7 @@ export type FetchMockFakeRestRequest = Partial & { queryString?: string; params?: { [key: string]: any }; }; + +export type FetchMockServerOptions = BaseServerOptions & { + loggingEnabled?: boolean; +}; diff --git a/src/adapters/MswServer.ts b/src/adapters/MswServer.ts new file mode 100644 index 0000000..916e844 --- /dev/null +++ b/src/adapters/MswServer.ts @@ -0,0 +1,48 @@ +import { http, HttpResponse } from 'msw'; +import { type BaseResponse, BaseServer } from '../BaseServer.js'; +import type { DatabaseOptions } from '../Database.js'; + +export class MswServer extends BaseServer { + async respond(response: BaseResponse) { + return HttpResponse.json(response.body, { + status: response.status, + headers: response.headers, + }); + } + + async getNormalizedRequest(request: Request) { + const url = new URL(request.url); + const params = Object.fromEntries( + Array.from(new URLSearchParams(url.search).entries()).map( + ([key, value]) => [key, JSON.parse(value)], + ), + ); + let requestJson: Record | undefined = undefined; + try { + const text = await request.text(); + requestJson = JSON.parse(text); + } catch (e) { + // not JSON, no big deal + } + + return { + url: request.url, + params, + requestJson, + method: request.method, + }; + } + + getHandler() { + return http.all( + // Using a regex ensures we match all URLs that start with the collection name + new RegExp(`${this.baseUrl}`), + ({ request }) => this.handle(request), + ); + } +} + +export const getMswHandler = (options: DatabaseOptions) => { + const server = new MswServer(options); + return server.getHandler(); +}; diff --git a/src/adapters/SinonServer.spec.ts b/src/adapters/SinonServer.spec.ts index c933412..d433c7e 100644 --- a/src/adapters/SinonServer.spec.ts +++ b/src/adapters/SinonServer.spec.ts @@ -3,7 +3,7 @@ import sinon, { type SinonFakeXMLHttpRequest } from 'sinon'; import { SinonServer } from './SinonServer.js'; import { Single } from '../Single.js'; import { Collection } from '../Collection.js'; -import type { BaseResponse } from '../AbstractBaseServer.js'; +import type { BaseResponse } from '../BaseServer.js'; function getFakeXMLHTTPRequest( method: string, diff --git a/src/adapters/SinonServer.ts b/src/adapters/SinonServer.ts index f07d8fc..51a8cc4 100644 --- a/src/adapters/SinonServer.ts +++ b/src/adapters/SinonServer.ts @@ -1,13 +1,30 @@ import type { SinonFakeXMLHttpRequest } from 'sinon'; -import { BaseServer } from '../BaseServer.js'; +import { + type BaseResponse, + BaseServer, + type BaseServerOptions, +} from '../BaseServer.js'; import { parseQueryString } from '../parseQueryString.js'; -import type { BaseResponse, BaseServerOptions } from '../AbstractBaseServer.js'; export class SinonServer extends BaseServer< SinonFakeXMLHttpRequest, SinonFakeRestResponse > { - async extractContext(request: SinonFakeXMLHttpRequest) { + loggingEnabled = false; + + constructor({ + loggingEnabled = false, + ...options + }: SinonServerOptions = {}) { + super(options); + this.loggingEnabled = loggingEnabled; + } + + toggleLogging() { + this.loggingEnabled = !this.loggingEnabled; + } + + async getNormalizedRequest(request: SinonFakeXMLHttpRequest) { const req: Request | SinonFakeXMLHttpRequest = typeof request === 'string' ? new Request(request) : request; @@ -133,7 +150,7 @@ export class SinonServer extends BaseServer< } } -export const getSinonHandler = (options: BaseServerOptions) => { +export const getSinonHandler = (options: SinonServerOptions) => { const server = new SinonServer(options); return server.getHandler(); }; @@ -148,3 +165,7 @@ export type SinonFakeRestResponse = { body: any; headers: Record; }; + +export type SinonServerOptions = BaseServerOptions & { + loggingEnabled?: boolean; +}; diff --git a/src/adapters/msw.ts b/src/adapters/msw.ts deleted file mode 100644 index 5671314..0000000 --- a/src/adapters/msw.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { http, HttpResponse } from 'msw'; -import { BaseServer } from '../BaseServer.js'; -import type { - BaseRequest, - BaseResponse, - BaseServerOptions, -} from '../AbstractBaseServer.js'; - -export class MswServer extends BaseServer { - async respond(response: BaseResponse) { - return HttpResponse.json(response.body, { - status: response.status, - headers: response.headers, - }); - } - - async extractContext(request: Request) { - const url = new URL(request.url); - const params = Object.fromEntries( - Array.from(new URLSearchParams(url.search).entries()).map( - ([key, value]) => [key, JSON.parse(value)], - ), - ); - let requestJson: Record | undefined = undefined; - try { - const text = await request.text(); - requestJson = JSON.parse(text); - } catch (e) { - // not JSON, no big deal - } - - const req: MswFakeRestRequest = request; - req.requestJson = requestJson; - req.params = params; - - return { - url: request.url, - params, - requestJson, - method: request.method, - }; - } - - getHandlers() { - return Object.keys(this.collections).map((collectionName) => - getCollectionHandlers({ - baseUrl: this.baseUrl, - collectionName, - server: this, - }), - ); - } -} - -export const getMswHandlers = (options: BaseServerOptions) => { - const server = new MswServer(options); - return server.getHandlers(); -}; - -const getCollectionHandlers = ({ - baseUrl, - collectionName, - server, -}: { - baseUrl: string; - collectionName: string; - server: MswServer; -}) => { - return http.all( - // Using a regex ensures we match all URLs that start with the collection name - new RegExp(`${baseUrl}/${collectionName}`), - ({ request }) => server.handle(request), - ); -}; - -export type MswFakeRestRequest = Partial & BaseRequest; diff --git a/src/FakeRest.ts b/src/index.ts similarity index 87% rename from src/FakeRest.ts rename to src/index.ts index 956c2e3..34b54ad 100644 --- a/src/FakeRest.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { FetchServer, FetchMockServer, } from './adapters/FetchMockServer.js'; -import { getMswHandlers, MswServer } from './adapters/msw.js'; +import { getMswHandler, MswServer } from './adapters/MswServer.js'; import { Collection } from './Collection.js'; import { Single } from './Single.js'; import { withDelay } from './withDelay.js'; @@ -16,7 +16,7 @@ import { withDelay } from './withDelay.js'; export { getSinonHandler, getFetchMockHandler, - getMswHandlers, + getMswHandler, Server, SinonServer, FetchServer, @@ -30,7 +30,7 @@ export { export default { getSinonHandler, getFetchMockHandler, - getMswHandlers, + getMswHandler, Server, SinonServer, FetchServer, diff --git a/vite.config.min.ts b/vite.config.min.ts index 86a8b0e..7a36c0b 100644 --- a/vite.config.min.ts +++ b/vite.config.min.ts @@ -6,7 +6,7 @@ export default defineConfig({ build: { lib: { // Could also be a dictionary or array of multiple entry points - entry: resolve(__dirname, 'src/FakeRest.ts'), + entry: resolve(__dirname, 'src/index.ts'), name: 'FakeRest', // the proper extensions will be added fileName: 'fakerest.min', diff --git a/vite.config.ts b/vite.config.ts index 1eb13f7..9ac7305 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ build: { lib: { // Could also be a dictionary or array of multiple entry points - entry: resolve(__dirname, 'src/FakeRest.ts'), + entry: resolve(__dirname, 'src/index.ts'), name: 'FakeRest', // the proper extensions will be added fileName: 'fakerest',