diff --git a/Makefile b/Makefile index 99d001f..a93f5bf 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,9 @@ run-msw: run-fetch-mock: @NODE_ENV=development VITE_MOCK=fetch-mock npm run dev +run-sinon: + @NODE_ENV=development VITE_MOCK=sinon npm run dev + watch: @NODE_ENV=development npm run build --watch diff --git a/README.md b/README.md index be8e6e2..0bdf5a1 100644 --- a/README.md +++ b/README.md @@ -4,25 +4,25 @@ Intercept AJAX calls to fake a REST server based on JSON data. Use it on top of See it in action in the [react-admin](https://marmelab.com/react-admin/) [demo](https://marmelab.com/react-admin-demo) ([source code](https://github.com/marmelab/react-admin/tree/master/examples/demo)). -## Usage +## Installation ### MSW We recommend you use [MSW](https://mswjs.io/) to mock your API. This will allow you to inspect requests as you usually do in the devtools network tab. -First, install msw and initialize it: +First, install fakerest and MSW. Then initialize MSW: ```sh -npm install msw@latest --save-dev +npm install fakerest msw@latest --save-dev npx msw init # eg: public ``` Then configure it: ```js -// in ./src/msw.js +// in ./src/fakeServer.js import { setupWorker } from "msw/browser"; -import { getMswHandlers } from "fakerest"; +import { getMswHandler } from "fakerest"; const data = { 'authors': [ @@ -41,7 +41,8 @@ const data = { } }; -export const worker = setupWorker(...getMswHandlers({ +export const worker = setupWorker(getMswHandler({ + baseUrl: 'http://localhost:3000', data })); ``` @@ -52,17 +53,20 @@ Finally call the `worker.start()` method before rendering your application. For import React from "react"; import ReactDom from "react-dom"; import { App } from "./App"; -import { worker } from "./msw"; +import { worker } from "./fakeServer"; -worker.start().then(() => { +worker.start({ + quiet: true, // Instruct MSW to not log requests in the console + onUnhandledRequest: 'bypass', // Instruct MSW to ignore requests we don't handle +}).then(() => { ReactDom.render(, document.getElementById("root")); }); ``` -Another option is to use the `MswServer` class. This is useful if you must conditionally include data: +Another option is to use the `MswServer` class. This is useful if you must conditionally include data or add middlewares: ```js -// in ./src/msw.js +// in ./src/fakeServer.js import { setupWorker } from "msw/browser"; import { MswServer } from "fakerest"; @@ -83,19 +87,24 @@ const data = { } }; -const restServer = new MswServer(); -restServer.init(data); +const restServer = new MswServer({ + baseUrl: 'http://localhost:3000', + data, +}); -export const worker = setupWorker(...restServer.getHandlers()); +export const worker = setupWorker(restServer.getHandler()); ``` +FakeRest will now intercept every `fetch` requests to the REST server. + ### Sinon -```html - - - +const sinonServer = sinon.fakeServer.create(); +// this is required when doing asynchronous XmlHttpRequest +sinonServer.autoRespond = true; + +sinonServer.respondWith( + getSinonHandler({ + baseUrl: 'http://localhost:3000', + data, + }) +); ``` -Another option is to use the `SinonServer` class. This is useful if you must conditionally include data or interceptors: +Another option is to use the `SinonServer` class. This is useful if you must conditionally include data or add middlewares: + +```js +// in ./src/fakeServer.js +import sinon from 'sinon'; +import { SinonServer } from "fakerest"; -```html - - - +const sinonServer = sinon.fakeServer.create(); +// this is required when doing asynchronous XmlHttpRequest +sinonServer.autoRespond = true; + +sinonServer.respondWith( + restServer.getHandler({ + baseUrl: 'http://localhost:3000', + data, + }) +); ``` +FakeRest will now intercept every `XmlHttpRequest` requests to the REST server. + ### fetch-mock +First, install fakerest and [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/): + +```sh +npm install fakerest fetch-mock --save-dev +``` + +You can then initialize the `FetchMockServer`: + ```js +// in ./src/fakeServer.js import fetchMock from 'fetch-mock'; -import FakeRest from 'fakerest'; +import { getFetchMockHandler } from "fakerest"; const data = { 'authors': [ @@ -175,15 +212,15 @@ const data = { fetchMock.mock( 'begin:http://localhost:3000', - FakeRest.getFetchMockHandler({ baseUrl: 'http://localhost:3000', data }) + getFetchMockHandler({ baseUrl: 'http://localhost:3000', data }) ); ``` -Another option is to use the `FetchMockServer` class. This is useful if you must conditionally include data or interceptors: +Another option is to use the `FetchMockServer` class. This is useful if you must conditionally include data or add middlewares: ```js import fetchMock from 'fetch-mock'; -import FakeRest from 'fakerest'; +import { FetchMockServer } from 'fakerest'; const data = { 'authors': [ @@ -201,84 +238,40 @@ const data = { preferred_format: 'hardback', } }; -const restServer = new FakeRest.FetchMockServer({ baseUrl: 'http://localhost:3000' }); -restServer.init(data); +const restServer = new FetchMockServer({ + baseUrl: 'http://localhost:3000', + data +}); fetchMock.mock('begin:http://localhost:3000', restServer.getHandler()); ``` -FakeRest will now intercept every `XmlHttpRequest` to the REST server. The handled routes for collections of items are: +FakeRest will now intercept every `fetch` requests to the REST server. -``` -GET /:resource -POST /:resource -GET /:resource/:id -PUT /:resource/:id -PATCH /:resource/:id -DELETE /:resource/:id -``` +## Concepts -The handled routes for single items are: +### Server -``` -GET /:resource -PUT /:resource -PATCH /:resource -``` +A fake server implementation. FakeRest provide the following: +- `MswServer`: Based on [MSW](https://mswjs.io/) +- `FetchMockServer`: Based on [`fetch-mock`](https://www.wheresrhys.co.uk/fetch-mock/) +- `SinonServer`: Based on [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/) -Let's see an example: +### Database -```js -// Query the fake REST server -var req = new XMLHttpRequest(); -req.open("GET", "/authors", false); -req.send(null); -console.log(req.responseText); -// [ -// {"id":0,"first_name":"Leo","last_name":"Tolstoi"}, -// {"id":1,"first_name":"Jane","last_name":"Austen"} -// ] - -var req = new XMLHttpRequest(); -req.open("GET", "/books/3", false); -req.send(null); -console.log(req.responseText); -// {"id":3,"author_id":1,"title":"Sense and Sensibility"} - -var req = new XMLHttpRequest(); -req.open("GET", "/settings", false); -req.send(null); -console.log(req.responseText); -// {"language:"english","preferred_format":"hardback"} - -var req = new XMLHttpRequest(); -req.open("POST", "/books", false); -req.send(JSON.stringify({ author_id: 1, title: 'Emma' })); -console.log(req.responseText); -// {"author_id":1,"title":"Emma","id":4} - -// restore native XHR constructor -server.restore(); -``` +FakeRest internal database, that contains [collections](#collections) and [single](#single). -*Tip*: The `fakerServer` provided by Sinon.js is [available as a standalone library](http://sinonjs.org/docs/#server), without the entire stubbing framework. Simply add the following bower dependency: +### Collections -``` -devDependencies: { - "sinon-server": "http://sinonjs.org/releases/sinon-server-1.14.1.js" -} -``` +The equivalent to a classic database table or document collection. It supports filtering. -## Installation +### Single -FakeRest is available through npm and Bower: +Represent an API endpoint that returns a single entity. Useful for things such as user profile routes (`/me`) or global settings (`/settings`). -```sh -# If you use Bower -bower install fakerest --save-dev -# If you use npm -npm install fakerest --save-dev -``` +### Embeds + +FakeRest support embedding other resources in a main resource query result. For instance, embedding the author of a book. ## REST Flavor @@ -452,87 +445,187 @@ Operators are specified as suffixes on each filtered field. For instance, applyi GET /books?filter={"price_gte":100} // return books that have a price greater or equal to 100 -## Usage and Configuration +## Middlewares + +All fake servers supports middlewares that allows you to intercept requests and simulate server features such as: + - authentication checks + - server side validation + - server dynamically generated values + - simulate response delays + +A middleware is a function that receive 3 parameters: + - The `request` object, specific to the chosen mocking solution (e.g. a [`Request`](https://developer.mozilla.org/fr/docs/Web/API/Request) for MSW and `fetch-mock`, a fake [`XMLHttpRequest`](https://developer.mozilla.org/fr/docs/Web/API/XMLHttpRequest) for [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/)) + - The FakeRest `context`, an object containing the data extracted from the request that FakeRest uses to build the response. It has the following properties: + - `url`: The request URL as a string + - `method`: The request method as a string (`GET`, `POST`, `PATCH` or `PUT`) + - `collection`: The name of the targeted [collection](#collection) (e.g. `posts`) + - `single`: The name of the targeted [single](#single) (e.g. `settings`) + - `requestJson`: The parsed request data if any + - `params`: The request parameters from the URL search (e.g. the identifier of the requested record) + - A `next` function to call the next middleware in the chain, to which you must pass the `request` and the `context` + +A middleware must return a FakeRest response either by returning the result of the `next` function or by returning its own response. A FakeRest response is an object with the following properties: + - `status`: The response status as a number (e.g. `200`) + - `headers`: The response HTTP headers as an object where keys are header names + - `body`: The response body which will be stringified + +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: + +```js +restServer.addMiddleware(async (request, context, next) => { + if (request.requestHeaders.Authorization === undefined) { + return { + status: 401, + headers: {}, + }; + } + + return next(request, context); +} +``` + +### Server Side Validation + +Here's to implement server side validation: ```js -// initialize a rest server with a custom base URL -const restServer = new FakeRest.SinonServer({ baseUrl: 'http://my.custom.domain' }); // only URLs starting with my.custom.domain will be intercepted -restServer.toggleLogging(); // logging is off by default, enable it to see network calls in the console -// Set all JSON data at once - only if identifier name is 'id' -restServer.init(json); -// modify the request before FakeRest handles it, using a request interceptor -// request is { -// url: '...', -// headers: [...], -// requestBody: '...', -// json: ..., // parsed JSON body -// queryString: '...', -// params: {...} // parsed query string -// } -restServer.addRequestInterceptor(function(request) { - var start = (request.params._start - 1) || 0; - var end = request.params._end !== undefined ? (request.params._end - 1) : 19; - request.params.range = [start, end]; - return request; // always return the modified input +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); +} +``` + +### Server Dynamically Generated Values + +Here's to implement server dynamically generated values: + +```js +restServer.addMiddleware(async (request, context, next) => { + if ( + context.collection === 'books' && + context.method === 'POST' + ) { + const response = await next(request, context); + response.body.updatedAt = new Date().toISOString(); + return response; + } + + return next(request, context); +} +``` + +### Simulate Response Delays + +Here's to simulate response delays: + +```js +restServer.addMiddleware(async (request, context, next) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(next(request, context)); + }, delayMs); + }); }); -// modify the response before FakeRest sends it, using a response interceptor -// response is { -// status: ..., -// headers: [...], -// body: {...} -// } -restServer.addResponseInterceptor(function(response) { - response.body = { data: response.body, status: response.status }; - return response; // always return the modified input +``` + +This is so common FakeRest provides the `withDelay` function for that: + +```js +import { withDelay } from 'fakerest'; + +restServer.addMiddleware(withDelay(300)); +``` + +## Configuration + +### Configure Identifiers + +By default, FakeRest assume all records have a unique `id` field. +Some database such as [MongoDB](https://www.mongodb.com) use `_id` instead of `id` for collection identifiers. +You can customize FakeRest to do the same by using the `identifierName` option: + +```js +import { MswServer } from 'fakerest'; + +const restServer = new MswServer({ + baseUrl: 'http://my.custom.domain', + identifierName: '_id' }); -// set default query, e.g. to force embeds or filters -restServer.setDefaultQuery(function(resourceName) { - if (resourceName == 'authors') return { embed: ['books'] } - if (resourceName == 'books') return { filter: { published: true } } - return {}; -}) -// enable batch request handler, i.e. allow API clients to query several resources into a single request -// see [Facebook's Batch Requests philosophy](https://developers.facebook.com/docs/graph-api/making-multiple-requests) for more details. -restServer.setBatchUrl('/batch'); - -// you can create more than one fake server to listen to several domains -const restServer2 = new FakeRest.SinonServer({ baseUrl: 'http://my.other.domain' }); -// Set data collection by collection - allows to customize the identifier name -const authorsCollection = new FakeRest.Collection({ items: [], identifierName: '_id' }); -authorsCollection.addOne({ first_name: 'Leo', last_name: 'Tolstoi' }); // { _id: 0, first_name: 'Leo', last_name: 'Tolstoi' } -authorsCollection.addOne({ first_name: 'Jane', last_name: 'Austen' }); // { _id: 1, first_name: 'Jane', last_name: 'Austen' } -// collections have auto incremented identifiers by default but accept identifiers already set -authorsCollection.addOne({ _id: 3, first_name: 'Marcel', last_name: 'Proust' }); // { _id: 3, first_name: 'Marcel', last_name: 'Proust' } -restServer2.addCollection('authors', authorsCollection); -// collections are mutable -authorsCollection.updateOne(1, { last_name: 'Doe' }); // { _id: 1, first_name: 'Jane', last_name: 'Doe' } -authorsCollection.removeOne(3); // { _id: 3, first_name: 'Marcel', last_name: 'Proust' } - -const server = sinon.fakeServer.create(); -server.autoRespond = true; -server.respondWith(restServer.getHandler()); -server.respondWith(restServer2.getHandler()); ``` -## Configure Identifiers Generation +This can also be specified at the collection level: + +```js +import { MswServer, Collection } from 'fakerest'; + +const restServer = new MswServer({ baseUrl: 'http://my.custom.domain' }); +const authorsCollection = new Collection({ items: [], identifierName: '_id' }); +restServer.addCollection('authors', authorsCollection); +``` + +### Configure Identifiers Generation -By default, FakeRest uses an auto incremented sequence for the items identifiers. If you'd rather use another type of identifiers (e.g. UUIDs), you can provide your own `getNewId` function at the server level: +By default, FakeRest uses an auto incremented sequence for the items identifiers. +If you'd rather use UUIDs for instance but would like to avoid providing them when you insert new items, you can provide your own function: ```js -import FakeRest from 'fakerest'; +import { MswServer } from 'fakerest'; import uuid from 'uuid'; -const restServer = new FakeRest.SinonServer({ baseUrl: 'http://my.custom.domain', getNewId: () => uuid.v5() }); +const restServer = new MswServer({ + baseUrl: 'http://my.custom.domain', + getNewId: () => uuid.v5() +}); ``` This can also be specified at the collection level: ```js -import FakeRest from 'fakerest'; +import { MswServer, Collection } from 'fakerest'; +import uuid from 'uuid'; + +const restServer = new MswServer({ baseUrl: 'http://my.custom.domain' }); +const authorsCollection = new Collection({ items: [], getNewId: () => uuid.v5() }); +restServer.addCollection('authors', authorsCollection); +``` + +### Configure Default Queries + +Some APIs might enforce some parameters on queries. For instance, an API might always include an [embed](#embed) or enforce a query filter. +You can simulate this using the `defaultQuery` parameter: + +```js +import { MswServer } from 'fakerest'; import uuid from 'uuid'; -const restServer = new FakeRest.SinonServer({ baseUrl: 'http://my.custom.domain' }); -const authorsCollection = new FakeRest.Collection({ items: [], identifierName: '_id', getNewId: () => uuid.v5() }); +const restServer = new MswServer({ + baseUrl: 'http://my.custom.domain', + getNewId: () => uuid.v5(), + defaultQuery: (collection) => { + if (resourceName == 'authors') return { embed: ['books'] } + if (resourceName == 'books') return { filter: { published: true } } + return {}; + } +}); ``` ## Development @@ -540,20 +633,24 @@ const authorsCollection = new FakeRest.Collection({ items: [], identifierName: ' ```sh # Install dependencies make install + # Run the demo with MSW make run-msw # Run the demo with fetch-mock make run-fetch-mock -# Watch source files and recompile dist/FakeRest.js when anything is modified -make watch + +# Run the demo with sinon +make run-sinon + # Run tests make test + # Build minified version make build ``` -To test the Sinon integration, build the library then run the demo to start Vite and visit http://localhost:5173/sinon.html +You can sign-in to the demo with `janedoe` and `password` ## License diff --git a/UPGRADE.md b/UPGRADE.md index 923dc5b..b6026e9 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); ``` @@ -39,4 +61,32 @@ server.init(data); + { id: 3, title: 'boz' }, + ], +}); +``` + +## Request and Response Interceptors Have Been Replaced By Middlewares + +Fakerest used to have request and response interceptors. We replaced those with middlewares. They allow much more use cases. + +Migrate your request interceptors: + +```diff +-restServer.addRequestInterceptor(function(request) { ++restServer.addMiddleware(async function(request, context, next) { + var start = (request.params._start - 1) || 0; + var end = request.params._end !== undefined ? (request.params._end - 1) : 19; + request.params.range = [start, end]; +- return request; // always return the modified input ++ return next(request, context); +}); +``` + +Migrate your response interceptors: + +```diff +-restServer.addResponseInterceptor(function(response) { ++restServer.addMiddleware(async function(request, context, next) { ++ const response = await next(request, context); + response.body = { data: response.body, status: response.status }; + return response; +}); ``` \ No newline at end of file diff --git a/example/App.tsx b/example/App.tsx index 961b4ea..0a91491 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -2,16 +2,31 @@ import React from 'react'; import { Admin, Create, + type DataProvider, EditGuesser, ListGuesser, Resource, ShowGuesser, + required, + AutocompleteInput, } from 'react-admin'; -import { dataProvider } from './dataProvider'; +import { QueryClient } from 'react-query'; -export const App = () => { +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, +}); + +export const App = ({ dataProvider }: { dataProvider: DataProvider }) => { return ( - + { list={ListGuesser} edit={EditGuesser} show={ShowGuesser} + recordRepresentation={(record) => + `${record.first_name} ${record.last_name}` + } /> ); }; import { Edit, ReferenceInput, SimpleForm, TextInput } from 'react-admin'; +import authProvider from './authProvider'; export const BookCreate = () => ( - - + + + + ); diff --git a/example/authProvider.ts b/example/authProvider.ts new file mode 100644 index 0000000..1986233 --- /dev/null +++ b/example/authProvider.ts @@ -0,0 +1,51 @@ +import { type AuthProvider, HttpError } from 'react-admin'; +import data from './users.json'; + +/** + * This authProvider is only for test purposes. Don't use it in production. + */ +export const authProvider: AuthProvider = { + login: ({ username, password }) => { + const user = data.users.find( + (u) => u.username === username && u.password === password, + ); + + if (user) { + const { password, ...userToPersist } = user; + localStorage.setItem('user', JSON.stringify(userToPersist)); + return Promise.resolve(); + } + + return Promise.reject( + new HttpError('Unauthorized', 401, { + message: 'Invalid username or password', + }), + ); + }, + logout: () => { + localStorage.removeItem('user'); + return Promise.resolve(); + }, + checkError: (error) => { + const status = error.status; + if (status === 401 || status === 403) { + localStorage.removeItem('auth'); + return Promise.reject(); + } + // other error code (404, 500, etc): no need to log out + return Promise.resolve(); + }, + checkAuth: () => + localStorage.getItem('user') ? Promise.resolve() : Promise.reject(), + getPermissions: () => { + return Promise.resolve(undefined); + }, + getIdentity: () => { + const persistedUser = localStorage.getItem('user'); + const user = persistedUser ? JSON.parse(persistedUser) : null; + + return Promise.resolve(user); + }, +}; + +export default authProvider; diff --git a/example/dataProvider.ts b/example/dataProvider.ts index d7106a2..db61424 100644 --- a/example/dataProvider.ts +++ b/example/dataProvider.ts @@ -1,3 +1,19 @@ import simpleRestProvider from 'ra-data-simple-rest'; +import { fetchUtils } from 'react-admin'; -export const dataProvider = simpleRestProvider('http://localhost:3000'); +const httpClient = (url: string, options: any = {}) => { + if (!options.headers) { + options.headers = new Headers({ Accept: 'application/json' }); + } + const persistedUser = localStorage.getItem('user'); + const user = persistedUser ? JSON.parse(persistedUser) : null; + if (user) { + options.headers.set('Authorization', `Bearer ${user.id}`); + } + return fetchUtils.fetchJson(url, options); +}; + +export const dataProvider = simpleRestProvider( + 'http://localhost:3000', + httpClient, +); diff --git a/example/fetchMock.ts b/example/fetchMock.ts index aaffc9e..e7d8fd1 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -1,16 +1,55 @@ import fetchMock from 'fetch-mock'; -import FakeRest from 'fakerest'; +import { FetchMockServer, withDelay } from '../src'; import { data } from './data'; +import { dataProvider as defaultDataProvider } from './dataProvider'; export const initializeFetchMock = () => { - const restServer = new FakeRest.FetchServer({ + const restServer = new FetchMockServer({ baseUrl: 'http://localhost:3000', + data, + loggingEnabled: true, }); if (window) { // @ts-ignore window.restServer = restServer; // give way to update data in the console } - restServer.init(data); - restServer.toggleLogging(); // logging is off by default, enable it + + restServer.addMiddleware(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()); }; + +export const dataProvider = defaultDataProvider; diff --git a/example/index.tsx b/example/index.tsx index c11ee63..65ba410 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -5,19 +5,39 @@ import { App } from './App'; switch (import.meta.env.VITE_MOCK) { case 'fetch-mock': import('./fetchMock') - .then(({ initializeFetchMock }) => { + .then(({ initializeFetchMock, dataProvider }) => { initializeFetchMock(); + return dataProvider; }) - .then(() => { - ReactDom.render(, document.getElementById('root')); + .then((dataProvider) => { + ReactDom.render( + , + document.getElementById('root'), + ); + }); + break; + case 'sinon': + import('./sinon') + .then(({ initializeSinon, dataProvider }) => { + initializeSinon(); + return dataProvider; + }) + .then((dataProvider) => { + ReactDom.render( + , + document.getElementById('root'), + ); }); break; default: import('./msw') - .then(({ worker }) => { - return worker.start(); + .then(({ initializeMsw, dataProvider }) => { + return initializeMsw().then(() => dataProvider); }) - .then(() => { - ReactDom.render(, document.getElementById('root')); + .then((dataProvider) => { + ReactDom.render( + , + document.getElementById('root'), + ); }); } diff --git a/example/msw.ts b/example/msw.ts index 34ae693..f52a448 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -1,10 +1,56 @@ import { setupWorker } from 'msw/browser'; -import { getMswHandlers } from '../src/FakeRest'; +import { MswServer, withDelay } from '../src'; import { data } from './data'; +import { dataProvider as defaultDataProvider } from './dataProvider'; -export const worker = setupWorker( - ...getMswHandlers({ - baseUrl: 'http://localhost:3000', - data, - }), -); +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 worker = setupWorker(restServer.getHandler()); + return worker.start({ + quiet: true, // Instruct MSW to not log requests in the console + onUnhandledRequest: 'bypass', // Instruct MSW to ignore requests we don't handle + }); +}; + +export const dataProvider = defaultDataProvider; diff --git a/example/sinon.ts b/example/sinon.ts new file mode 100644 index 0000000..abf95a8 --- /dev/null +++ b/example/sinon.ts @@ -0,0 +1,124 @@ +import sinon from 'sinon'; +import { SinonServer, withDelay } from '../src'; +import { data } from './data'; +import { HttpError, type Options } from 'react-admin'; +import simpleRestProvider from 'ra-data-simple-rest'; + +export const initializeSinon = () => { + const restServer = new SinonServer({ + 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); + }); + + // use sinon.js to monkey-patch XmlHttpRequest + const server = sinon.fakeServer.create(); + // this is required when doing asynchronous XmlHttpRequest + server.autoRespond = true; + if (window) { + // @ts-ignore + window.restServer = restServer; // give way to update data in the console + // @ts-ignore + window.sinonServer = server; // give way to update data in the console + } + server.respondWith(restServer.getHandler()); +}; + +// An HttpClient based on XMLHttpRequest to use with Sinon +const httpClient = (url: string, options: Options = {}): Promise => { + const request = new XMLHttpRequest(); + request.open(options.method ?? 'GET', url); + + const persistedUser = localStorage.getItem('user'); + const user = persistedUser ? JSON.parse(persistedUser) : null; + if (user) { + request.setRequestHeader('Authorization', `Bearer ${user.id}`); + } + + // add content-type header + request.overrideMimeType('application/json'); + request.send(typeof options.body === 'string' ? options.body : undefined); + + return new Promise((resolve, reject) => { + request.onloadend = (e) => { + let json: any; + try { + json = JSON.parse(request.responseText); + } catch (e) { + // not json, no big deal + } + // Get the raw header string + const headersAsString = request.getAllResponseHeaders(); + + // Convert the header string into an array + // of individual headers + const arr = headersAsString.trim().split(/[\r\n]+/); + + // Create a map of header names to values + const headers = new Headers(); + for (const line of arr) { + const parts = line.split(': '); + const header = parts.shift(); + if (!header) continue; + const value = parts.join(': '); + headers.set(header, value); + } + if (request.status < 200 || request.status >= 300) { + return reject( + new HttpError( + json?.message || request.statusText, + request.status, + json, + ), + ); + } + resolve({ + status: request.status, + headers, + body: request.responseText, + json, + }); + }; + }); +}; + +export const dataProvider = simpleRestProvider( + 'http://localhost:3000', + httpClient, +); diff --git a/example/users.json b/example/users.json new file mode 100644 index 0000000..fc14c48 --- /dev/null +++ b/example/users.json @@ -0,0 +1,18 @@ +{ + "users": [ + { + "id": 1, + "username": "janedoe", + "password": "password", + "fullName": "Jane Doe", + "avatar": "data:image/jpeg;base64,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" + }, + { + "id": 2, + "username": "johndoe", + "password": "password", + "fullName": "John Doe", + "avatar": "data:image/jpeg;base64,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" + } + ] +} diff --git a/example/vite-env.d.ts b/example/vite-env.d.ts index 30fb06a..fd01e68 100644 --- a/example/vite-env.d.ts +++ b/example/vite-env.d.ts @@ -1,7 +1,7 @@ /// interface ImportMetaEnv { - readonly VITE_MOCK: 'msw' | 'fetch-mock'; + readonly VITE_MOCK: 'msw' | 'fetch-mock' | 'sinon'; } interface ImportMeta { diff --git a/package-lock.json b/package-lock.json index a836a8e..a1d07c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "react": "^17.0.2", "react-admin": "^4.16.15", "react-dom": "^17.0.2", - "sinon": "~17.0.1", + "sinon": "~18.0.0", "typescript": "^5.4.5", "vite": "^5.2.9", "vite-plugin-dts": "^3.8.3", @@ -5724,9 +5724,9 @@ } }, "node_modules/nise": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", - "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.0.0.tgz", + "integrity": "sha512-K8ePqo9BFvN31HXwEtTNGzgrPpmvgciDsFz8aztFjt4LqKO/JeFD8tBOeuDiCMXrIl/m1YvfH8auSpxfaD09wg==", "dev": true, "dependencies": { "@sinonjs/commons": "^3.0.0", @@ -6799,17 +6799,17 @@ } }, "node_modules/sinon": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", - "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.0.tgz", + "integrity": "sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==", "dev": true, "dependencies": { - "@sinonjs/commons": "^3.0.0", + "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^11.2.2", "@sinonjs/samsam": "^8.0.0", - "diff": "^5.1.0", - "nise": "^5.1.5", - "supports-color": "^7.2.0" + "diff": "^5.2.0", + "nise": "^6.0.0", + "supports-color": "^7" }, "funding": { "type": "opencollective", @@ -12080,9 +12080,9 @@ "dev": true }, "nise": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", - "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.0.0.tgz", + "integrity": "sha512-K8ePqo9BFvN31HXwEtTNGzgrPpmvgciDsFz8aztFjt4LqKO/JeFD8tBOeuDiCMXrIl/m1YvfH8auSpxfaD09wg==", "dev": true, "requires": { "@sinonjs/commons": "^3.0.0", @@ -12878,17 +12878,17 @@ "dev": true }, "sinon": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", - "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.0.tgz", + "integrity": "sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==", "dev": true, "requires": { - "@sinonjs/commons": "^3.0.0", + "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^11.2.2", "@sinonjs/samsam": "^8.0.0", - "diff": "^5.1.0", - "nise": "^5.1.5", - "supports-color": "^7.2.0" + "diff": "^5.2.0", + "nise": "^6.0.0", + "supports-color": "^7" }, "dependencies": { "has-flag": { diff --git a/package.json b/package.json index dc115b8..d6cc8f3 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "react": "^17.0.2", "react-admin": "^4.16.15", "react-dom": "^17.0.2", - "sinon": "~17.0.1", + "sinon": "~18.0.0", "typescript": "^5.4.5", "vite": "^5.2.9", "vite-plugin-dts": "^3.8.3", diff --git a/public/sinon.html b/public/sinon.html deleted file mode 100644 index 3d813b7..0000000 --- a/public/sinon.html +++ /dev/null @@ -1,81 +0,0 @@ - - - - Test FakeRest server - - -

FakeRest example

-

See source for example FakeRest usage.

-

Test data

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

GET /authors

- -
-
-

GET /books/3

- -
-
-

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

- -
- - - - - \ No newline at end of file diff --git a/src/BaseServer.ts b/src/BaseServer.ts index 5e46844..c6ef837 100644 --- a/src/BaseServer.ts +++ b/src/BaseServer.ts @@ -1,63 +1,30 @@ -import { Collection } from './Collection.js'; -import { Single } from './Single.js'; -import type { CollectionItem, Query, QueryFunction } from './types.js'; +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'; -export class BaseServer { +export class BaseServer { baseUrl = ''; - identifierName = 'id'; - loggingEnabled = false; defaultQuery: QueryFunction = () => ({}); - batchUrl: string | null = null; - collections: Record> = {}; - singles: Record> = {}; - getNewId?: () => number | string; + middlewares: Array> = []; + database: Database; constructor({ baseUrl = '', - batchUrl = null, - data, defaultQuery = () => ({}), - identifierName = 'id', - getNewId, - loggingEnabled = false, + database, + ...options }: BaseServerOptions = {}) { this.baseUrl = baseUrl; - this.batchUrl = batchUrl; - this.getNewId = getNewId; - this.loggingEnabled = loggingEnabled; - this.identifierName = identifierName; this.defaultQuery = defaultQuery; - if (data) { - this.init(data); - } - } - - /** - * Shortcut for adding several collections if identifierName is always the same - */ - init(data: Record) { - for (const name in data) { - const value = data[name]; - if (Array.isArray(value)) { - this.addCollection( - name, - new Collection({ - items: value, - identifierName: this.identifierName, - getNewId: this.getNewId, - }), - ); - } else { - this.addSingle(name, new Single(value)); - } + if (database) { + this.database = database; + } else { + this.database = new Database(options); } } - toggleLogging() { - this.loggingEnabled = !this.loggingEnabled; - } - /** * @param Function ResourceName => object */ @@ -65,117 +32,96 @@ export class BaseServer { this.defaultQuery = query; } - setBatchUrl(batchUrl: string) { - this.batchUrl = batchUrl; - } + getContext(context: NormalizedRequest): FakeRestContext { + for (const name of this.database.getSingleNames()) { + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), + ); + if (!matches) continue; + return { + ...context, + single: name, + }; + } - /** - * @deprecated use setBatchUrl instead - */ - setBatch(url: string) { - console.warn( - 'Server.setBatch() is deprecated, use Server.setBatchUrl() instead', + const matches = context.url?.match( + new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), ); - this.batchUrl = url; - } + if (matches) { + const name = matches[1]; + const params = Object.assign( + {}, + this.defaultQuery(name), + context.params, + ); - addCollection( - name: string, - collection: Collection, - ) { - this.collections[name] = collection; - collection.setServer(this); - collection.setName(name); - } + return { + ...context, + collection: name, + params, + }; + } - getCollection(name: string) { - return this.collections[name]; + return context; } - getCollectionNames() { - return Object.keys(this.collections); + getNormalizedRequest(request: RequestType): Promise { + throw new Error('Not implemented'); } - addSingle( - name: string, - single: Single, - ) { - this.singles[name] = single; - single.setServer(this); - single.setName(name); + respond( + response: BaseResponse | null, + request: RequestType, + context: FakeRestContext, + ): Promise { + throw new Error('Not implemented'); } - getSingle(name: string) { - return this.singles[name]; - } + async handle(request: RequestType): Promise { + const context = this.getContext( + await this.getNormalizedRequest(request), + ); - getSingleNames() { - return Object.keys(this.singles); - } + // Call middlewares + let index = 0; + const middlewares = [...this.middlewares]; - /** - * @param {string} name - * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } - */ - getCount(name: string, params?: Query) { - return this.collections[name].getCount(params); - } + const next = (req: RequestType, ctx: FakeRestContext) => { + const middleware = middlewares[index++]; + if (middleware) { + return middleware(req, ctx, next); + } - /** - * @param {string} name - * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } - */ - getAll(name: string, params?: Query) { - return this.collections[name].getAll(params); - } + return this.handleRequest(req, ctx); + }; - getOne(name: string, identifier: string | number, params?: Query) { - return this.collections[name].getOne(identifier, params); - } + try { + const response = await next(request, context); + if (response != null) { + return this.respond(response, request, context); + } + } catch (error) { + if (error instanceof Error) { + throw error; + } - addOne(name: string, item: CollectionItem) { - if (!Object.prototype.hasOwnProperty.call(this.collections, name)) { - this.addCollection( - name, - new Collection({ - items: [], - identifierName: 'id', - getNewId: this.getNewId, - }), - ); + return error as ResponseType; } - return this.collections[name].addOne(item); - } - - updateOne(name: string, identifier: string | number, item: CollectionItem) { - return this.collections[name].updateOne(identifier, item); - } - - removeOne(name: string, identifier: string | number) { - return this.collections[name].removeOne(identifier); - } - - getOnly(name: string, params?: Query) { - return this.singles[name].getOnly(); } - updateOnly(name: string, item: CollectionItem) { - return this.singles[name].updateOnly(item); - } - - handleRequest(request: BaseRequest, opts?: RequestInit): BaseResponse { + handleRequest(request: RequestType, ctx: FakeRestContext): BaseResponse { // Handle Single Objects - for (const name of this.getSingleNames()) { - const matches = request.url?.match( + for (const name of this.database.getSingleNames()) { + const matches = ctx.url?.match( new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), ); if (!matches) continue; - if (request.method === 'GET') { + if (ctx.method === 'GET') { try { return { status: 200, - body: this.getOnly(name), + body: this.database.getOnly(name), headers: { 'Content-Type': 'application/json', }, @@ -187,9 +133,9 @@ export class BaseServer { }; } } - if (request.method === 'PUT') { + if (ctx.method === 'PUT') { try { - if (request.requestJson == null) { + if (ctx.requestBody == null) { return { status: 400, headers: {}, @@ -197,7 +143,7 @@ export class BaseServer { } return { status: 200, - body: this.updateOnly(name, request.requestJson), + body: this.database.updateOnly(name, ctx.requestBody), headers: { 'Content-Type': 'application/json', }, @@ -209,9 +155,9 @@ export class BaseServer { }; } } - if (request.method === 'PATCH') { + if (ctx.method === 'PATCH') { try { - if (request.requestJson == null) { + if (ctx.requestBody == null) { return { status: 400, headers: {}, @@ -219,7 +165,7 @@ export class BaseServer { } return { status: 200, - body: this.updateOnly(name, request.requestJson), + body: this.database.updateOnly(name, ctx.requestBody), headers: { 'Content-Type': 'application/json', }, @@ -234,29 +180,25 @@ export class BaseServer { } // handle collections - const matches = request.url?.match( + const matches = ctx.url?.match( new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), ); if (!matches) { return { status: 404, headers: {} }; } const name = matches[1]; - const params = Object.assign( - {}, - this.defaultQuery(name), - request.params, - ); + const params = Object.assign({}, this.defaultQuery(name), ctx.params); if (!matches[2]) { - if (request.method === 'GET') { - if (!this.getCollection(name)) { + if (ctx.method === 'GET') { + if (!this.database.getCollection(name)) { return { status: 404, headers: {} }; } - const count = this.getCount( + const count = this.database.getCount( name, params.filter ? { filter: params.filter } : {}, ); if (count > 0) { - const items = this.getAll(name, params); + const items = this.database.getAll(name, params); const first = params.range ? params.range[0] : 0; const last = params.range && params.range.length === 2 @@ -285,17 +227,19 @@ export class BaseServer { }, }; } - if (request.method === 'POST') { - if (request.requestJson == null) { + if (ctx.method === 'POST') { + if (ctx.requestBody == null) { return { status: 400, headers: {}, }; } - const newResource = this.addOne(name, request.requestJson); + const newResource = this.database.addOne(name, ctx.requestBody); const newResourceURI = `${this.baseUrl}/${name}/${ - newResource[this.getCollection(name).identifierName] + newResource[ + this.database.getCollection(name).identifierName + ] }`; return { @@ -308,15 +252,15 @@ export class BaseServer { }; } } else { - if (!this.getCollection(name)) { + if (!this.database.getCollection(name)) { return { status: 404, headers: {} }; } const id = Number.parseInt(matches[3]); - if (request.method === 'GET') { + if (ctx.method === 'GET') { try { return { status: 200, - body: this.getOne(name, id, params), + body: this.database.getOne(name, id, params), headers: { 'Content-Type': 'application/json', }, @@ -328,9 +272,9 @@ export class BaseServer { }; } } - if (request.method === 'PUT') { + if (ctx.method === 'PUT') { try { - if (request.requestJson == null) { + if (ctx.requestBody == null) { return { status: 400, headers: {}, @@ -338,7 +282,11 @@ export class BaseServer { } return { status: 200, - body: this.updateOne(name, id, request.requestJson), + body: this.database.updateOne( + name, + id, + ctx.requestBody, + ), headers: { 'Content-Type': 'application/json', }, @@ -350,9 +298,9 @@ export class BaseServer { }; } } - if (request.method === 'PATCH') { + if (ctx.method === 'PATCH') { try { - if (request.requestJson == null) { + if (ctx.requestBody == null) { return { status: 400, headers: {}, @@ -360,7 +308,11 @@ export class BaseServer { } return { status: 200, - body: this.updateOne(name, id, request.requestJson), + body: this.database.updateOne( + name, + id, + ctx.requestBody, + ), headers: { 'Content-Type': 'application/json', }, @@ -372,11 +324,11 @@ export class BaseServer { }; } } - if (request.method === 'DELETE') { + if (ctx.method === 'DELETE') { try { return { status: 200, - body: this.removeOne(name, id), + body: this.database.removeOne(name, id), headers: { 'Content-Type': 'application/json', }, @@ -394,27 +346,74 @@ export class BaseServer { headers: {}, }; } + + addMiddleware(middleware: Middleware) { + this.middlewares.push(middleware); + } + + addCollection( + name: string, + collection: Collection, + ) { + this.database.addCollection(name, collection); + } + + getCollection(name: string) { + return this.database.getCollection(name); + } + + getCollectionNames() { + return this.database.getCollectionNames(); + } + + addSingle( + name: string, + single: Single, + ) { + this.database.addSingle(name, single); + } + + getSingle(name: string) { + return this.database.getSingle(name); + } + + getSingleNames() { + return this.database.getSingleNames(); + } } -export type BaseServerOptions = { +export type Middleware = ( + request: RequestType, + context: FakeRestContext, + next: ( + req: RequestType, + ctx: FakeRestContext, + ) => Promise | BaseResponse | null, +) => Promise | BaseResponse | null; + +export type BaseServerOptions = DatabaseOptions & { + database?: Database; baseUrl?: string; batchUrl?: string | null; - data?: Record; defaultQuery?: QueryFunction; - identifierName?: string; - getNewId?: () => number | string; - loggingEnabled?: boolean; -}; - -type BaseRequest = { - url?: string; - method?: string; - requestJson?: Record | undefined; - params?: { [key: string]: any }; }; -type BaseResponse = { +export type BaseResponse = { status: number; body?: Record | Record[]; headers: { [key: string]: string }; }; + +export type FakeRestContext = { + url?: string; + method?: string; + collection?: string; + single?: string; + requestBody: Record | undefined; + params: { [key: string]: any }; +}; + +export type NormalizedRequest = Pick< + FakeRestContext, + 'url' | 'method' | 'params' | 'requestBody' +>; diff --git a/src/Collection.spec.ts b/src/Collection.spec.ts index 4222504..4887680 100644 --- a/src/Collection.spec.ts +++ b/src/Collection.spec.ts @@ -1,5 +1,5 @@ import { Collection } from './Collection.js'; -import { Server } from './SinonServer.js'; +import { Database } from './Database.js'; import type { CollectionItem } from './types.js'; describe('Collection', () => { @@ -627,8 +627,8 @@ describe('Collection', () => { const foos = new Collection({ items: [{ name: 'John', bar_id: 123 }], }); - const server = new Server(); - server.addCollection('foos', foos); + const database = new Database(); + database.addCollection('foos', foos); expect(() => { foos.getAll({ embed: ['bar'] }); }).toThrow( @@ -641,9 +641,9 @@ describe('Collection', () => { items: [{ name: 'John', bar_id: 123 }], }); const bars = new Collection({ items: [] }); - const server = new Server(); - server.addCollection('foos', foos); - server.addCollection('bars', bars); + const database = new Database(); + database.addCollection('foos', foos); + database.addCollection('bars', bars); const expected = [{ id: 0, name: 'John', bar_id: 123 }]; expect(foos.getAll({ embed: ['bar'] })).toEqual(expected); }); @@ -662,9 +662,9 @@ describe('Collection', () => { { id: 456, bar: 'bazz' }, ], }); - const server = new Server(); - server.addCollection('foos', foos); - server.addCollection('bars', bars); + const database = new Database(); + database.addCollection('foos', foos); + database.addCollection('bars', bars); const expected = [ { id: 0, @@ -686,8 +686,8 @@ describe('Collection', () => { const foos = new Collection({ items: [{ name: 'John', bar_id: 123 }], }); - const server = new Server(); - server.addCollection('foos', foos); + const database = new Database(); + database.addCollection('foos', foos); expect(() => { foos.getAll({ embed: ['bars'] }); }).toThrow( @@ -702,9 +702,9 @@ describe('Collection', () => { const bars = new Collection({ items: [{ id: 1, bar: 'nobody wants me' }], }); - const server = new Server(); - server.addCollection('foos', foos); - server.addCollection('bars', bars); + const database = new Database(); + database.addCollection('foos', foos); + database.addCollection('bars', bars); const expected = [{ id: 1, bar: 'nobody wants me', foos: [] }]; expect(bars.getAll({ embed: ['foos'] })).toEqual(expected); }); @@ -724,9 +724,9 @@ describe('Collection', () => { { id: 456, bar: 'bazz' }, ], }); - const server = new Server(); - server.addCollection('foos', foos); - server.addCollection('bars', bars); + const database = new Database(); + database.addCollection('foos', foos); + database.addCollection('bars', bars); const expected = [ { id: 1, bar: 'nobody wants me', foos: [] }, { @@ -761,9 +761,9 @@ describe('Collection', () => { { id: 456, bar: 'bazz', foos: [2, 3] }, ], }); - const server = new Server(); - server.addCollection('foos', foos); - server.addCollection('bars', bars); + const database = new Database(); + database.addCollection('foos', foos); + database.addCollection('bars', bars); const expected = [ { id: 1, bar: 'nobody wants me', foos: [] }, { id: 123, bar: 'baz', foos: [{ id: 1, name: 'John' }] }, @@ -809,10 +809,10 @@ describe('Collection', () => { { id: 2, name: 'Russia' }, ], }); - const server = new Server(); - server.addCollection('books', books); - server.addCollection('authors', authors); - server.addCollection('countrys', countries); // nevermind the plural + const database = new Database(); + database.addCollection('books', books); + database.addCollection('authors', authors); + database.addCollection('countrys', countries); // nevermind the plural const expected = [ { id: 1, diff --git a/src/Collection.ts b/src/Collection.ts index 9be8fac..f03112c 100644 --- a/src/Collection.ts +++ b/src/Collection.ts @@ -1,6 +1,6 @@ import get from 'lodash/get.js'; import matches from 'lodash/matches.js'; -import type { BaseServer } from './BaseServer.js'; +import type { Database } from './Database.js'; import type { CollectionItem, Embed, @@ -14,7 +14,7 @@ import type { export class Collection { sequence = 0; items: T[] = []; - server: BaseServer | null = null; + database: Database | null = null; name: string | null = null; identifierName = 'id'; getNewId: () => number | string; @@ -40,10 +40,10 @@ export class Collection { /** * A Collection may need to access other collections (e.g. for embedding references) - * This is done through a reference to the parent server. + * This is done through a reference to the parent database. */ - setServer(server: BaseServer) { - this.server = server; + setDatabase(database: Database) { + this.database = database; } setName(name: string) { @@ -66,10 +66,10 @@ export class Collection { const singularResourceName = this.name.slice(0, -1); const referenceName = `${singularResourceName}_id`; return (item: T) => { - if (this.server == null) { - throw new Error("Can't embed references without a server"); + if (this.database == null) { + throw new Error("Can't embed references without a database"); } - const otherCollection = this.server.collections[resourceName]; + const otherCollection = this.database.collections[resourceName]; if (!otherCollection) throw new Error( `Can't embed a non-existing collection ${resourceName}`, @@ -108,10 +108,11 @@ export class Collection { const pluralResourceName = `${resourceName}s`; const referenceName = `${resourceName}_id`; return (item: T) => { - if (this.server == null) { - throw new Error("Can't embed references without a server"); + if (this.database == null) { + throw new Error("Can't embed references without a database"); } - const otherCollection = this.server.collections[pluralResourceName]; + const otherCollection = + this.database.collections[pluralResourceName]; if (!otherCollection) throw new Error( `Can't embed a non-existing collection ${resourceName}`, @@ -163,7 +164,7 @@ export class Collection { items = rangeItems(items, query.range); } items = items.map((item) => Object.assign({}, item)); // clone item to avoid returning the original - if (query.embed && this.server) { + if (query.embed && this.database) { items = items.map(this._itemEmbedder(query.embed)); // embed reference } } @@ -184,7 +185,7 @@ export class Collection { } let item = this.items[index]; item = Object.assign({}, item); // clone item to avoid returning the original - if (query?.embed && this.server) { + if (query?.embed && this.database) { item = this._itemEmbedder(query.embed)(item); // embed reference } return item; diff --git a/src/Database.spec.ts b/src/Database.spec.ts new file mode 100644 index 0000000..da112cd --- /dev/null +++ b/src/Database.spec.ts @@ -0,0 +1,150 @@ +import { Database } from './Database.js'; +import { Single } from './Single.js'; +import { Collection } from './Collection.js'; + +describe('Database', () => { + describe('init', () => { + it('should populate several collections', () => { + const server = new Database(); + server.init({ + foo: [{ a: 1 }, { a: 2 }, { a: 3 }], + bar: [{ b: true }, { b: false }], + baz: { name: 'baz' }, + }); + expect(server.getAll('foo')).toEqual([ + { id: 0, a: 1 }, + { id: 1, a: 2 }, + { id: 2, a: 3 }, + ]); + expect(server.getAll('bar')).toEqual([ + { id: 0, b: true }, + { id: 1, b: false }, + ]); + expect(server.getOnly('baz')).toEqual({ name: 'baz' }); + }); + }); + + describe('addCollection', () => { + it('should add a collection and index it by name', () => { + const server = new Database(); + const collection = new Collection({ + items: [ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' }, + ], + }); + server.addCollection('foo', collection); + const newcollection = server.getCollection('foo'); + expect(newcollection).toEqual(collection); + }); + }); + + describe('addSingle', () => { + it('should add a single object and index it by name', () => { + const server = new Database(); + const single = new Single({ name: 'foo', description: 'bar' }); + server.addSingle('foo', single); + expect(server.getSingle('foo')).toEqual(single); + }); + }); + + describe('getAll', () => { + it('should return all items for a given name', () => { + const server = new Database(); + server.addCollection( + 'foo', + new Collection({ + items: [ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' }, + ], + }), + ); + server.addCollection( + 'baz', + new Collection({ items: [{ id: 1, name: 'baz' }] }), + ); + expect(server.getAll('foo')).toEqual([ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' }, + ]); + expect(server.getAll('baz')).toEqual([{ id: 1, name: 'baz' }]); + }); + + it('should support a query', () => { + const server = new Database(); + server.addCollection( + 'foo', + new Collection({ + items: [ + { id: 0, name: 'c', arg: false }, + { id: 1, name: 'b', arg: true }, + { id: 2, name: 'a', arg: true }, + ], + }), + ); + const params = { + filter: { arg: true }, + sort: 'name', + slice: [0, 10], + }; + const expected = [ + { id: 2, name: 'a', arg: true }, + { id: 1, name: 'b', arg: true }, + ]; + expect(server.getAll('foo', params)).toEqual(expected); + }); + }); + + describe('getOne', () => { + it('should return an error when no collection match the identifier', () => { + const server = new Database(); + server.addCollection( + 'foo', + new Collection({ items: [{ id: 1, name: 'foo' }] }), + ); + expect(() => { + server.getOne('foo', 2); + }).toThrow(new Error('No item with identifier 2')); + }); + + it('should return the first collection matching the identifier', () => { + const server = new Database(); + server.addCollection( + 'foo', + new Collection({ + items: [ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' }, + ], + }), + ); + expect(server.getOne('foo', 1)).toEqual({ id: 1, name: 'foo' }); + expect(server.getOne('foo', 2)).toEqual({ id: 2, name: 'bar' }); + }); + + it('should use the identifierName', () => { + const server = new Database(); + server.addCollection( + 'foo', + new Collection({ + items: [ + { _id: 1, name: 'foo' }, + { _id: 2, name: 'bar' }, + ], + identifierName: '_id', + }), + ); + expect(server.getOne('foo', 1)).toEqual({ _id: 1, name: 'foo' }); + expect(server.getOne('foo', 2)).toEqual({ _id: 2, name: 'bar' }); + }); + }); + + describe('getOnly', () => { + it('should return the single matching the identifier', () => { + const server = new Database(); + server.addSingle('foo', new Single({ name: 'foo' })); + expect(server.getOnly('foo')).toEqual({ name: 'foo' }); + }); + }); +}); diff --git a/src/Database.ts b/src/Database.ts new file mode 100644 index 0000000..1f8f66a --- /dev/null +++ b/src/Database.ts @@ -0,0 +1,138 @@ +import { Collection } from './Collection.js'; +import { Single } from './Single.js'; +import type { CollectionItem, Query, QueryFunction } from './types.js'; + +export class Database { + identifierName = 'id'; + collections: Record> = {}; + singles: Record> = {}; + getNewId?: () => number | string; + + constructor({ + data, + identifierName = 'id', + getNewId, + }: DatabaseOptions = {}) { + this.getNewId = getNewId; + this.identifierName = identifierName; + + if (data) { + this.init(data); + } + } + + /** + * Shortcut for adding several collections if identifierName is always the same + */ + init(data: Record) { + for (const name in data) { + const value = data[name]; + if (Array.isArray(value)) { + this.addCollection( + name, + new Collection({ + items: value, + identifierName: this.identifierName, + getNewId: this.getNewId, + }), + ); + } else { + this.addSingle(name, new Single(value)); + } + } + } + + addCollection( + name: string, + collection: Collection, + ) { + this.collections[name] = collection; + collection.setDatabase(this); + collection.setName(name); + } + + getCollection(name: string) { + return this.collections[name]; + } + + getCollectionNames() { + return Object.keys(this.collections); + } + + addSingle( + name: string, + single: Single, + ) { + this.singles[name] = single; + single.setDatabase(this); + single.setName(name); + } + + getSingle(name: string) { + return this.singles[name]; + } + + getSingleNames() { + return Object.keys(this.singles); + } + + /** + * @param {string} name + * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } + */ + getCount(name: string, params?: Query) { + return this.collections[name].getCount(params); + } + + /** + * @param {string} name + * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] } + */ + getAll(name: string, params?: Query) { + return this.collections[name].getAll(params); + } + + getOne(name: string, identifier: string | number, params?: Query) { + return this.collections[name].getOne(identifier, params); + } + + addOne(name: string, item: CollectionItem) { + if (!Object.prototype.hasOwnProperty.call(this.collections, name)) { + this.addCollection( + name, + new Collection({ + items: [], + identifierName: this.identifierName, + getNewId: this.getNewId, + }), + ); + } + return this.collections[name].addOne(item); + } + + updateOne(name: string, identifier: string | number, item: CollectionItem) { + return this.collections[name].updateOne(identifier, item); + } + + removeOne(name: string, identifier: string | number) { + return this.collections[name].removeOne(identifier); + } + + getOnly(name: string, params?: Query) { + return this.singles[name].getOnly(); + } + + updateOnly(name: string, item: CollectionItem) { + return this.singles[name].updateOnly(item); + } +} + +export type DatabaseOptions = { + baseUrl?: string; + batchUrl?: string | null; + data?: Record; + defaultQuery?: QueryFunction; + identifierName?: string; + getNewId?: () => number | string; + loggingEnabled?: boolean; +}; diff --git a/src/FetchMockServer.ts b/src/FetchMockServer.ts deleted file mode 100644 index 1c87a65..0000000 --- a/src/FetchMockServer.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { BaseServer, type BaseServerOptions } from './BaseServer.js'; -import { parseQueryString } from './parseQueryString.js'; -import type { MockResponse, MockResponseObject } from 'fetch-mock'; - -export class FetchMockServer extends BaseServer { - requestInterceptors: FetchMockRequestInterceptor[] = []; - responseInterceptors: FetchMockResponseInterceptor[] = []; - - decode(request: Request, opts?: RequestInit) { - const req: FetchMockFakeRestRequest = - typeof request === 'string' ? new Request(request, opts) : request; - req.queryString = req.url - ? decodeURIComponent(req.url.slice(req.url.indexOf('?') + 1)) - : ''; - req.params = parseQueryString(req.queryString); - return (req as Request) - .text() - .then((text) => { - req.requestBody = text; - try { - req.requestJson = JSON.parse(text); - } catch (e) { - // not JSON, no big deal - } - }) - .then(() => - this.requestInterceptors.reduce( - (previous, current) => current(previous), - req, - ), - ); - } - - respond(response: MockResponseObject, request: FetchMockFakeRestRequest) { - const resp = this.responseInterceptors.reduce( - (previous, current) => current(previous, request), - response, - ); - this.log(request, resp); - - return resp; - } - - log(request: FetchMockFakeRestRequest, response: MockResponseObject) { - if (!this.loggingEnabled) return; - if (console.group) { - // Better logging in Chrome - console.groupCollapsed(request.method, request.url, '(FakeRest)'); - console.group('request'); - console.log(request.method, request.url); - console.log('headers', request.headers); - console.log('body ', request.requestBody); - console.groupEnd(); - console.group('response', response.status); - console.log('headers', response.headers); - console.log('body ', response.body); - console.groupEnd(); - console.groupEnd(); - } else { - console.log( - 'FakeRest request ', - request.method, - request.url, - 'headers', - request.headers, - 'body', - request.requestBody, - ); - console.log( - 'FakeRest response', - response.status, - 'headers', - response.headers, - 'body', - response.body, - ); - } - } - - batch(request: any) { - throw new Error('not implemented'); - } - - /** - * @param {Request} fetch request - * @param {Object} options - * - */ - handle(req: Request, opts?: RequestInit) { - return this.decode(req, opts).then((request) => { - // handle batch request - if ( - this.batchUrl && - this.batchUrl === request.url && - request.method === 'POST' - ) { - return this.batch(request); - } - - const response = this.handleRequest({ - url: request.url, - method: request.method, - requestJson: request.requestJson, - params: request.params, - }); - - return this.respond(response, request); - }); - } - - getHandler() { - return this.handle.bind(this); - } -} - -export const getFetchMockHandler = (options: BaseServerOptions) => { - const server = new FetchMockServer(options); - return server.getHandler(); -}; - -/** - * @deprecated Use FetchServer instead - */ -export const FetchServer = FetchMockServer; - -export type FetchMockFakeRestRequest = Partial & { - requestBody?: string; - responseText?: string; - requestJson?: Record; - queryString?: string; - params?: { [key: string]: any }; -}; - -export type FetchMockRequestInterceptor = ( - request: FetchMockFakeRestRequest, -) => FetchMockFakeRestRequest; - -export type FetchMockResponseInterceptor = ( - response: MockResponseObject, - request: FetchMockFakeRestRequest, -) => MockResponseObject; diff --git a/src/Single.spec.ts b/src/Single.spec.ts index cdcb224..625bcca 100644 --- a/src/Single.spec.ts +++ b/src/Single.spec.ts @@ -1,6 +1,6 @@ import { Single } from './Single.js'; import { Collection } from './Collection.js'; -import { Server } from './SinonServer.js'; +import { Database } from './Database.js'; describe('Single', () => { describe('constructor', () => { @@ -19,8 +19,8 @@ describe('Single', () => { describe('embed query', () => { it('should throw an error when trying to embed a non-existing collection', () => { const foo = new Single({ name: 'foo', bar_id: 123 }); - const server = new Server(); - server.addSingle('foo', foo); + const database = new Database(); + database.addSingle('foo', foo); expect(() => { foo.getOnly({ embed: ['bar'] }); }).toThrow( @@ -31,9 +31,9 @@ describe('Single', () => { it('should return the original object for missing embed one', () => { const foo = new Single({ name: 'foo', bar_id: 123 }); const bars = new Collection({ items: [] }); - const server = new Server(); - server.addSingle('foo', foo); - server.addCollection('bars', bars); + const database = new Database(); + database.addSingle('foo', foo); + database.addCollection('bars', bars); const expected = { name: 'foo', bar_id: 123 }; expect(foo.getOnly({ embed: ['bar'] })).toEqual(expected); }); @@ -47,9 +47,9 @@ describe('Single', () => { { id: 456, bar: 'bazz' }, ], }); - const server = new Server(); - server.addSingle('foo', foo); - server.addCollection('bars', bars); + const database = new Database(); + database.addSingle('foo', foo); + database.addCollection('bars', bars); const expected = { name: 'foo', bar_id: 123, @@ -60,8 +60,8 @@ describe('Single', () => { it('should throw an error when trying to embed many a non-existing collection', () => { const foo = new Single({ name: 'foo', bar_id: 123 }); - const server = new Server(); - server.addSingle('foo', foo); + const database = new Database(); + database.addSingle('foo', foo); expect(() => { foo.getOnly({ embed: ['bars'] }); }).toThrow( @@ -78,9 +78,9 @@ describe('Single', () => { { id: 3, bar: 'boz' }, ], }); - const server = new Server(); - server.addSingle('foo', foo); - server.addCollection('bars', bars); + const database = new Database(); + database.addSingle('foo', foo); + database.addCollection('bars', bars); const expected = { name: 'foo', bars: [ @@ -111,10 +111,10 @@ describe('Single', () => { { id: 6, name: 'baz3' }, ], }); - const server = new Server(); - server.addSingle('foo', foo); - server.addCollection('bars', bars); - server.addCollection('bazs', bazs); + const database = new Database(); + database.addSingle('foo', foo); + database.addCollection('bars', bars); + database.addCollection('bazs', bazs); const expected = { name: 'foo', bars: [ diff --git a/src/Single.ts b/src/Single.ts index 861e9e8..cc66a6f 100644 --- a/src/Single.ts +++ b/src/Single.ts @@ -1,9 +1,9 @@ -import type { BaseServer } from './BaseServer.js'; +import type { Database } from './Database.js'; import type { CollectionItem, Embed, Query } from './types.js'; export class Single { obj: T | null = null; - server: BaseServer | null = null; + database: Database | null = null; name: string | null = null; constructor(obj: T) { @@ -17,10 +17,10 @@ export class Single { /** * A Single may need to access other collections (e.g. for embedded - * references) This is done through a reference to the parent server. + * references) This is done through a reference to the parent database. */ - setServer(server: BaseServer) { - this.server = server; + setDatabase(database: Database) { + this.database = database; } setName(name: string) { @@ -32,10 +32,10 @@ export class Single { // it is by definition a singleton _oneToManyEmbedder(resourceName: string) { return (item: T) => { - if (this.server == null) { - throw new Error("Can't embed references without a server"); + if (this.database == null) { + throw new Error("Can't embed references without a database"); } - const otherCollection = this.server.collections[resourceName]; + const otherCollection = this.database.collections[resourceName]; if (!otherCollection) throw new Error( `Can't embed a non-existing collection ${resourceName}`, @@ -57,10 +57,11 @@ export class Single { const pluralResourceName = `${resourceName}s`; const referenceName = `${resourceName}_id`; return (item: T) => { - if (this.server == null) { - throw new Error("Can't embed references without a server"); + if (this.database == null) { + throw new Error("Can't embed references without a database"); } - const otherCollection = this.server.collections[pluralResourceName]; + const otherCollection = + this.database.collections[pluralResourceName]; if (!otherCollection) throw new Error( `Can't embed a non-existing collection ${resourceName}`, @@ -93,7 +94,7 @@ export class Single { getOnly(query?: Query) { let item = this.obj; - if (query?.embed && this.server) { + if (query?.embed && this.database) { item = Object.assign({}, item); // Clone item = this._itemEmbedder(query.embed)(item); } diff --git a/src/SinonServer.ts b/src/SinonServer.ts deleted file mode 100644 index 64ec399..0000000 --- a/src/SinonServer.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { SinonFakeXMLHttpRequest } from 'sinon'; -import { BaseServer, type BaseServerOptions } from './BaseServer.js'; -import { parseQueryString } from './parseQueryString.js'; - -export class SinonServer extends BaseServer { - requestInterceptors: SinonRequestInterceptor[] = []; - responseInterceptors: SinonResponseInterceptor[] = []; - - addRequestInterceptor(interceptor: SinonRequestInterceptor) { - this.requestInterceptors.push(interceptor); - } - - addResponseInterceptor(interceptor: SinonResponseInterceptor) { - this.responseInterceptors.push(interceptor); - } - - decode( - request: string | Request | SinonFakeRestRequest, - opts?: RequestInit, - ): SinonFakeRestRequest | Promise { - const req: SinonFakeRestRequest = - typeof request === 'string' ? new Request(request, opts) : request; - req.queryString = req.url - ? decodeURIComponent(req.url.slice(req.url.indexOf('?') + 1)) - : ''; - req.params = parseQueryString(req.queryString); - if (req.requestBody) { - try { - req.requestJson = JSON.parse(req.requestBody); - } catch (error) { - // body isn't JSON, skipping - } - } - return this.requestInterceptors.reduce( - (previous, current) => current(previous), - req, - ); - } - - respond( - body: any, - headers: Record | null, - request: SinonFakeRestRequest, - status = 200, - ) { - let resp: SinonFakeRestResponse = { - status, - headers: headers || {}, - body, - }; - if (resp.headers == null) { - resp.headers = {}; - } - if (Array.isArray(resp.headers)) { - if ( - !(resp.headers as Array<{ name: string; value: string }>).find( - (header) => header.name.toLowerCase() === 'content-type', - ) - ) { - resp.headers.push({ - name: 'Content-Type', - value: 'application/json', - }); - } - } else if (!(resp.headers as Record)['Content-Type']) { - resp.headers['Content-Type'] = 'application/json'; - } - - resp = this.responseInterceptors.reduce( - (previous, current) => current(previous, request), - resp, - ); - this.log(request, resp); - - if (request.respond == null) { - throw new Error('request.respond is null'); - } - return request.respond( - resp.status as number, - resp.headers, - JSON.stringify(resp.body), - ); - } - - log(request: SinonFakeRestRequest, response: SinonFakeRestResponse) { - if (!this.loggingEnabled) return; - if (console.group) { - // Better logging in Chrome - console.groupCollapsed(request.method, request.url, '(FakeRest)'); - console.group('request'); - console.log(request.method, request.url); - console.log('headers', request.requestHeaders); - console.log('body ', request.requestBody); - console.groupEnd(); - console.group('response', response.status); - console.log('headers', response.headers); - console.log('body ', response.body); - console.groupEnd(); - console.groupEnd(); - } else { - console.log( - 'FakeRest request ', - request.method, - request.url, - 'headers', - request.requestHeaders, - 'body', - request.requestBody, - ); - console.log( - 'FakeRest response', - response.status, - 'headers', - response.headers, - 'body', - response.body, - ); - } - } - - batch(request: SinonFakeRestRequest) { - const json = request.requestJson; - const handle = this.handle.bind(this); - - if (json == null) { - throw new Error('json is null'); - } - - const jsonResponse = Object.keys(json).reduce( - (jsonResponse, requestName) => { - let subResponse: Record | undefined = - undefined; - const sub: SinonFakeRestRequest = { - url: json[requestName], - method: 'GET', - params: {}, - respond: (code, headers, body) => { - subResponse = { - code: code, - headers: Object.keys(headers || {}).map( - (headerName) => ({ - name: headerName, - value: headers[headerName], - }), - ), - body: body || {}, - }; - }, - }; - handle(sub); - - jsonResponse[requestName] = subResponse || { - code: 404, - headers: [], - body: {}, - }; - - return jsonResponse; - }, - {} as Record>, - ); - - return this.respond(jsonResponse, null, request); - } - - /** - * @param {FakeXMLHttpRequest} request - * - * String request.url The URL set on the request object. - * String request.method The request method as a string. - * Object request.requestHeaders An object of all request headers, i.e.: - * { - * "Accept": "text/html", - * "Connection": "keep-alive" - * } - * String request.requestBody The request body - * String request.username Username, if any. - * String request.password Password, if any. - */ - handle(request: Request | SinonFakeRestRequest | string) { - const req = this.decode(request) as SinonFakeRestRequest; - - if ( - this.batchUrl && - this.batchUrl === req.url && - req.method === 'POST' - ) { - return this.batch(req); - } - - const response = this.handleRequest({ - url: req.url, - method: req.method, - requestJson: req.requestJson, - params: req.params, - }); - - return this.respond( - response.body, - response.headers, - req, - response.status, - ); - } - - getHandler() { - return this.handle.bind(this); - } -} - -export const getSinonHandler = (options: BaseServerOptions) => { - const server = new SinonServer(options); - return server.getHandler(); -}; - -/** - * @deprecated Use SinonServer instead - */ -export const Server = SinonServer; - -export type SinonFakeRestRequest = Partial & { - requestBody?: string; - responseText?: string; - requestJson?: Record; - queryString?: string; - params?: { [key: string]: any }; -}; - -export type SinonFakeRestResponse = { - status: number; - body: any; - headers: Record; -}; - -export type SinonRequestInterceptor = ( - request: SinonFakeRestRequest, -) => SinonFakeRestRequest; - -export type SinonResponseInterceptor = ( - response: SinonFakeRestResponse, - request: SinonFakeRestRequest, -) => SinonFakeRestResponse; diff --git a/src/adapters/FetchMockServer.ts b/src/adapters/FetchMockServer.ts new file mode 100644 index 0000000..0eb3520 --- /dev/null +++ b/src/adapters/FetchMockServer.ts @@ -0,0 +1,126 @@ +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 { + 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 + ? decodeURIComponent(req.url.slice(req.url.indexOf('?') + 1)) + : ''; + const params = parseQueryString(queryString); + const text = await req.text(); + let requestBody: Record | undefined = undefined; + try { + requestBody = JSON.parse(text); + } catch (e) { + // not JSON, no big deal + } + + return { + url: req.url, + 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, + ) { + if (!this.loggingEnabled) return; + if (console.group) { + // Better logging in Chrome + console.groupCollapsed(context.method, context.url, '(FakeRest)'); + console.group('request'); + console.log(context.method, context.url); + console.log('headers', request.headers); + console.log('body ', request.requestJson); + console.groupEnd(); + console.group('response', response.status); + console.log('headers', response.headers); + console.log('body ', response.body); + console.groupEnd(); + console.groupEnd(); + } else { + console.log( + 'FakeRest request ', + context.method, + context.url, + 'headers', + request.headers, + 'body', + request.requestJson, + ); + console.log( + 'FakeRest response', + response.status, + 'headers', + response.headers, + 'body', + response.body, + ); + } + } + + getHandler() { + const handler = (url: string, options: RequestInit) => { + return this.handle(new Request(url, options)); + }; + + return handler; + } +} + +export const getFetchMockHandler = (options: FetchMockServerOptions) => { + const server = new FetchMockServer(options); + return server.getHandler(); +}; + +/** + * @deprecated Use FetchServer instead + */ +export const FetchServer = FetchMockServer; + +export type FetchMockFakeRestRequest = Partial & { + requestBody?: string; + responseText?: string; + requestJson?: Record; + 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..6b263de --- /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 requestBody: Record | undefined = undefined; + try { + const text = await request.text(); + requestBody = JSON.parse(text); + } catch (e) { + // not JSON, no big deal + } + + return { + url: request.url, + 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(); +}; diff --git a/src/SinonServer.spec.ts b/src/adapters/SinonServer.spec.ts similarity index 54% rename from src/SinonServer.spec.ts rename to src/adapters/SinonServer.spec.ts index ba7bb77..ed9012e 100644 --- a/src/SinonServer.spec.ts +++ b/src/adapters/SinonServer.spec.ts @@ -1,8 +1,9 @@ import sinon, { type SinonFakeXMLHttpRequest } from 'sinon'; -import { type SinonFakeRestRequest, Server } from './SinonServer.js'; -import { Single } from './Single.js'; -import { Collection } from './Collection.js'; +import { SinonServer } from './SinonServer.js'; +import { Single } from '../Single.js'; +import { Collection } from '../Collection.js'; +import type { BaseResponse } from '../BaseServer.js'; function getFakeXMLHTTPRequest( method: string, @@ -21,168 +22,23 @@ function getFakeXMLHTTPRequest( return request; } -describe('Server', () => { - describe('init', () => { - it('should populate several collections', () => { - const server = new Server(); - server.init({ - foo: [{ a: 1 }, { a: 2 }, { a: 3 }], - bar: [{ b: true }, { b: false }], - baz: { name: 'baz' }, - }); - expect(server.getAll('foo')).toEqual([ - { id: 0, a: 1 }, - { id: 1, a: 2 }, - { id: 2, a: 3 }, - ]); - expect(server.getAll('bar')).toEqual([ - { id: 0, b: true }, - { id: 1, b: false }, - ]); - expect(server.getOnly('baz')).toEqual({ name: 'baz' }); - }); - }); - - describe('addCollection', () => { - it('should add a collection and index it by name', () => { - const server = new Server(); - const collection = new Collection({ - items: [ - { id: 1, name: 'foo' }, - { id: 2, name: 'bar' }, - ], - }); - server.addCollection('foo', collection); - const newcollection = server.getCollection('foo'); - expect(newcollection).toEqual(collection); - }); - }); - - describe('addSingle', () => { - it('should add a single object and index it by name', () => { - const server = new Server(); - const single = new Single({ name: 'foo', description: 'bar' }); - server.addSingle('foo', single); - expect(server.getSingle('foo')).toEqual(single); - }); - }); - - describe('getAll', () => { - it('should return all items for a given name', () => { - const server = new Server(); - server.addCollection( - 'foo', - new Collection({ - items: [ - { id: 1, name: 'foo' }, - { id: 2, name: 'bar' }, - ], - }), - ); - server.addCollection( - 'baz', - new Collection({ items: [{ id: 1, name: 'baz' }] }), - ); - expect(server.getAll('foo')).toEqual([ - { id: 1, name: 'foo' }, - { id: 2, name: 'bar' }, - ]); - expect(server.getAll('baz')).toEqual([{ id: 1, name: 'baz' }]); - }); - - it('should support a query', () => { - const server = new Server(); - server.addCollection( - 'foo', - new Collection({ - items: [ - { id: 0, name: 'c', arg: false }, - { id: 1, name: 'b', arg: true }, - { id: 2, name: 'a', arg: true }, - ], - }), - ); - const params = { - filter: { arg: true }, - sort: 'name', - slice: [0, 10], - }; - const expected = [ - { id: 2, name: 'a', arg: true }, - { id: 1, name: 'b', arg: true }, - ]; - expect(server.getAll('foo', params)).toEqual(expected); - }); - }); - - describe('getOne', () => { - it('should return an error when no collection match the identifier', () => { - const server = new Server(); - server.addCollection( - 'foo', - new Collection({ items: [{ id: 1, name: 'foo' }] }), - ); - expect(() => { - server.getOne('foo', 2); - }).toThrow(new Error('No item with identifier 2')); - }); - - it('should return the first collection matching the identifier', () => { - const server = new Server(); - server.addCollection( - 'foo', - new Collection({ - items: [ - { id: 1, name: 'foo' }, - { id: 2, name: 'bar' }, - ], - }), - ); - expect(server.getOne('foo', 1)).toEqual({ id: 1, name: 'foo' }); - expect(server.getOne('foo', 2)).toEqual({ id: 2, name: 'bar' }); - }); - - it('should use the identifierName', () => { - const server = new Server(); - server.addCollection( - 'foo', - new Collection({ - items: [ - { _id: 1, name: 'foo' }, - { _id: 2, name: 'bar' }, - ], - identifierName: '_id', - }), - ); - expect(server.getOne('foo', 1)).toEqual({ _id: 1, name: 'foo' }); - expect(server.getOne('foo', 2)).toEqual({ _id: 2, name: 'bar' }); - }); - }); - - describe('getOnly', () => { - it('should return the single matching the identifier', () => { - const server = new Server(); - server.addSingle('foo', new Single({ name: 'foo' })); - expect(server.getOnly('foo')).toEqual({ name: 'foo' }); - }); - }); - - describe('addRequestInterceptor', () => { - it('should allow request transformation', () => { - const server = new Server(); - server.addRequestInterceptor((request) => { - const start = request.params?._start - ? request.params._start - 1 +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 = - request.params?._end !== undefined - ? request.params._end - 1 + context.params?._end !== undefined + ? context.params._end - 1 : 19; - if (!request.params) { - request.params = {}; + if (!context.params) { + context.params = {}; } - request.params.range = [start, end]; - return request; + context.params.range = [start, end]; + return next(request, context); }); server.addCollection( 'foo', @@ -196,41 +52,39 @@ describe('Server', () => { let request: SinonFakeXMLHttpRequest | null; request = getFakeXMLHTTPRequest('GET', '/foo?_start=1&_end=1'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request?.status).toEqual(206); - expect((request as SinonFakeRestRequest)?.responseText).toEqual( - '[{"id":1,"name":"foo"}]', - ); + // @ts-ignore + expect(request.responseText).toEqual('[{"id":1,"name":"foo"}]'); expect(request?.getResponseHeader('Content-Range')).toEqual( 'items 0-0/2', ); request = getFakeXMLHTTPRequest('GET', '/foo?_start=2&_end=2'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request?.status).toEqual(206); - expect((request as SinonFakeRestRequest)?.responseText).toEqual( - '[{"id":2,"name":"bar"}]', - ); + // @ts-ignore + expect(request?.responseText).toEqual('[{"id":2,"name":"bar"}]'); expect(request?.getResponseHeader('Content-Range')).toEqual( 'items 1-1/2', ); }); - }); - describe('addResponseInterceptor', () => { - it('should allow response transformation', () => { - const server = new Server(); - server.addResponseInterceptor((response) => { + 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.addResponseInterceptor((response) => { - response.status = 418; - return response; - }); server.addCollection( 'foo', new Collection({ @@ -242,41 +96,42 @@ describe('Server', () => { ); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(418); - expect((request as SinonFakeRestRequest).responseText).toEqual( + // @ts-ignore + expect(request.responseText).toEqual( '{"data":[{"id":1,"name":"foo"},{"id":2,"name":"bar"}],"status":200}', ); }); - it('should pass request in response interceptor', () => { - const server = new Server(); + it('should pass request in response interceptor', async () => { + const server = new SinonServer(); let requestUrl: string | undefined; - server.addResponseInterceptor((response, request) => { + server.addMiddleware((request, context, next) => { requestUrl = request.url; - return response; + return next(request, context); }); server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(requestUrl).toEqual('/foo'); }); }); describe('handle', () => { - it('should respond a 404 to GET /whatever on non existing collection', () => { - const server = new Server(); + it('should respond a 404 to GET /whatever on non existing collection', async () => { + const server = new SinonServer(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(404); // not responded }); - it('should respond to GET /foo by sending all items in collection foo', () => { - const server = new Server(); + it('should respond to GET /foo by sending all items in collection foo', async () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -288,9 +143,10 @@ describe('Server', () => { ); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( + // @ts-ignore + expect(request.responseText).toEqual( '[{"id":1,"name":"foo"},{"id":2,"name":"bar"}]', ); expect(request.getResponseHeader('Content-Type')).toEqual( @@ -301,8 +157,8 @@ describe('Server', () => { ); }); - it('should respond to GET /foo?queryString by sending all items in collection foo satisfying query', () => { - const server = new Server(); + 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({ @@ -322,9 +178,10 @@ describe('Server', () => { '/foos?filter={"arg":true}&sort=name&slice=[0,10]&embed=["bars"]', ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( + // @ts-ignore + expect(request.responseText).toEqual( '[{"id":2,"name":"a","arg":true,"bars":[]},{"id":1,"name":"b","arg":true,"bars":[{"id":0,"name":"a","foo_id":1}]}]', ); expect(request.getResponseHeader('Content-Type')).toEqual( @@ -335,8 +192,8 @@ describe('Server', () => { ); }); - it('should respond to GET /foo?queryString with pagination by sending the correct content-range header', () => { - const server = new Server(); + 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({ @@ -346,51 +203,50 @@ describe('Server', () => { let request: SinonFakeXMLHttpRequest | null; request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-10/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-4/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[5,9]'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 5-9/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[10,14]'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 10-10/11', ); }); - it('should respond to GET /foo on an empty collection with a []', () => { - const server = new Server(); + it('should respond to GET /foo on an empty collection with a []', async () => { + const server = new SinonServer(); server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '[]', - ); + // @ts-ignore + expect(request.responseText).toEqual('[]'); expect(request.getResponseHeader('Content-Range')).toEqual( 'items */0', ); }); - it('should respond to POST /foo by adding an item to collection foo', () => { - const server = new Server(); + it('should respond to POST /foo by adding an item to collection foo', async () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -406,44 +262,44 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(201); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"name":"baz","id":3}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"name":"baz","id":3}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); expect(request.getResponseHeader('Location')).toEqual('/foo/3'); - expect(server.getAll('foo')).toEqual([ + expect(server.database.getAll('foo')).toEqual([ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, { id: 3, name: 'baz' }, ]); }); - it('should respond to POST /foo by adding an item to collection foo, even if the collection does not exist', () => { - const server = new Server(); + it('should respond to POST /foo by adding an item to collection foo, even if the collection does not exist', async () => { + const server = new SinonServer(); const request = getFakeXMLHTTPRequest( 'POST', '/foo', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(201); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"name":"baz","id":0}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"name":"baz","id":0}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); expect(request.getResponseHeader('Location')).toEqual('/foo/0'); - expect(server.getAll('foo')).toEqual([{ id: 0, name: 'baz' }]); + expect(server.database.getAll('foo')).toEqual([ + { id: 0, name: 'baz' }, + ]); }); - it('should respond to GET /foo/:id by sending element of identifier id in collection foo', () => { - const server = new Server(); + 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({ @@ -455,27 +311,26 @@ describe('Server', () => { ); const request = getFakeXMLHTTPRequest('GET', '/foo/2'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"id":2,"name":"bar"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"id":2,"name":"bar"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); }); - it('should respond to GET /foo/:id on a non-existing id with a 404', () => { - const server = new Server(); + it('should respond to GET /foo/:id on a non-existing id with a 404', async () => { + const server = new SinonServer(); server.addCollection('foo', new Collection()); const request = getFakeXMLHTTPRequest('GET', '/foo/3'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(404); }); - it('should respond to PUT /foo/:id by updating element of identifier id in collection foo', () => { - const server = new Server(); + 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({ @@ -491,22 +346,21 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"id":2,"name":"baz"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"id":2,"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.getAll('foo')).toEqual([ + expect(server.database.getAll('foo')).toEqual([ { id: 1, name: 'foo' }, { id: 2, name: 'baz' }, ]); }); - it('should respond to PUT /foo/:id on a non-existing id with a 404', () => { - const server = new Server(); + it('should respond to PUT /foo/:id on a non-existing id with a 404', async () => { + const server = new SinonServer(); server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest( 'PUT', @@ -514,12 +368,12 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(404); }); - it('should respond to PATCH /foo/:id by updating element of identifier id in collection foo', () => { - const server = new Server(); + 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({ @@ -535,22 +389,21 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"id":2,"name":"baz"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"id":2,"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.getAll('foo')).toEqual([ + expect(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', () => { - const server = new Server(); + it('should respond to PATCH /foo/:id on a non-existing id with a 404', async () => { + const server = new SinonServer(); server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest( 'PATCH', @@ -558,12 +411,12 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(404); }); - it('should respond to DELETE /foo/:id by removing element of identifier id in collection foo', () => { - const server = new Server(); + 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({ @@ -575,44 +428,44 @@ describe('Server', () => { ); const request = getFakeXMLHTTPRequest('DELETE', '/foo/2'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"id":2,"name":"bar"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"id":2,"name":"bar"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.getAll('foo')).toEqual([{ id: 1, name: 'foo' }]); + expect(server.database.getAll('foo')).toEqual([ + { id: 1, name: 'foo' }, + ]); }); - it('should respond to DELETE /foo/:id on a non-existing id with a 404', () => { - const server = new Server(); + it('should respond to DELETE /foo/:id on a non-existing id with a 404', async () => { + const server = new SinonServer(); server.addCollection('foo', new Collection({ items: [] })); const request = getFakeXMLHTTPRequest('DELETE', '/foo/3'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(404); }); - it('should respond to GET /foo/ with single item', () => { - const server = new Server(); + it('should respond to GET /foo/ with single item', async () => { + const server = new SinonServer(); server.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"name":"foo"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"name":"foo"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); }); - it('should respond to PUT /foo/ by updating the singleton record', () => { - const server = new Server(); + it('should respond to PUT /foo/ by updating the singleton record', async () => { + const server = new SinonServer(); server.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest( @@ -621,19 +474,18 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"name":"baz"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.getOnly('foo')).toEqual({ name: 'baz' }); + expect(server.database.getOnly('foo')).toEqual({ name: 'baz' }); }); - it('should respond to PATCH /foo/ by updating the singleton record', () => { - const server = new Server(); + it('should respond to PATCH /foo/ by updating the singleton record', async () => { + const server = new SinonServer(); server.addSingle('foo', new Single({ name: 'foo' })); const request = getFakeXMLHTTPRequest( @@ -642,21 +494,20 @@ describe('Server', () => { JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(200); - expect((request as SinonFakeRestRequest).responseText).toEqual( - '{"name":"baz"}', - ); + // @ts-ignore + expect(request.responseText).toEqual('{"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.getOnly('foo')).toEqual({ name: 'baz' }); + expect(server.database.getOnly('foo')).toEqual({ name: 'baz' }); }); }); describe('setDefaultQuery', () => { - it('should set the default query string', () => { - const server = new Server(); + it('should set the default query string', async () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -668,19 +519,18 @@ describe('Server', () => { }); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 2-4/10', ); const expected = [{ id: 2 }, { id: 3 }, { id: 4 }]; - expect((request as SinonFakeRestRequest).responseText).toEqual( - JSON.stringify(expected), - ); + // @ts-ignore + expect(request.responseText).toEqual(JSON.stringify(expected)); }); - it('should not override any provided query string', () => { - const server = new Server(); + it('should not override any provided query string', async () => { + const server = new SinonServer(); server.addCollection( 'foo', new Collection({ @@ -690,7 +540,7 @@ describe('Server', () => { server.setDefaultQuery((name) => ({ range: [2, 4] })); const request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]'); if (request == null) throw new Error('request is null'); - server.handle(request); + await server.handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-4/10', @@ -702,72 +552,8 @@ describe('Server', () => { { id: 3 }, { id: 4 }, ]; - expect((request as SinonFakeRestRequest).responseText).toEqual( - JSON.stringify(expected), - ); - }); - }); - - describe('batch', () => { - it('should return batch response', () => { - const server = new Server(); - server.init({ - foo: [{ a: 1 }, { a: 2 }, { a: 3 }], - bar: [{ b: true }, { b: false }], - biz: { name: 'biz' }, - }); - server.setBatchUrl('/batch'); - const request = getFakeXMLHTTPRequest( - 'POST', - '/batch', - JSON.stringify({ - foo0: '/foo/0', - allbar: '/bar', - baz0: '/baz/0', - biz: '/biz', - }), - ); - if (request == null) throw new Error('request is null'); - server.handle(request); - expect((request as SinonFakeRestRequest).responseText).toEqual( - JSON.stringify({ - foo0: { - code: 200, - headers: [ - { - name: 'Content-Type', - value: 'application/json', - }, - ], - body: '{"a":1,"id":0}', - }, - allbar: { - code: 200, - headers: [ - { name: 'Content-Type', value: 'application/json' }, - { name: 'Content-Range', value: 'items 0-1/2' }, - ], - body: '[{"b":true,"id":0},{"b":false,"id":1}]', - }, - baz0: { - code: 404, - headers: [ - { name: 'Content-Type', value: 'application/json' }, - ], - body: {}, - }, - biz: { - code: 200, - headers: [ - { - name: 'Content-Type', - value: 'application/json', - }, - ], - body: '{"name":"biz"}', - }, - }), - ); + // @ts-ignore + expect(request.responseText).toEqual(JSON.stringify(expected)); }); }); }); diff --git a/src/adapters/SinonServer.ts b/src/adapters/SinonServer.ts new file mode 100644 index 0000000..1e38b5b --- /dev/null +++ b/src/adapters/SinonServer.ts @@ -0,0 +1,171 @@ +import type { SinonFakeXMLHttpRequest } from 'sinon'; +import { + type BaseResponse, + BaseServer, + type BaseServerOptions, +} from '../BaseServer.js'; +import { parseQueryString } from '../parseQueryString.js'; + +export class SinonServer extends BaseServer< + SinonFakeXMLHttpRequest, + SinonFakeRestResponse +> { + 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; + + const queryString = req.url + ? decodeURIComponent(req.url.slice(req.url.indexOf('?') + 1)) + : ''; + const params = parseQueryString(queryString); + let requestBody: Record | undefined = undefined; + if ((req as SinonFakeXMLHttpRequest).requestBody) { + try { + requestBody = JSON.parse( + (req as SinonFakeXMLHttpRequest).requestBody, + ); + } catch (error) { + // body isn't JSON, skipping + } + } + + return { + url: req.url, + params, + requestBody, + method: req.method, + }; + } + + async respond(response: BaseResponse, request: SinonFakeXMLHttpRequest) { + const sinonResponse = { + status: response.status, + body: response.body ?? '', + headers: response.headers ?? {}, + }; + + if (Array.isArray(sinonResponse.headers)) { + if ( + !( + sinonResponse.headers as Array<{ + name: string; + value: string; + }> + ).find((header) => header.name.toLowerCase() === 'content-type') + ) { + sinonResponse.headers.push({ + name: 'Content-Type', + value: 'application/json', + }); + } + } else if ( + !(sinonResponse.headers as Record)['Content-Type'] + ) { + sinonResponse.headers['Content-Type'] = 'application/json'; + } + + // This is an internal property of SinonFakeXMLHttpRequest but we have to reset it to 1 + // to handle the request asynchronously. + // See https://github.com/sinonjs/sinon/issues/637 + // @ts-expect-error + request.readyState = 1; + + request.respond( + sinonResponse.status, + sinonResponse.headers, + JSON.stringify(sinonResponse.body), + ); + + this.log(request, sinonResponse); + + return { + status: response.status, + body: response.body, + headers: response.headers, + }; + } + + log(request: SinonFakeXMLHttpRequest, response: SinonFakeRestResponse) { + if (!this.loggingEnabled) return; + if (console.group) { + // Better logging in Chrome + console.groupCollapsed(request.method, request.url, '(FakeRest)'); + console.group('request'); + console.log(request.method, request.url); + console.log('headers', request.requestHeaders); + console.log('body ', request.requestBody); + console.groupEnd(); + console.group('response', response.status); + console.log('headers', response.headers); + console.log('body ', response.body); + console.groupEnd(); + console.groupEnd(); + } else { + console.log( + 'FakeRest request ', + request.method, + request.url, + 'headers', + request.requestHeaders, + 'body', + request.requestBody, + ); + console.log( + 'FakeRest response', + response.status, + 'headers', + response.headers, + 'body', + response.body, + ); + } + } + + 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; + }; + } +} + +export const getSinonHandler = (options: SinonServerOptions) => { + const server = new SinonServer(options); + return server.getHandler(); +}; + +/** + * @deprecated Use SinonServer instead + */ +export const Server = SinonServer; + +export type SinonFakeRestResponse = { + status: number; + body: any; + headers: Record; +}; + +export type SinonServerOptions = BaseServerOptions & { + loggingEnabled?: boolean; +}; diff --git a/src/FakeRest.ts b/src/index.ts similarity index 52% rename from src/FakeRest.ts rename to src/index.ts index 281e3c7..4a7c59d 100644 --- a/src/FakeRest.ts +++ b/src/index.ts @@ -1,17 +1,26 @@ -import { getSinonHandler, Server, SinonServer } from './SinonServer.js'; +import { + getSinonHandler, + Server, + SinonServer, +} from './adapters/SinonServer.js'; import { getFetchMockHandler, FetchServer, FetchMockServer, -} from './FetchMockServer.js'; +} from './adapters/FetchMockServer.js'; +import { getMswHandler, MswServer } from './adapters/MswServer.js'; +import { Database } from './Database.js'; +import { BaseServer } from './BaseServer.js'; import { Collection } from './Collection.js'; import { Single } from './Single.js'; -import { getMswHandlers, MswServer } from './msw.js'; +import { withDelay } from './withDelay.js'; export { + BaseServer, + Database, getSinonHandler, getFetchMockHandler, - getMswHandlers, + getMswHandler, Server, SinonServer, FetchServer, @@ -19,12 +28,15 @@ export { MswServer, Collection, Single, + withDelay, }; export default { + BaseServer, + Database, getSinonHandler, getFetchMockHandler, - getMswHandlers, + getMswHandler, Server, SinonServer, FetchServer, @@ -32,4 +44,5 @@ export default { MswServer, Collection, Single, + withDelay, }; diff --git a/src/msw.ts b/src/msw.ts deleted file mode 100644 index 7ab9b09..0000000 --- a/src/msw.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { http, HttpResponse } from 'msw'; -import { BaseServer, type BaseServerOptions } from './BaseServer.js'; - -export class MswServer extends BaseServer { - 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: BaseServer; -}) => { - return http.all( - // Using a regex ensures we match all URLs that start with the collection name - new RegExp(`${baseUrl}/${collectionName}`), - async ({ request }) => { - const url = new URL(request.url); - const params = Object.fromEntries( - Array.from(new URLSearchParams(url.search).entries()).map( - ([key, value]) => [key, JSON.parse(value)], - ), - ); - let requestJson: Record | undefined = undefined; - try { - const text = await request.text(); - requestJson = JSON.parse(text); - } catch (e) { - // not JSON, no big deal - } - const response = server.handleRequest({ - url: request.url.split('?')[0], - method: request.method, - requestJson, - params, - }); - - return HttpResponse.json(response.body, { - status: response.status, - headers: response.headers, - }); - }, - ); -}; diff --git a/src/withDelay.ts b/src/withDelay.ts new file mode 100644 index 0000000..dd22c1e --- /dev/null +++ b/src/withDelay.ts @@ -0,0 +1,11 @@ +import type { Middleware } from './BaseServer.js'; + +export const withDelay = + (delayMs: number): Middleware => + (request, context, next) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(next(request, context)); + }, delayMs); + }); + }; 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',