diff --git a/.gitignore b/.gitignore index 328ffa6..f06235c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,2 @@ node_modules -bower_components dist -example/ng-admin/bower-components diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4bce94d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "singleQuote": true +} \ No newline at end of file diff --git a/Makefile b/Makefile index a93f5bf..6293ec9 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,6 @@ install: @npm install - @bower install build-dev: @NODE_ENV=development npm run build diff --git a/README.md b/README.md index 0bdf5a1..8b6498f 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,60 @@ # FakeRest -Intercept AJAX calls to fake a REST server based on JSON data. Use it on top of [Sinon.js](http://sinonjs.org/) (for `XMLHTTPRequest`) or [fetch-mock](https://github.com/wheresrhys/fetch-mock) (for `fetch`) to test JavaScript REST clients on the browser side (e.g. single page apps) without a server. +A browser library that intercepts AJAX calls to mock a REST server based on JSON data. + +Use it in conjunction with [MSW](https://mswjs.io/), [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/), or [Sinon.js](https://sinonjs.org/releases/v18/fake-xhr-and-server/) to test JavaScript REST clients on the client side (e.g. single page apps) without a server. See it in action in the [react-admin](https://marmelab.com/react-admin/) [demo](https://marmelab.com/react-admin-demo) ([source code](https://github.com/marmelab/react-admin/tree/master/examples/demo)). ## Installation -### MSW +```sh +npm install fakerest --save-dev +``` + +## Usage -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. +FakeRest lets you create a handler function that you can pass to an API mocking library. FakeRest supports [MSW](https://mswjs.io/), [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/), and [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/). If you have the choice, we recommend using MSW, as it will allow you to inspect requests as you usually do in the dev tools network tab. -First, install fakerest and MSW. Then initialize MSW: +### MSW + +Install [MSW](https://mswjs.io/) and initialize it: ```sh -npm install fakerest msw@latest --save-dev +npm install msw@latest --save-dev npx msw init # eg: public ``` -Then configure it: +Then configure an MSW worker: ```js // in ./src/fakeServer.js import { setupWorker } from "msw/browser"; import { getMswHandler } from "fakerest"; -const data = { - 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], - 'books': [ - { id: 0, author_id: 0, title: 'Anna Karenina' }, - { id: 1, author_id: 0, title: 'War and Peace' }, - { id: 2, author_id: 1, title: 'Pride and Prejudice' }, - { id: 3, author_id: 1, title: 'Sense and Sensibility' } - ], - 'settings': { - language: 'english', - preferred_format: 'hardback', - } -}; - -export const worker = setupWorker(getMswHandler({ +const handler = getMswHandler({ baseUrl: 'http://localhost:3000', - data -})); + data: { + 'authors': [ + { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, + { id: 1, first_name: 'Jane', last_name: 'Austen' } + ], + 'books': [ + { id: 0, author_id: 0, title: 'Anna Karenina' }, + { id: 1, author_id: 0, title: 'War and Peace' }, + { id: 2, author_id: 1, title: 'Pride and Prejudice' }, + { id: 3, author_id: 1, title: 'Sense and Sensibility' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + } +}); +export const worker = setupWorker(handler); ``` -Finally call the `worker.start()` method before rendering your application. For instance, in a Vite React application: +Finally, call the `worker.start()` method before rendering your application. For instance, in a Vite React application: ```js import React from "react"; @@ -63,571 +70,726 @@ worker.start({ }); ``` -Another option is to use the `MswServer` class. This is useful if you must conditionally include data or add middlewares: +FakeRest will now intercept every `fetch` request to the REST server. + +### fetch-mock + +Install [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/): + +```sh +npm install fetch-mock --save-dev +``` + +You can then create a handler and pass it to fetch-mock: ```js -// in ./src/fakeServer.js -import { setupWorker } from "msw/browser"; -import { MswServer } from "fakerest"; - -const data = { - 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], - 'books': [ - { id: 0, author_id: 0, title: 'Anna Karenina' }, - { id: 1, author_id: 0, title: 'War and Peace' }, - { id: 2, author_id: 1, title: 'Pride and Prejudice' }, - { id: 3, author_id: 1, title: 'Sense and Sensibility' } - ], - 'settings': { - language: 'english', - preferred_format: 'hardback', - } -}; +import fetchMock from 'fetch-mock'; +import { getFetchMockHandler } from "fakerest"; -const restServer = new MswServer({ +const handler = getFetchMockHandler({ baseUrl: 'http://localhost:3000', - data, + data: { + 'authors': [ + { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, + { id: 1, first_name: 'Jane', last_name: 'Austen' } + ], + 'books': [ + { id: 0, author_id: 0, title: 'Anna Karenina' }, + { id: 1, author_id: 0, title: 'War and Peace' }, + { id: 2, author_id: 1, title: 'Pride and Prejudice' }, + { id: 3, author_id: 1, title: 'Sense and Sensibility' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + } }); -export const worker = setupWorker(restServer.getHandler()); +fetchMock.mock('begin:http://localhost:3000', handler); ``` -FakeRest will now intercept every `fetch` requests to the REST server. +FakeRest will now intercept every `fetch` request to the REST server. ### Sinon -```js -// in ./src/fakeServer.js -import sinon from 'sinon'; -import { getSinonHandler } from "fakerest"; - -const data = { - 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], - 'books': [ - { id: 0, author_id: 0, title: 'Anna Karenina' }, - { id: 1, author_id: 0, title: 'War and Peace' }, - { id: 2, author_id: 1, title: 'Pride and Prejudice' }, - { id: 3, author_id: 1, title: 'Sense and Sensibility' } - ], - 'settings': { - language: 'english', - preferred_format: 'hardback', - } -}; - -// use sinon.js to monkey-patch XmlHttpRequest -const sinonServer = sinon.fakeServer.create(); -// this is required when doing asynchronous XmlHttpRequest -sinonServer.autoRespond = true; +Install [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/): -sinonServer.respondWith( - getSinonHandler({ - baseUrl: 'http://localhost:3000', - data, - }) -); +```sh +npm install sinon --save-dev ``` -Another option is to use the `SinonServer` class. This is useful if you must conditionally include data or add middlewares: +Then, configure a Sinon server: ```js -// in ./src/fakeServer.js import sinon from 'sinon'; -import { SinonServer } from "fakerest"; - -const data = { - 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], - 'books': [ - { id: 0, author_id: 0, title: 'Anna Karenina' }, - { id: 1, author_id: 0, title: 'War and Peace' }, - { id: 2, author_id: 1, title: 'Pride and Prejudice' }, - { id: 3, author_id: 1, title: 'Sense and Sensibility' } - ], - 'settings': { - language: 'english', - preferred_format: 'hardback', - } -}; +import { getSinonHandler } from "fakerest"; -const restServer = new SinonServer({ +const handler = getSinonHandler({ baseUrl: 'http://localhost:3000', - data, + data: { + 'authors': [ + { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, + { id: 1, first_name: 'Jane', last_name: 'Austen' } + ], + 'books': [ + { id: 0, author_id: 0, title: 'Anna Karenina' }, + { id: 1, author_id: 0, title: 'War and Peace' }, + { id: 2, author_id: 1, title: 'Pride and Prejudice' }, + { id: 3, author_id: 1, title: 'Sense and Sensibility' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + }, }); // use sinon.js to monkey-patch XmlHttpRequest const sinonServer = sinon.fakeServer.create(); // this is required when doing asynchronous XmlHttpRequest sinonServer.autoRespond = true; - -sinonServer.respondWith( - restServer.getHandler({ - baseUrl: 'http://localhost:3000', - data, - }) -); +sinonServer.respondWith(handler); ``` -FakeRest will now intercept every `XmlHttpRequest` requests to the REST server. +FakeRest will now intercept every `XMLHttpRequest` request to the REST server. -### fetch-mock +## REST Syntax -First, install fakerest and [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/): +FakeRest uses a simple REST syntax described below. -```sh -npm install fakerest fetch-mock --save-dev -``` +### Get A Collection of records -You can then initialize the `FetchMockServer`: - -```js -// in ./src/fakeServer.js -import fetchMock from 'fetch-mock'; -import { getFetchMockHandler } from "fakerest"; +`GET /[name]` returns an array of records in the `name` collection. It accepts 4 query parameters: `filter`, `sort`, `range`, and `embed`. It responds with a status 200 if there is no pagination, or 206 if the list of items is paginated. The response mentions the total count in the `Content-Range` header. -const data = { - 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], - 'books': [ - { id: 0, author_id: 0, title: 'Anna Karenina' }, - { id: 1, author_id: 0, title: 'War and Peace' }, - { id: 2, author_id: 1, title: 'Pride and Prejudice' }, - { id: 3, author_id: 1, title: 'Sense and Sensibility' } - ], - 'settings': { - language: 'english', - preferred_format: 'hardback', - } -}; + GET /books?filter={"author_id":1}&embed=["author"]&sort=["title","desc"]&range=[0-9] -fetchMock.mock( - 'begin:http://localhost:3000', - getFetchMockHandler({ baseUrl: 'http://localhost:3000', data }) -); -``` + HTTP 1.1 200 OK + Content-Range: items 0-1/2 + Content-Type: application/json + [ + { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }, + { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } + ] -Another option is to use the `FetchMockServer` class. This is useful if you must conditionally include data or add middlewares: +The `filter` param must be a serialized object literal describing the criteria to apply to the search query. See the [supported filters](#supported-filters) for more details. -```js -import fetchMock from 'fetch-mock'; -import { FetchMockServer } from 'fakerest'; - -const data = { - 'authors': [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 1, first_name: 'Jane', last_name: 'Austen' } - ], - 'books': [ - { id: 0, author_id: 0, title: 'Anna Karenina' }, - { id: 1, author_id: 0, title: 'War and Peace' }, - { id: 2, author_id: 1, title: 'Pride and Prejudice' }, - { id: 3, author_id: 1, title: 'Sense and Sensibility' } - ], - 'settings': { - language: 'english', - preferred_format: 'hardback', - } -}; -const restServer = new FetchMockServer({ - baseUrl: 'http://localhost:3000', - data -}); -fetchMock.mock('begin:http://localhost:3000', restServer.getHandler()); -``` + GET /books?filter={"author_id":1} // return books where author_id is equal to 1 + HTTP 1.1 200 OK + Content-Range: items 0-1/2 + Content-Type: application/json + [ + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, + { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } + ] -FakeRest will now intercept every `fetch` requests to the REST server. + // array values are possible + GET /books?filter={"id":[2,3]} // return books where id is in [2,3] + HTTP 1.1 200 OK + Content-Range: items 0-1/2 + Content-Type: application/json + [ + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, + { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } + ] -## Concepts + // use the special "q" filter to make a full-text search on all text fields + GET /books?filter={"q":"and"} // return books where any of the book properties contains the string 'and' -### Server + HTTP 1.1 200 OK + Content-Range: items 0-2/3 + Content-Type: application/json + [ + { "id": 1, "author_id": 0, "title": "War and Peace" }, + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, + { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } + ] -A fake server implementation. FakeRest provide the following: + // use _gt, _gte, _lte, _lt, or _neq suffix on filter names to make range queries + GET /books?filter={"price_lte":20} // return books where the price is less than or equal to 20 + GET /books?filter={"price_gt":20} // return books where the price is greater than 20 -- `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/) + // when the filter object contains more than one property, the criteria combine with an AND logic + GET /books?filter={"published_at_gte":"2015-06-12","published_at_lte":"2015-06-15"} // return books published between two dates -### Database +The `sort` param must be a serialized array literal defining first the property used for sorting, then the sorting direction. -FakeRest internal database, that contains [collections](#collections) and [single](#single). + GET /author?sort=["date_of_birth","asc"] // return authors, the oldest first + GET /author?sort=["date_of_birth","desc"] // return authors, the youngest first -### Collections +The `range` param defines the number of results by specifying the rank of the first and last results. The first result is #0. -The equivalent to a classic database table or document collection. It supports filtering. + GET /books?range=[0-9] // return the first 10 books + GET /books?range=[10-19] // return the 10 next books -### Single +The `embed` param sets the related objects or collections to be embedded in the response. -Represent an API endpoint that returns a single entity. Useful for things such as user profile routes (`/me`) or global settings (`/settings`). + // embed author in books + GET /books?embed=["author"] + HTTP 1.1 200 OK + Content-Range: items 0-3/4 + Content-Type: application/json + [ + { "id": 0, "author_id": 0, "title": "Anna Karenina", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } }, + { "id": 1, "author_id": 0, "title": "War and Peace", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } }, + { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }, + { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } + ] -### Embeds + // embed books in author + GET /authors?embed=["books"] + HTTP 1.1 200 OK + Content-Range: items 0-1/2 + Content-Type: application/json + [ + { id: 0, first_name: 'Leo', last_name: 'Tolstoi', books: [{ id: 0, author_id: 0, title: 'Anna Karenina' }, { id: 1, author_id: 0, title: 'War and Peace' }] }, + { id: 1, first_name: 'Jane', last_name: 'Austen', books: [{ id: 2, author_id: 1, title: 'Pride and Prejudice' }, { id: 3, author_id: 1, title: 'Sense and Sensibility' }] } + ] -FakeRest support embedding other resources in a main resource query result. For instance, embedding the author of a book. - -## REST Flavor - -FakeRest defines a REST flavor, described below. It is inspired by commonly used ways how to handle aspects like filtering and sorting. - -* `GET /foo` returns a JSON array. It accepts three query parameters: `filter`, `sort`, and `range`. It responds with a status 200 if there is no pagination, or 206 if the list of items is paginated. The response contains a mention of the total count in the `Content-Range` header. - - GET /books?filter={"author_id":1}&embed=["author"]&sort=["title","desc"]&range=[0-9] + // you can embed several objects + GET /authors?embed=["books","country"] - HTTP 1.1 200 OK - Content-Range: items 0-1/2 - Content-Type: application/json - [ - { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }, - { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } - ] +### Get A Single Record - The `filter` param must be a serialized object literal describing the criteria to apply to the search query. +`GET /[name]/:id` returns a JSON object, and a status 200, unless the resource doesn't exist. - GET /books?filter={"author_id":1} // return books where author_id is equal to 1 - HTTP 1.1 200 OK - Content-Range: items 0-1/2 - Content-Type: application/json - [ - { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, - { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } - ] + GET /books/2 - // array values are possible - GET /books?filter={"id":[2,3]} // return books where id is in [2,3] - HTTP 1.1 200 OK - Content-Range: items 0-1/2 - Content-Type: application/json - [ - { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, - { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } - ] + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } - // use the special "q" filter to make a full-text search on all text fields - GET /books?filter={"q":"and"} // return books where any of the book properties contains the string 'and' +The `embed` param sets the related objects or collections to be embedded in the response. - HTTP 1.1 200 OK - Content-Range: items 0-2/3 - Content-Type: application/json - [ - { "id": 1, "author_id": 0, "title": "War and Peace" }, - { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }, - { "id": 3, "author_id": 1, "title": "Sense and Sensibility" } - ] + GET /books/2?embed=['author'] - // use _gt, _gte, _lte, _lt, or _neq suffix on filter names to make range queries - GET /books?filter={"price_lte":20} // return books where price is less than or equal to 20 - GET /books?filter={"price_gt":20} // return books where price is greater than 20 + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } - // when the filter object contains more than one property, the criteria combine with an AND logic - GET /books?filter={"published_at_gte":"2015-06-12","published_at_lte":"2015-06-15"} // return books published between two dates +### Create A Record - The `embed` param sets the related objects or collections to be embedded in the response. +`POST /[name]` returns a status 201 with a `Location` header for the newly created resource, and the new resource in the body. - // embed author in books - GET /books?embed=["author"] - HTTP 1.1 200 OK - Content-Range: items 0-3/4 - Content-Type: application/json - [ - { "id": 0, "author_id": 0, "title": "Anna Karenina", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } }, - { "id": 1, "author_id": 0, "title": "War and Peace", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } }, - { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }, - { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } - ] + POST /books + { "author_id": 1, "title": "Emma" } - // embed books in author - GET /authors?embed=["books"] - HTTP 1.1 200 OK - Content-Range: items 0-1/2 - Content-Type: application/json - [ - { id: 0, first_name: 'Leo', last_name: 'Tolstoi', books: [{ id: 0, author_id: 0, title: 'Anna Karenina' }, { id: 1, author_id: 0, title: 'War and Peace' }] }, - { id: 1, first_name: 'Jane', last_name: 'Austen', books: [{ id: 2, author_id: 1, title: 'Pride and Prejudice' }, { id: 3, author_id: 1, title: 'Sense and Sensibility' }] } - ] + HTTP 1.1 201 Created + Location: /books/4 + Content-Type: application/json + { "author_id": 1, "title": "Emma", "id": 4 } - // you can embed several objects - GET /authors?embed=["books","country"] - The `sort` param must be a serialized array literal defining first the property used for sorting, then the sorting direction. +### Update A Record - GET /author?sort=["date_of_birth","asc"] // return authors, the oldest first - GET /author?sort=["date_of_birth","desc"] // return authors, the youngest first +`PUT /[name]/:id` returns the modified JSON object, and a status 200, unless the resource doesn't exist. - The `range` param defines the number of results by specifying the rank of the first and last result. The first result is #0. + PUT /books/2 + { "author_id": 1, "title": "Pride and Prejudice" } - GET /books?range=[0-9] // return the first 10 books - GET /books?range=[10-19] // return the 10 next books + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } -* `POST /foo` returns a status 201 with a `Location` header for the newly created resource, and the new resource in the body. +### Delete A Single Record - POST /books - { "author_id": 1, "title": "Emma" } +`DELETE /[name]/:id` returns the deleted JSON object, and a status 200, unless the resource doesn't exist. - HTTP 1.1 201 Created - Location: /books/4 - Content-Type: application/json - { "author_id": 1, "title": "Emma", "id": 4 } + DELETE /books/2 -* `GET /foo/:id` returns a JSON object, and a status 200, unless the resource doesn't exist + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } - GET /books/2 +### Supported Filters - HTTP 1.1 200 OK - Content-Type: application/json - { "id": 2, "author_id": 1, "title": "Pride and Prejudice" } +Operators are specified as suffixes on each filtered field. For instance, applying the `_lte` operator on the `price` field for the `books` resource is done like this: - The `embed` param sets the related objects or collections to be embedded in the response. + GET /books?filter={"price_lte":20} // return books where the price is less than or equal to 20 - GET /books/2?embed=['author'] +- `_eq`: check for equality on simple values: - HTTP 1.1 200 OK - Content-Type: application/json - { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } + GET /books?filter={"price_eq":20} // return books where the price is equal to 20 -* `PUT /foo/:id` returns the modified JSON object, and a status 200, unless the resource doesn't exist -* `DELETE /foo/:id` returns the deleted JSON object, and a status 200, unless the resource doesn't exist +- `_neq`: check for inequality on simple values -If the REST flavor you want to simulate differs from the one chosen for FakeRest, no problem: request and response interceptors will do the conversion (see below). + GET /books?filter={"price_neq":20} // return books where the price is not equal to 20 -Note that all of the above apply only to collections. Single objects respond to `GET /bar`, `PUT /bar` and `PATCH /bar` in a manner identical to those operations for `/foo/:id`, including embedding. `POST /bar` and `DELETE /bar` are not enabled. +- `_eq_any`: check for equality on any passed values -## Supported Filters + GET /books?filter={"price_eq_any":[20, 30]} // return books where the price is equal to 20 or 30 -Operators are specified as suffixes on each filtered field. For instance, applying the `_lte` operator on the `price` field for the `books` resource is done by like this: +- `_neq_any`: check for inequality on any passed values - GET /books?filter={"price_lte":20} // return books where price is less than or equal to 20 + GET /books?filter={"price_neq_any":[20, 30]} // return books where the price is not equal to 20 nor 30 -- `_eq`: check for equality on simple values: +- `_inc_any`: check for items that include any of the passed values - GET /books?filter={"price_eq":20} // return books where price is equal to 20 + GET /books?filter={"authors_inc_any":['William Gibson', 'Pat Cadigan']} // return books where authors include either 'William Gibson' or 'Pat Cadigan' or both -- `_neq`: check for inequality on simple values +- `_q`: check for items that contain the provided text - GET /books?filter={"price_neq":20} // return books where price is not equal to 20 + GET /books?filter={"author_q":['Gibson']} // return books where the author includes 'Gibson' not considering the other fields -- `_eq_any`: check for equality on any passed values +- `_lt`: check for items that have a value lower than the provided value - GET /books?filter={"price_eq_any":[20, 30]} // return books where price is equal to 20 or 30 + GET /books?filter={"price_lte":100} // return books that have a price lower that 100 -- `_neq_any`: check for inequality on any passed values +- `_lte`: check for items that have a value lower than or equal to the provided value - GET /books?filter={"price_neq_any":[20, 30]} // return books where price is not equal to 20 nor 30 + GET /books?filter={"price_lte":100} // return books that have a price lower or equal to 100 -- `_inc_any`: check for items that includes any of the passed values +- `_gt`: check for items that have a value greater than the provided value - GET /books?filter={"authors_inc_any":['William Gibson', 'Pat Cadigan']} // return books where authors includes either 'William Gibson' or 'Pat Cadigan' or both + GET /books?filter={"price_gte":100} // return books that have a price greater that 100 -- `_q`: check for items that contains the provided text +- `_gte`: check for items that have a value greater than or equal to the provided value - GET /books?filter={"author_q":['Gibson']} // return books where author includes 'Gibson' not considering the other fields + GET /books?filter={"price_gte":100} // return books that have a price greater or equal to 100 -- `_lt`: check for items that has a value lower than the provided value +### Single Elements - GET /books?filter={"price_lte":100} // return books that have a price lower that 100 +FakeRest allows you to define a single element, such as a user profile or global settings, that can be fetched, updated, or deleted. -- `_lte`: check for items that has a value lower or equal than the provided value + GET /settings - GET /books?filter={"price_lte":100} // return books that have a price lower or equal to 100 + HTTP 1.1 200 OK + Content-Type: application/json + { "language": "english", "preferred_format": "hardback" } -- `_gt`: check for items that has a value greater than the provided value + PUT /settings + { "language": "french", "preferred_format": "paperback" } - GET /books?filter={"price_gte":100} // return books that have a price greater that 100 + HTTP 1.1 200 OK + Content-Type: application/json + { "language": "french", "preferred_format": "paperback" } -- `_gte`: check for items that has a value greater or equal than the provided value + DELETE /settings - GET /books?filter={"price_gte":100} // return books that have a price greater or equal to 100 + HTTP 1.1 200 OK + Content-Type: application/json + { "language": "french", "preferred_format": "paperback" } ## Middlewares -All fake servers supports middlewares that allows you to intercept requests and simulate server features such as: - - 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` +Middlewares let you intercept requests and simulate server features such as: + - authentication checks + - server-side validation + - server dynamically generated values + - simulate response delays -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 +You can define middlewares on all handlers, by passing a `middlewares` option: -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. +```js +import { getMswHandler } from 'fakerest'; +import { data } from './data'; + +const handler = getMswHandler({ + baseUrl: 'http://my.custom.domain', + data, + middlewares: [ + async (context, next) => { + if (context.headers.Authorization === undefined) { + return { + status: 401, + headers: {}, + }; + } + + return next(context); + }, + withDelay(300), + ], +}); +``` + +A middleware is a function that receives 2 parameters: + - The FakeRest `context`, an object containing the data extracted from the request that FakeRest uses to build the response. It has the following properties: + - `method`: The request method as a string (`GET`, `POST`, `PATCH` or `PUT`) + - `url`: The request URL as a string + - `headers`: The request headers as an object where keys are header names + - `requestBody`: The parsed request data if any + - `params`: The request parameters from the URL search (e.g. the identifier of the requested record) + - `collection`: The name of the targeted [collection](#collection) (e.g. `posts`) + - `single`: The name of the targeted [single](#single) (e.g. `settings`) + - A `next` function to call the next middleware in the chain, to which you must pass the `context` + +A middleware must return a FakeRest response either by returning the result of the `next` function or by returning its own response. A FakeRest response is an object with the following properties: + - `status`: The response status as a number (e.g. `200`) + - `headers`: The response HTTP headers as an object where keys are header names + - `body`: The response body which will be stringified ### Authentication Checks -Here's to implement an authentication check: +Here's how to implement an authentication check: ```js -restServer.addMiddleware(async (request, context, next) => { - if (request.requestHeaders.Authorization === undefined) { - return { - status: 401, - headers: {}, - }; - } - - return next(request, context); -} +const handler = getMswHandler({ + baseUrl: 'http://my.custom.domain', + data, + middlewares: [ + async (context, next) => { + if (context.headers.Authorization === undefined) { + return { status: 401, headers: {} }; + } + return next(context); + } + ] +}); ``` -### Server Side Validation +### Server-Side Validation -Here's to implement server side validation: +Here's how to implement server-side validation: ```js -restServer.addMiddleware(async (request, context, next) => { - if ( - context.collection === "books" && - request.method === "POST" && - !context.requestJson?.title - ) { - return { - status: 400, - headers: {}, - body: { - errors: { - title: 'An article with this title already exists. The title must be unique.', - }, - }, - }; - } - - return next(request, context); -} +const handler = getMswHandler({ + baseUrl: 'http://my.custom.domain', + data, + middlewares: [ + async (context, next) => { + if ( + context.collection === "books" && + request.method === "POST" && + !context.requestBody?.title + ) { + return { + status: 400, + headers: {}, + body: { + errors: { + title: 'An article with this title already exists. The title must be unique.', + }, + }, + }; + } + + return next(context); + } + ] +}); ``` -### Server Dynamically Generated Values +### Dynamically Generated Values -Here's to implement server dynamically generated values: +Here's how to implement dynamically generated values on creation: ```js -restServer.addMiddleware(async (request, context, next) => { - if ( - context.collection === 'books' && - context.method === 'POST' - ) { - const response = await next(request, context); - response.body.updatedAt = new Date().toISOString(); - return response; - } - - return next(request, context); -} +const handler = getMswHandler({ + baseUrl: 'http://my.custom.domain', + data, + middlewares: [ + async (context, next) => { + if ( + context.collection === 'books' && + context.method === 'POST' + ) { + const response = await next(context); + response.body.updatedAt = new Date().toISOString(); + return response; + } + + return next(context); + } + ] +}); ``` ### Simulate Response Delays -Here's to simulate response delays: +Here's how to simulate response delays: ```js -restServer.addMiddleware(async (request, context, next) => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(next(request, context)); - }, delayMs); - }); +const handler = getMswHandler({ + baseUrl: 'http://my.custom.domain', + data, + middlewares: [ + async (context, next) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(next(context)); + }, 500); + }); + } + ] }); ``` This is so common FakeRest provides the `withDelay` function for that: ```js -import { withDelay } from 'fakerest'; +import { getMswHandler, withDelay } from 'fakerest'; -restServer.addMiddleware(withDelay(300)); +const handler = getMswHandler({ + baseUrl: 'http://my.custom.domain', + data, + middlewares: [ + withDelay(500), // delay in ms + ] +}); ``` ## Configuration -### Configure Identifiers +All handlers can be customized to accommodate your API structure. -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: +### Identifiers -```js -import { MswServer } from 'fakerest'; +By default, FakeRest assumes all records have a unique `id` field. +Some databases such as [MongoDB](https://www.mongodb.com) use `_id` instead of `id` for collection identifiers. +You can customize FakeRest to do the same by setting the `identifierName` option: -const restServer = new MswServer({ +```js +const handler = getMswHandler({ baseUrl: 'http://my.custom.domain', + data, identifierName: '_id' }); ``` -This can also be specified at the collection level: +You can also specify that on a per-collection basis: ```js -import { MswServer, Collection } from 'fakerest'; +import { MswAdapter, Collection } from 'fakerest'; -const restServer = new MswServer({ baseUrl: 'http://my.custom.domain' }); +const adapter = new MswAdapter({ baseUrl: 'http://my.custom.domain', data }); const authorsCollection = new Collection({ items: [], identifierName: '_id' }); -restServer.addCollection('authors', authorsCollection); +adapter.server.addCollection('authors', authorsCollection); +const handler = adapter.getHandler(); ``` -### Configure Identifiers Generation +### Primary Keys -By default, FakeRest uses an auto incremented sequence for the items identifiers. +By default, FakeRest uses an auto-incremented sequence for the item identifiers. If you'd rather use UUIDs for instance but would like to avoid providing them when you insert new items, you can provide your own function: ```js -import { MswServer } from 'fakerest'; +import { getMswHandler } from 'fakerest'; import uuid from 'uuid'; -const restServer = new MswServer({ +const handler = new getMswHandler({ baseUrl: 'http://my.custom.domain', + data, getNewId: () => uuid.v5() }); ``` -This can also be specified at the collection level: +You can also specify that on a per-collection basis: ```js -import { MswServer, Collection } from 'fakerest'; +import { MswAdapter, Collection } from 'fakerest'; import uuid from 'uuid'; -const restServer = new MswServer({ baseUrl: 'http://my.custom.domain' }); +const adapter = new MswAdapter({ baseUrl: 'http://my.custom.domain', data }); const authorsCollection = new Collection({ items: [], getNewId: () => uuid.v5() }); -restServer.addCollection('authors', authorsCollection); +adapter.server.addCollection('authors', authorsCollection); +const handler = adapter.getHandler(); ``` -### Configure Default Queries +### Default Queries Some APIs might enforce some parameters on queries. For instance, an API might always include an [embed](#embed) or enforce a query filter. You can simulate this using the `defaultQuery` parameter: ```js -import { MswServer } from 'fakerest'; +import { getMswHandler } from 'fakerest'; import uuid from 'uuid'; -const restServer = new MswServer({ +const handler = getMswHandler({ baseUrl: 'http://my.custom.domain', - getNewId: () => uuid.v5(), + data, defaultQuery: (collection) => { if (resourceName == 'authors') return { embed: ['books'] } if (resourceName == 'books') return { filter: { published: true } } return {}; - } + } +}); +``` + +## Architecture + +Behind a simple API (`getXXXHandler`), FakeRest uses a modular architecture that lets you combine different components to build a fake REST server that fits your needs. + +### Mocking Adapter + +`getXXXHandler` is a shortcut to an object-oriented API of adapter classes: + +```js +export const getMswHandler = (options: MswAdapterOptions) => { + const server = new MswAdapter(options); + return server.getHandler(); +}; +``` + +FakeRest provides 3 adapter classes: + +- `MswAdapter`: Based on [MSW](https://mswjs.io/) +- `FetchMockAdapter`: Based on [`fetch-mock`](https://www.wheresrhys.co.uk/fetch-mock/) +- `SinonAdapter`: Based on [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/) + +You can use the adapter class directly, e.g. if you want to make the adapter instance available in the global scope for debugging purposes: + +```js +import { MsWAdapter } from 'fakerest'; + +const adapter = new MswAdapter({ + baseUrl: 'http://my.custom.domain', + data: { + 'authors': [ + { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, + { id: 1, first_name: 'Jane', last_name: 'Austen' } + ], + 'books': [ + { id: 0, author_id: 0, title: 'Anna Karenina' }, + { id: 1, author_id: 0, title: 'War and Peace' }, + { id: 2, author_id: 1, title: 'Pride and Prejudice' }, + { id: 3, author_id: 1, title: 'Sense and Sensibility' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + } +}); +window.fakerest = adapter; +const handler = adapter.getHandler(); +``` + +### REST Server + +Adapters transform requests to a normalized format, pass them to a server object, and transform the normalized server response into the format expected by the mocking library. + +The server object implements the REST syntax. It takes a normalized request and exposes a `handle` method that returns a normalized response. FakeRest currently provides only one server implementation: `SimpleRestServer`. + +You can specify the server to use in an adapter by passing the `server` option: + +```js +const server = new SimpleRestServer({ + baseUrl: 'http://my.custom.domain', + data: { + 'authors': [ + { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, + { id: 1, first_name: 'Jane', last_name: 'Austen' } + ], + 'books': [ + { id: 0, author_id: 0, title: 'Anna Karenina' }, + { id: 1, author_id: 0, title: 'War and Peace' }, + { id: 2, author_id: 1, title: 'Pride and Prejudice' }, + { id: 3, author_id: 1, title: 'Sense and Sensibility' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + } +}); +const adapter = new MswAdapter({ server }); +const handler = adapter.getHandler(); +``` + +You can provide an alternative server implementation. This class must implement the `APIServer` type: + +```ts +export type APIServer = { + baseUrl?: string; + handle: (context: FakeRestContext) => Promise; +}; + +export type BaseResponse = { + status: number; + body?: Record | Record[]; + headers: { [key: string]: string }; +}; + +export type FakeRestContext = { + url?: string; + headers?: Headers; + method?: string; + collection?: string; + single?: string; + requestBody: Record | undefined; + params: { [key: string]: any }; +}; +``` + +The `FakerRestContext` type describes the normalized request. It's usually the adapter's job to transform the request from the mocking library to this format. + +### Database + +The querying logic is implemented in a class called `Database`, which is independent of the server. It contains [collections](#collections) and [single](#single). + +You can specify the database used by a server by setting its `database` property: + +```js +const database = new Database({ + data: { + 'authors': [ + { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, + { id: 1, first_name: 'Jane', last_name: 'Austen' } + ], + 'books': [ + { id: 0, author_id: 0, title: 'Anna Karenina' }, + { id: 1, author_id: 0, title: 'War and Peace' }, + { id: 2, author_id: 1, title: 'Pride and Prejudice' }, + { id: 3, author_id: 1, title: 'Sense and Sensibility' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + } }); +const server = new SimpleRestServer({ baseUrl: 'http://my.custom.domain', database }); ``` +You can even use the database object if you want to manipulate the data: + +```js +database.updateOne('authors', 0, { first_name: 'Lev' }); +``` + +### Collections & Singles + +The Database may contain collections and singles. In the following example, `authors` and `books` are collections, and `settings` is a single. + +```js +const handler = getMswHandler({ + baseUrl: 'http://localhost:3000', + data: { + 'authors': [ + { id: 0, first_name: 'Leo', last_name: 'Tolstoi' }, + { id: 1, first_name: 'Jane', last_name: 'Austen' } + ], + 'books': [ + { id: 0, author_id: 0, title: 'Anna Karenina' }, + { id: 1, author_id: 0, title: 'War and Peace' }, + { id: 2, author_id: 1, title: 'Pride and Prejudice' }, + { id: 3, author_id: 1, title: 'Sense and Sensibility' } + ], + 'settings': { + language: 'english', + preferred_format: 'hardback', + } + } +}); +``` + +A collection is the equivalent of a classic database table. It supports filtering and direct access to records by their identifier. + +A single represents an API endpoint that returns a single entity. It's useful for things such as user profile routes (`/me`) or global settings (`/settings`). + +### Embeds + +FakeRest supports embedding other resources in a main resource query result. For instance, embedding the author of a book. + + GET /books/2?embed=['author'] + + HTTP 1.1 200 OK + Content-Type: application/json + { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } } + +Embeds are defined by the query, they require no setup in the database. + ## Development ```sh diff --git a/UPGRADE.md b/UPGRADE.md index b6026e9..f9fe4fc 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,49 +1,39 @@ # Upgrading to 4.0.0 -## Renamed `Server` And `FetchServer` +## Dropped bower support -The `Server` class has been renamed to `SinonServer`. +Fakerest no longer supports bower. You can still use it in your project by installing it via npm: -```diff --import { Server } from 'fakerest'; -+import { SinonServer } from 'fakerest'; - --const server = new Server('http://myapi.com'); -+const server = new SinonServer({ baseUrl: 'http://myapi.com' }); +```bash +npm install fakerest ``` -The `FetchServer` class has been renamed to `FetchMockServer`. - -```diff --import { FetchServer } from 'fakerest'; -+import { FetchMockServer } from 'fakerest'; - --const server = new FetchServer('http://myapi.com'); -+const server = new FetchMockServer({ baseUrl: 'http://myapi.com' }); -``` +## Renamed `Server` to `SinonAdapter` -## Constructors Of `SinonServer` and `FetchMockServer` Take An Object - -For `SinonServer`: +The `Server` class has been renamed to `SinonAdapter` and now expects a configuration object instead of a URL. ```diff -import { SinonServer } from 'fakerest'; +-import { Server } from 'fakerest'; ++import { SinonAdapter } from 'fakerest'; import { data } from './data'; --const server = new SinonServer('http://myapi.com'); -+const server = new SinonServer({ baseUrl: 'http://myapi.com' }); -server.init(data); +-const server = new Server('http://myapi.com'); +-server.init(data); ++const server = new SinonAdapter({ baseUrl: 'http://myapi.com', data }); ``` -For `FetchServer`: +## Renamed `FetchServer` to `FetchMockAdapter` + +The `FetchServer` class has been renamed to `FetchMockAdapter` and now expects a configuration object instead of a URL. ```diff -import { FetchMockServer } from 'fakerest'; +-import { FetchServer } from 'fakerest'; ++import { FetchMockAdapter } from 'fakerest'; import { data } from './data'; --const server = new FetchMockServer('http://myapi.com'); -+const server = new FetchMockServer({ baseUrl: 'http://myapi.com' }); -server.init(data); +-const server = new FetchServer('http://myapi.com'); +-server.init(data); ++const server = new FetchMockAdapter({ baseUrl: 'http://myapi.com', data }); ``` ## Constructor Of `Collection` Takes An Object @@ -67,26 +57,24 @@ server.init(data); Fakerest used to have request and response interceptors. We replaced those with middlewares. They allow much more use cases. -Migrate your request interceptors: +Migrate your request interceptors to middlewares passed when building the handler: ```diff --restServer.addRequestInterceptor(function(request) { -+restServer.addMiddleware(async function(request, context, next) { +- const myRequestInterceptor = function(request) { ++ const myMiddleware = async function(context, next) { var start = (request.params._start - 1) || 0; var end = request.params._end !== undefined ? (request.params._end - 1) : 19; request.params.range = [start, end]; -- return request; // always return the modified input -+ return next(request, context); +- return request; // always return the modified input ++ return next(context); +}; + +-restServer.addRequestInterceptor(myRequestInterceptor); ++const handler = new getMswHandler({ ++ baseUrl: 'http://my.custom.domain', ++ data, ++ middlewares: [myMiddleware], }); ``` -Migrate your response interceptors: - -```diff --restServer.addResponseInterceptor(function(response) { -+restServer.addMiddleware(async function(request, context, next) { -+ const response = await next(request, context); - response.body = { data: response.body, status: response.status }; - return response; -}); -``` \ No newline at end of file +Migrate your response interceptors the same way. diff --git a/example/App.tsx b/example/App.tsx index 0a91491..eba3ed4 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -47,9 +47,10 @@ export const App = ({ dataProvider }: { dataProvider: DataProvider }) => { ); }; -import { Edit, ReferenceInput, SimpleForm, TextInput } from 'react-admin'; +import { ReferenceInput, SimpleForm, TextInput } from 'react-admin'; import authProvider from './authProvider'; +// The default value for the title field should cause a server validation error as it's not unique export const BookCreate = () => ( diff --git a/example/fetchMock.ts b/example/fetchMock.ts index e7d8fd1..09bd9ba 100644 --- a/example/fetchMock.ts +++ b/example/fetchMock.ts @@ -1,54 +1,21 @@ import fetchMock from 'fetch-mock'; -import { FetchMockServer, withDelay } from '../src'; +import { FetchMockAdapter } from '../src'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; +import { middlewares } from './middlewares'; export const initializeFetchMock = () => { - const restServer = new FetchMockServer({ + const restServer = new FetchMockAdapter({ baseUrl: 'http://localhost:3000', data, loggingEnabled: true, + middlewares, }); if (window) { // @ts-ignore window.restServer = restServer; // give way to update data in the console } - restServer.addMiddleware(withDelay(300)); - restServer.addMiddleware(async (request, context, next) => { - if (!request.headers?.get('Authorization')) { - return { - status: 401, - headers: {}, - }; - } - return next(request, context); - }); - restServer.addMiddleware(async (request, context, next) => { - if (context.collection === 'books' && request.method === 'POST') { - if ( - restServer.database.getCount(context.collection, { - filter: { - title: context.requestBody?.title, - }, - }) > 0 - ) { - throw new Response( - JSON.stringify({ - errors: { - title: 'An article with this title already exists. The title must be unique.', - }, - }), - { - status: 400, - statusText: 'Title is required', - }, - ); - } - } - - return next(request, context); - }); fetchMock.mock('begin:http://localhost:3000', restServer.getHandler()); }; diff --git a/example/middlewares.ts b/example/middlewares.ts new file mode 100644 index 0000000..6406a62 --- /dev/null +++ b/example/middlewares.ts @@ -0,0 +1,35 @@ +import { withDelay } from '../src'; +import { data } from './data'; + +export const middlewares = [ + withDelay(300), + async (context, next) => { + if (!context.headers?.get('Authorization')) { + return { + status: 401, + headers: {}, + }; + } + return next(context); + }, + async (context, next) => { + if (context.collection === 'books' && context.method === 'POST') { + if ( + data[context.collection].some( + (book) => book.title === context.requestBody?.title, + ) + ) { + return { + body: { + errors: { + title: 'An article with this title already exists. The title must be unique.', + }, + }, + status: 400, + headers: {}, + }; + } + } + return next(context); + }, +]; diff --git a/example/msw.ts b/example/msw.ts index f52a448..46c6228 100644 --- a/example/msw.ts +++ b/example/msw.ts @@ -1,52 +1,16 @@ import { setupWorker } from 'msw/browser'; -import { MswServer, withDelay } from '../src'; +import { getMswHandler } from '../src'; import { data } from './data'; import { dataProvider as defaultDataProvider } from './dataProvider'; - -const restServer = new MswServer({ - baseUrl: 'http://localhost:3000', - data, -}); - -restServer.addMiddleware(withDelay(300)); -restServer.addMiddleware(async (request, context, next) => { - if (!request.headers?.get('Authorization')) { - return { - status: 401, - headers: {}, - }; - } - return next(request, context); -}); - -restServer.addMiddleware(async (request, context, next) => { - if (context.collection === 'books' && request.method === 'POST') { - if ( - restServer.database.getCount(context.collection, { - filter: { - title: context.requestBody?.title, - }, - }) > 0 - ) { - throw new Response( - JSON.stringify({ - errors: { - title: 'An article with this title already exists. The title must be unique.', - }, - }), - { - status: 400, - statusText: 'Title is required', - }, - ); - } - } - - return next(request, context); -}); +import { middlewares } from './middlewares'; export const initializeMsw = async () => { - const worker = setupWorker(restServer.getHandler()); + const handler = getMswHandler({ + baseUrl: 'http://localhost:3000', + data, + middlewares, + }); + const worker = setupWorker(handler); return worker.start({ quiet: true, // Instruct MSW to not log requests in the console onUnhandledRequest: 'bypass', // Instruct MSW to ignore requests we don't handle diff --git a/example/sinon.ts b/example/sinon.ts index abf95a8..f1062c9 100644 --- a/example/sinon.ts +++ b/example/sinon.ts @@ -1,50 +1,16 @@ 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'; +import { HttpError, type Options } from 'react-admin'; +import { SinonAdapter } from '../src'; +import { data } from './data'; +import { middlewares } from './middlewares'; export const initializeSinon = () => { - const restServer = new SinonServer({ + const restServer = new SinonAdapter({ baseUrl: 'http://localhost:3000', data, loggingEnabled: true, - }); - - restServer.addMiddleware(withDelay(300)); - restServer.addMiddleware(async (request, context, next) => { - if (request.requestHeaders.Authorization === undefined) { - return { - status: 401, - headers: {}, - }; - } - - return next(request, context); - }); - - restServer.addMiddleware(async (request, context, next) => { - if (context.collection === 'books' && request.method === 'POST') { - if ( - restServer.database.getCount(context.collection, { - filter: { - title: context.requestBody?.title, - }, - }) > 0 - ) { - return { - status: 400, - headers: {}, - body: { - errors: { - title: 'An article with this title already exists. The title must be unique.', - }, - }, - }; - } - } - - return next(request, context); + middlewares, }); // use sinon.js to monkey-patch XmlHttpRequest diff --git a/src/BaseServer.ts b/src/SimpleRestServer.ts similarity index 75% rename from src/BaseServer.ts rename to src/SimpleRestServer.ts index c6ef837..a3dce08 100644 --- a/src/BaseServer.ts +++ b/src/SimpleRestServer.ts @@ -1,22 +1,31 @@ import type { Collection } from './Collection.js'; import { Database, type DatabaseOptions } from './Database.js'; import type { Single } from './Single.js'; -import type { CollectionItem, QueryFunction } from './types.js'; +import type { + APIServer, + BaseResponse, + FakeRestContext, + CollectionItem, + QueryFunction, + NormalizedRequest, +} from './types.js'; -export class BaseServer { +export class SimpleRestServer implements APIServer { baseUrl = ''; defaultQuery: QueryFunction = () => ({}); - middlewares: Array> = []; + middlewares: Array; database: Database; constructor({ baseUrl = '', defaultQuery = () => ({}), database, + middlewares, ...options }: BaseServerOptions = {}) { this.baseUrl = baseUrl; this.defaultQuery = defaultQuery; + this.middlewares = middlewares || []; if (database) { this.database = database; @@ -32,19 +41,19 @@ export class BaseServer { this.defaultQuery = query; } - getContext(context: NormalizedRequest): FakeRestContext { + getContext(normalizedRequest: NormalizedRequest): FakeRestContext { for (const name of this.database.getSingleNames()) { - const matches = context.url?.match( + const matches = normalizedRequest.url?.match( new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), ); if (!matches) continue; return { - ...context, + ...normalizedRequest, single: name, }; } - const matches = context.url?.match( + const matches = normalizedRequest.url?.match( new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), ); if (matches) { @@ -52,72 +61,54 @@ export class BaseServer { const params = Object.assign( {}, this.defaultQuery(name), - context.params, + normalizedRequest.params, ); return { - ...context, + ...normalizedRequest, collection: name, params, }; } - return context; - } - - getNormalizedRequest(request: RequestType): Promise { - throw new Error('Not implemented'); - } - - respond( - response: BaseResponse | null, - request: RequestType, - context: FakeRestContext, - ): Promise { - throw new Error('Not implemented'); + return normalizedRequest; } - async handle(request: RequestType): Promise { - const context = this.getContext( - await this.getNormalizedRequest(request), - ); - + async handle(normalizedRequest: NormalizedRequest): Promise { + const context = this.getContext(normalizedRequest); // Call middlewares let index = 0; const middlewares = [...this.middlewares]; - const next = (req: RequestType, ctx: FakeRestContext) => { + const next = (context: FakeRestContext) => { const middleware = middlewares[index++]; if (middleware) { - return middleware(req, ctx, next); + return middleware(context, next); } - - return this.handleRequest(req, ctx); + return this.handleRequest(context); }; try { - const response = await next(request, context); - if (response != null) { - return this.respond(response, request, context); - } + const response = await next(context); + return response; } catch (error) { if (error instanceof Error) { throw error; } - return error as ResponseType; + return error as BaseResponse; } } - handleRequest(request: RequestType, ctx: FakeRestContext): BaseResponse { + handleRequest(context: FakeRestContext): BaseResponse { // Handle Single Objects for (const name of this.database.getSingleNames()) { - const matches = ctx.url?.match( + const matches = context.url?.match( new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`), ); if (!matches) continue; - if (ctx.method === 'GET') { + if (context.method === 'GET') { try { return { status: 200, @@ -133,9 +124,9 @@ export class BaseServer { }; } } - if (ctx.method === 'PUT') { + if (context.method === 'PUT') { try { - if (ctx.requestBody == null) { + if (context.requestBody == null) { return { status: 400, headers: {}, @@ -143,7 +134,10 @@ export class BaseServer { } return { status: 200, - body: this.database.updateOnly(name, ctx.requestBody), + body: this.database.updateOnly( + name, + context.requestBody, + ), headers: { 'Content-Type': 'application/json', }, @@ -155,9 +149,9 @@ export class BaseServer { }; } } - if (ctx.method === 'PATCH') { + if (context.method === 'PATCH') { try { - if (ctx.requestBody == null) { + if (context.requestBody == null) { return { status: 400, headers: {}, @@ -165,7 +159,10 @@ export class BaseServer { } return { status: 200, - body: this.database.updateOnly(name, ctx.requestBody), + body: this.database.updateOnly( + name, + context.requestBody, + ), headers: { 'Content-Type': 'application/json', }, @@ -180,16 +177,20 @@ export class BaseServer { } // handle collections - const matches = ctx.url?.match( + const matches = context.url?.match( new RegExp(`^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w))?(\\?.*)?$`), ); if (!matches) { return { status: 404, headers: {} }; } const name = matches[1]; - const params = Object.assign({}, this.defaultQuery(name), ctx.params); + const params = Object.assign( + {}, + this.defaultQuery(name), + context.params, + ); if (!matches[2]) { - if (ctx.method === 'GET') { + if (context.method === 'GET') { if (!this.database.getCollection(name)) { return { status: 404, headers: {} }; } @@ -227,15 +228,18 @@ export class BaseServer { }, }; } - if (ctx.method === 'POST') { - if (ctx.requestBody == null) { + if (context.method === 'POST') { + if (context.requestBody == null) { return { status: 400, headers: {}, }; } - const newResource = this.database.addOne(name, ctx.requestBody); + const newResource = this.database.addOne( + name, + context.requestBody, + ); const newResourceURI = `${this.baseUrl}/${name}/${ newResource[ this.database.getCollection(name).identifierName @@ -256,7 +260,7 @@ export class BaseServer { return { status: 404, headers: {} }; } const id = Number.parseInt(matches[3]); - if (ctx.method === 'GET') { + if (context.method === 'GET') { try { return { status: 200, @@ -272,9 +276,9 @@ export class BaseServer { }; } } - if (ctx.method === 'PUT') { + if (context.method === 'PUT') { try { - if (ctx.requestBody == null) { + if (context.requestBody == null) { return { status: 400, headers: {}, @@ -285,7 +289,7 @@ export class BaseServer { body: this.database.updateOne( name, id, - ctx.requestBody, + context.requestBody, ), headers: { 'Content-Type': 'application/json', @@ -298,9 +302,9 @@ export class BaseServer { }; } } - if (ctx.method === 'PATCH') { + if (context.method === 'PATCH') { try { - if (ctx.requestBody == null) { + if (context.requestBody == null) { return { status: 400, headers: {}, @@ -311,7 +315,7 @@ export class BaseServer { body: this.database.updateOne( name, id, - ctx.requestBody, + context.requestBody, ), headers: { 'Content-Type': 'application/json', @@ -324,7 +328,7 @@ export class BaseServer { }; } } - if (ctx.method === 'DELETE') { + if (context.method === 'DELETE') { try { return { status: 200, @@ -347,7 +351,7 @@ export class BaseServer { }; } - addMiddleware(middleware: Middleware) { + addMiddleware(middleware: Middleware) { this.middlewares.push(middleware); } @@ -382,38 +386,15 @@ export class BaseServer { } } -export type Middleware = ( - request: RequestType, +export type Middleware = ( context: FakeRestContext, - next: ( - req: RequestType, - ctx: FakeRestContext, - ) => Promise | BaseResponse | null, -) => Promise | BaseResponse | null; + next: (context: FakeRestContext) => Promise | BaseResponse, +) => Promise | BaseResponse; export type BaseServerOptions = DatabaseOptions & { database?: Database; baseUrl?: string; batchUrl?: string | null; defaultQuery?: QueryFunction; + middlewares?: Array; }; - -export type BaseResponse = { - 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/adapters/FetchMockServer.ts b/src/adapters/FetchMockAdapter.ts similarity index 62% rename from src/adapters/FetchMockServer.ts rename to src/adapters/FetchMockAdapter.ts index 0eb3520..487cad6 100644 --- a/src/adapters/FetchMockServer.ts +++ b/src/adapters/FetchMockAdapter.ts @@ -1,28 +1,35 @@ -import type { MockResponseObject } from 'fetch-mock'; -import { - type BaseResponse, - BaseServer, - type FakeRestContext, - type BaseServerOptions, -} from '../BaseServer.js'; +import { SimpleRestServer } from '../SimpleRestServer.js'; import { parseQueryString } from '../parseQueryString.js'; +import type { BaseServerOptions } from '../SimpleRestServer.js'; +import type { BaseResponse, APIServer, NormalizedRequest } from '../types.js'; +import type { MockResponseObject } from 'fetch-mock'; -export class FetchMockServer extends BaseServer { +export class FetchMockAdapter { loggingEnabled = false; + server: APIServer; constructor({ loggingEnabled = false, + server, ...options - }: FetchMockServerOptions = {}) { - super(options); + }: FetchMockAdapterOptions = {}) { + this.server = server || new SimpleRestServer(options); this.loggingEnabled = loggingEnabled; } - toggleLogging() { - this.loggingEnabled = !this.loggingEnabled; + getHandler() { + const handler = async (url: string, options: RequestInit) => { + const request = new Request(url, options); + const normalizedRequest = await this.getNormalizedRequest(request); + const response = await this.server.handle(normalizedRequest); + this.log(request, response, normalizedRequest); + return response as MockResponseObject; + }; + + return handler; } - async getNormalizedRequest(request: Request) { + async getNormalizedRequest(request: Request): Promise { const req = typeof request === 'string' ? new Request(request) : request; const queryString = req.url @@ -39,32 +46,28 @@ export class FetchMockServer extends BaseServer { return { url: req.url, + headers: req.headers, params, requestBody, method: req.method, }; } - async respond( - response: BaseResponse, - request: FetchMockFakeRestRequest, - context: FakeRestContext, - ) { - this.log(request, response, context); - return response; - } - log( request: FetchMockFakeRestRequest, - response: MockResponseObject, - context: FakeRestContext, + response: BaseResponse, + normalizedRequest: NormalizedRequest, ) { if (!this.loggingEnabled) return; if (console.group) { // Better logging in Chrome - console.groupCollapsed(context.method, context.url, '(FakeRest)'); + console.groupCollapsed( + normalizedRequest.method, + normalizedRequest.url, + '(FakeRest)', + ); console.group('request'); - console.log(context.method, context.url); + console.log(normalizedRequest.method, normalizedRequest.url); console.log('headers', request.headers); console.log('body ', request.requestJson); console.groupEnd(); @@ -76,8 +79,8 @@ export class FetchMockServer extends BaseServer { } else { console.log( 'FakeRest request ', - context.method, - context.url, + normalizedRequest.method, + normalizedRequest.url, 'headers', request.headers, 'body', @@ -94,24 +97,20 @@ export class FetchMockServer extends BaseServer { } } - getHandler() { - const handler = (url: string, options: RequestInit) => { - return this.handle(new Request(url, options)); - }; - - return handler; + toggleLogging() { + this.loggingEnabled = !this.loggingEnabled; } } -export const getFetchMockHandler = (options: FetchMockServerOptions) => { - const server = new FetchMockServer(options); +export const getFetchMockHandler = (options: FetchMockAdapterOptions) => { + const server = new FetchMockAdapter(options); return server.getHandler(); }; /** * @deprecated Use FetchServer instead */ -export const FetchServer = FetchMockServer; +export const FetchServer = FetchMockAdapter; export type FetchMockFakeRestRequest = Partial & { requestBody?: string; @@ -121,6 +120,7 @@ export type FetchMockFakeRestRequest = Partial & { params?: { [key: string]: any }; }; -export type FetchMockServerOptions = BaseServerOptions & { +export type FetchMockAdapterOptions = BaseServerOptions & { + server?: APIServer; loggingEnabled?: boolean; }; diff --git a/src/adapters/MswAdapter.ts b/src/adapters/MswAdapter.ts new file mode 100644 index 0000000..9d334fb --- /dev/null +++ b/src/adapters/MswAdapter.ts @@ -0,0 +1,61 @@ +import { http, HttpResponse } from 'msw'; +import { SimpleRestServer } from '../SimpleRestServer.js'; +import type { BaseServerOptions } from '../SimpleRestServer.js'; +import type { APIServer, NormalizedRequest } from '../types.js'; + +export class MswAdapter { + server: APIServer; + + constructor({ server, ...options }: MswAdapterOptions) { + this.server = server || new SimpleRestServer(options); + } + + getHandler() { + return http.all( + // Using a regex ensures we match all URLs that start with the collection name + new RegExp(`${this.server.baseUrl}`), + async ({ request }) => { + const normalizedRequest = + await this.getNormalizedRequest(request); + const response = await this.server.handle(normalizedRequest); + return HttpResponse.json(response.body, { + status: response.status, + headers: response.headers, + }); + }, + ); + } + + async getNormalizedRequest(request: Request): Promise { + 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, + headers: request.headers, + params, + requestBody, + method: request.method, + }; + } +} + +export const getMswHandler = (options: MswAdapterOptions) => { + const server = new MswAdapter(options); + return server.getHandler(); +}; + +export type MswAdapterOptions = BaseServerOptions & { + server?: APIServer; +}; diff --git a/src/adapters/MswServer.ts b/src/adapters/MswServer.ts deleted file mode 100644 index 6b263de..0000000 --- a/src/adapters/MswServer.ts +++ /dev/null @@ -1,48 +0,0 @@ -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/adapters/SinonServer.spec.ts b/src/adapters/SinonAdapter.spec.ts similarity index 68% rename from src/adapters/SinonServer.spec.ts rename to src/adapters/SinonAdapter.spec.ts index ed9012e..41e1fae 100644 --- a/src/adapters/SinonServer.spec.ts +++ b/src/adapters/SinonAdapter.spec.ts @@ -1,9 +1,7 @@ import sinon, { type SinonFakeXMLHttpRequest } from 'sinon'; -import { SinonServer } from './SinonServer.js'; -import { Single } from '../Single.js'; -import { Collection } from '../Collection.js'; -import type { BaseResponse } from '../BaseServer.js'; +import { SinonAdapter } from './SinonAdapter.js'; +import type { BaseResponse } from '../types.js'; function getFakeXMLHTTPRequest( method: string, @@ -25,34 +23,35 @@ function getFakeXMLHTTPRequest( describe('SinonServer', () => { describe('addMiddleware', () => { it('should allow request transformation', async () => { - const server = new SinonServer(); - server.addMiddleware((request, context, next) => { - const start = context.params?._start - ? context.params._start - 1 - : 0; - const end = - context.params?._end !== undefined - ? context.params._end - 1 - : 19; - if (!context.params) { - context.params = {}; - } - context.params.range = [start, end]; - return next(request, context); - }); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonAdapter({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + middlewares: [ + (context, next) => { + const start = context.params?._start + ? context.params._start - 1 + : 0; + const end = + context.params?._end !== undefined + ? context.params._end - 1 + : 19; + if (!context.params) { + context.params = {}; + } + context.params.range = [start, end]; + return next(context); + }, + ], + }); + const handle = server.getHandler(); let request: SinonFakeXMLHttpRequest | null; request = getFakeXMLHTTPRequest('GET', '/foo?_start=1&_end=1'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request?.status).toEqual(206); // @ts-ignore expect(request.responseText).toEqual('[{"id":1,"name":"foo"}]'); @@ -61,7 +60,7 @@ describe('SinonServer', () => { ); request = getFakeXMLHTTPRequest('GET', '/foo?_start=2&_end=2'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request?.status).toEqual(206); // @ts-ignore expect(request?.responseText).toEqual('[{"id":2,"name":"bar"}]'); @@ -71,79 +70,64 @@ describe('SinonServer', () => { }); it('should allow response transformation', async () => { - const server = new SinonServer(); - server.addMiddleware((request, context, next) => { - const response = next(request, context); - (response as BaseResponse).status = 418; - return response; - }); - server.addMiddleware((request, context, next) => { - const response = next(request, context) as BaseResponse; - response.body = { - data: response.body, - status: response.status, - }; - return response; - }); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonAdapter({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + middlewares: [ + (context, next) => { + const response = next(context); + (response as BaseResponse).status = 418; + return response; + }, + (context, next) => { + const response = next(context) as BaseResponse; + response.body = { + data: response.body, + status: response.status, + }; + return response; + }, + ], + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(418); // @ts-ignore expect(request.responseText).toEqual( '{"data":[{"id":1,"name":"foo"},{"id":2,"name":"bar"}],"status":200}', ); }); - - it('should pass request in response interceptor', async () => { - const server = new SinonServer(); - let requestUrl: string | undefined; - server.addMiddleware((request, context, next) => { - requestUrl = request.url; - return next(request, context); - }); - server.addCollection('foo', new Collection()); - - const request = getFakeXMLHTTPRequest('GET', '/foo'); - if (request == null) throw new Error('request is null'); - await server.handle(request); - - expect(requestUrl).toEqual('/foo'); - }); }); describe('handle', () => { it('should respond a 404 to GET /whatever on non existing collection', async () => { - const server = new SinonServer(); + const server = new SinonAdapter(); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(404); // not responded }); it('should respond to GET /foo by sending all items in collection foo', async () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonAdapter({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual( @@ -158,27 +142,23 @@ describe('SinonServer', () => { }); it('should respond to GET /foo?queryString by sending all items in collection foo satisfying query', async () => { - const server = new SinonServer(); - server.addCollection( - 'foos', - new Collection({ - items: [ + const server = new SinonAdapter({ + data: { + foos: [ { id: 0, name: 'c', arg: false }, { id: 1, name: 'b', arg: true }, { id: 2, name: 'a', arg: true }, ], - }), - ); - server.addCollection( - 'bars', - new Collection({ items: [{ id: 0, name: 'a', foo_id: 1 }] }), - ); + bars: [{ id: 0, name: 'a', foo_id: 1 }], + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'GET', '/foos?filter={"arg":true}&sort=name&slice=[0,10]&embed=["bars"]', ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual( @@ -193,38 +173,35 @@ describe('SinonServer', () => { }); it('should respond to GET /foo?queryString with pagination by sending the correct content-range header', async () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}], - }), - ); // 11 items + const server = new SinonAdapter({ + data: { foo: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}] }, // 11 items + }); + const handle = server.getHandler(); let request: SinonFakeXMLHttpRequest | null; request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-10/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-4/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[5,9]'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 5-9/11', ); request = getFakeXMLHTTPRequest('GET', '/foo?range=[10,14]'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 10-10/11', @@ -232,11 +209,13 @@ describe('SinonServer', () => { }); it('should respond to GET /foo on an empty collection with a []', async () => { - const server = new SinonServer(); - server.addCollection('foo', new Collection()); + const server = new SinonAdapter({ + data: { foo: [] }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('[]'); @@ -246,23 +225,22 @@ describe('SinonServer', () => { }); it('should respond to POST /foo by adding an item to collection foo', async () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonAdapter({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'POST', '/foo', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(201); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz","id":3}'); @@ -270,7 +248,8 @@ describe('SinonServer', () => { 'application/json', ); expect(request.getResponseHeader('Location')).toEqual('/foo/3'); - expect(server.database.getAll('foo')).toEqual([ + // @ts-ignore + expect(server.server.database.getAll('foo')).toEqual([ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, { id: 3, name: 'baz' }, @@ -278,14 +257,15 @@ describe('SinonServer', () => { }); it('should respond to POST /foo by adding an item to collection foo, even if the collection does not exist', async () => { - const server = new SinonServer(); + const server = new SinonAdapter(); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'POST', '/foo', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(201); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz","id":0}'); @@ -293,25 +273,25 @@ describe('SinonServer', () => { 'application/json', ); expect(request.getResponseHeader('Location')).toEqual('/foo/0'); - expect(server.database.getAll('foo')).toEqual([ + // @ts-ignore + expect(server.server.database.getAll('foo')).toEqual([ { id: 0, name: 'baz' }, ]); }); it('should respond to GET /foo/:id by sending element of identifier id in collection foo', async () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonAdapter({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo/2'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"bar"}'); @@ -321,141 +301,142 @@ describe('SinonServer', () => { }); it('should respond to GET /foo/:id on a non-existing id with a 404', async () => { - const server = new SinonServer(); - server.addCollection('foo', new Collection()); + const server = new SinonAdapter({ data: { foo: [] } }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo/3'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(404); }); it('should respond to PUT /foo/:id by updating element of identifier id in collection foo', async () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonAdapter({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PUT', '/foo/2', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.database.getAll('foo')).toEqual([ + // @ts-ignore + expect(server.server.database.getAll('foo')).toEqual([ { id: 1, name: 'foo' }, { id: 2, name: 'baz' }, ]); }); 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 server = new SinonAdapter(); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PUT', '/foo/3', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(404); }); it('should respond to PATCH /foo/:id by updating element of identifier id in collection foo', async () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonAdapter({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PATCH', '/foo/2', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.database.getAll('foo')).toEqual([ + // @ts-ignore + expect(server.server.database.getAll('foo')).toEqual([ { id: 1, name: 'foo' }, { id: 2, name: 'baz' }, ]); }); it('should respond to PATCH /foo/:id on a non-existing id with a 404', async () => { - const server = new SinonServer(); - server.addCollection('foo', new Collection({ items: [] })); + const server = new SinonAdapter({ data: { foo: [] } }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PATCH', '/foo/3', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(404); }); it('should respond to DELETE /foo/:id by removing element of identifier id in collection foo', async () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [ + const server = new SinonAdapter({ + data: { + foo: [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' }, ], - }), - ); + }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('DELETE', '/foo/2'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"id":2,"name":"bar"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.database.getAll('foo')).toEqual([ + // @ts-ignore + expect(server.server.database.getAll('foo')).toEqual([ { id: 1, name: 'foo' }, ]); }); it('should respond to DELETE /foo/:id on a non-existing id with a 404', async () => { - const server = new SinonServer(); - server.addCollection('foo', new Collection({ items: [] })); + const server = new SinonAdapter({ data: { foo: [] } }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('DELETE', '/foo/3'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(404); }); it('should respond to GET /foo/ with single item', async () => { - const server = new SinonServer(); - server.addSingle('foo', new Single({ name: 'foo' })); - + const server = new SinonAdapter({ + data: { foo: { name: 'foo' } }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"name":"foo"}'); @@ -465,61 +446,64 @@ describe('SinonServer', () => { }); it('should respond to PUT /foo/ by updating the singleton record', async () => { - const server = new SinonServer(); - server.addSingle('foo', new Single({ name: 'foo' })); - + const server = new SinonAdapter({ + data: { foo: { name: 'foo' } }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PUT', '/foo/', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.database.getOnly('foo')).toEqual({ name: 'baz' }); + // @ts-ignore + expect(server.server.database.getOnly('foo')).toEqual({ + name: 'baz', + }); }); it('should respond to PATCH /foo/ by updating the singleton record', async () => { - const server = new SinonServer(); - server.addSingle('foo', new Single({ name: 'foo' })); - + const server = new SinonAdapter({ + data: { foo: { name: 'foo' } }, + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest( 'PATCH', '/foo/', JSON.stringify({ name: 'baz' }), ); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(200); // @ts-ignore expect(request.responseText).toEqual('{"name":"baz"}'); expect(request.getResponseHeader('Content-Type')).toEqual( 'application/json', ); - expect(server.database.getOnly('foo')).toEqual({ name: 'baz' }); + // @ts-ignore + expect(server.server.database.getOnly('foo')).toEqual({ + name: 'baz', + }); }); }); describe('setDefaultQuery', () => { it('should set the default query string', async () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}], - }), - ); // 10 items - server.setDefaultQuery(() => { - return { range: [2, 4] }; + const server = new SinonAdapter({ + data: { foo: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}] }, // 10 items + defaultQuery: () => ({ range: [2, 4] }), }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 2-4/10', @@ -530,17 +514,14 @@ describe('SinonServer', () => { }); it('should not override any provided query string', async () => { - const server = new SinonServer(); - server.addCollection( - 'foo', - new Collection({ - items: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}], - }), - ); // 10 items - server.setDefaultQuery((name) => ({ range: [2, 4] })); + const server = new SinonAdapter({ + data: { foo: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}] }, // 10 items + defaultQuery: () => ({ range: [2, 4] }), + }); + const handle = server.getHandler(); const request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]'); if (request == null) throw new Error('request is null'); - await server.handle(request); + await handle(request); expect(request.status).toEqual(206); expect(request.getResponseHeader('Content-Range')).toEqual( 'items 0-4/10', diff --git a/src/adapters/SinonServer.ts b/src/adapters/SinonAdapter.ts similarity index 81% rename from src/adapters/SinonServer.ts rename to src/adapters/SinonAdapter.ts index 1e38b5b..23121a8 100644 --- a/src/adapters/SinonServer.ts +++ b/src/adapters/SinonAdapter.ts @@ -1,30 +1,39 @@ import type { SinonFakeXMLHttpRequest } from 'sinon'; import { - type BaseResponse, - BaseServer, + SimpleRestServer, type BaseServerOptions, -} from '../BaseServer.js'; +} from '../SimpleRestServer.js'; import { parseQueryString } from '../parseQueryString.js'; +import type { BaseResponse, APIServer, NormalizedRequest } from '../types.js'; -export class SinonServer extends BaseServer< - SinonFakeXMLHttpRequest, - SinonFakeRestResponse -> { +export class SinonAdapter { loggingEnabled = false; + server: APIServer; constructor({ loggingEnabled = false, + server, ...options - }: SinonServerOptions = {}) { - super(options); + }: SinonAdapterOptions = {}) { + this.server = server || new SimpleRestServer(options); this.loggingEnabled = loggingEnabled; } - toggleLogging() { - this.loggingEnabled = !this.loggingEnabled; + getHandler() { + return async (request: SinonFakeXMLHttpRequest) => { + // This is an internal property of SinonFakeXMLHttpRequest but we have to set it to 4 to + // suppress sinon's synchronous processing (which would result in HTTP 404). This allows us + // to handle the request asynchronously. + // See https://github.com/sinonjs/sinon/issues/637 + // @ts-expect-error + request.readyState = 4; + const normalizedRequest = this.getNormalizedRequest(request); + const response = await this.server.handle(normalizedRequest); + this.respond(response, request); + }; } - async getNormalizedRequest(request: SinonFakeXMLHttpRequest) { + getNormalizedRequest(request: SinonFakeXMLHttpRequest): NormalizedRequest { const req: Request | SinonFakeXMLHttpRequest = typeof request === 'string' ? new Request(request) : request; @@ -45,13 +54,14 @@ export class SinonServer extends BaseServer< return { url: req.url, + headers: new Headers(request.requestHeaders), params, requestBody, method: req.method, }; } - async respond(response: BaseResponse, request: SinonFakeXMLHttpRequest) { + respond(response: BaseResponse, request: SinonFakeXMLHttpRequest) { const sinonResponse = { status: response.status, body: response.body ?? '', @@ -91,12 +101,6 @@ export class SinonServer extends BaseServer< ); this.log(request, sinonResponse); - - return { - status: response.status, - body: response.body, - headers: response.headers, - }; } log(request: SinonFakeXMLHttpRequest, response: SinonFakeRestResponse) { @@ -135,30 +139,20 @@ export class SinonServer extends BaseServer< } } - getHandler() { - return (request: SinonFakeXMLHttpRequest) => { - // This is an internal property of SinonFakeXMLHttpRequest but we have to set it to 4 to - // suppress sinon's synchronous processing (which would result in HTTP 404). This allows us - // to handle the request asynchronously. - // See https://github.com/sinonjs/sinon/issues/637 - // @ts-expect-error - request.readyState = 4; - this.handle(request); - // Let Sinon know we've handled the request - return true; - }; + toggleLogging() { + this.loggingEnabled = !this.loggingEnabled; } } -export const getSinonHandler = (options: SinonServerOptions) => { - const server = new SinonServer(options); +export const getSinonHandler = (options: SinonAdapterOptions) => { + const server = new SinonAdapter(options); return server.getHandler(); }; /** * @deprecated Use SinonServer instead */ -export const Server = SinonServer; +export const Server = SinonAdapter; export type SinonFakeRestResponse = { status: number; @@ -166,6 +160,7 @@ export type SinonFakeRestResponse = { headers: Record; }; -export type SinonServerOptions = BaseServerOptions & { +export type SinonAdapterOptions = BaseServerOptions & { + server?: APIServer; loggingEnabled?: boolean; }; diff --git a/src/index.ts b/src/index.ts index 4a7c59d..f13cc7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,48 +1,9 @@ -import { - getSinonHandler, - Server, - SinonServer, -} from './adapters/SinonServer.js'; -import { - getFetchMockHandler, - FetchServer, - FetchMockServer, -} from './adapters/FetchMockServer.js'; -import { getMswHandler, MswServer } from './adapters/MswServer.js'; -import { Database } from './Database.js'; -import { BaseServer } from './BaseServer.js'; -import { Collection } from './Collection.js'; -import { Single } from './Single.js'; -import { withDelay } from './withDelay.js'; - -export { - BaseServer, - Database, - getSinonHandler, - getFetchMockHandler, - getMswHandler, - Server, - SinonServer, - FetchServer, - FetchMockServer, - MswServer, - Collection, - Single, - withDelay, -}; - -export default { - BaseServer, - Database, - getSinonHandler, - getFetchMockHandler, - getMswHandler, - Server, - SinonServer, - FetchServer, - FetchMockServer, - MswServer, - Collection, - Single, - withDelay, -}; +export * from './types.js'; +export * from './adapters/SinonAdapter.js'; +export * from './adapters/FetchMockAdapter.js'; +export * from './adapters/MswAdapter.js'; +export * from './Database.js'; +export * from './SimpleRestServer.js'; +export * from './Collection.js'; +export * from './Single.js'; +export * from './withDelay.js'; diff --git a/src/types.ts b/src/types.ts index 6df14e1..465df28 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,3 +31,29 @@ export type Predicate = ( ) => boolean; export type Embed = string | string[]; + +export type BaseResponse = { + status: number; + body?: Record | Record[]; + headers: { [key: string]: string }; +}; + +export type FakeRestContext = { + url?: string; + headers?: Headers; + method?: string; + collection?: string; + single?: string; + requestBody: Record | undefined; + params: { [key: string]: any }; +}; + +export type NormalizedRequest = Pick< + FakeRestContext, + 'url' | 'method' | 'params' | 'requestBody' | 'headers' +>; + +export type APIServer = { + baseUrl?: string; + handle: (context: FakeRestContext) => Promise; +}; diff --git a/src/withDelay.ts b/src/withDelay.ts index dd22c1e..fed5bc3 100644 --- a/src/withDelay.ts +++ b/src/withDelay.ts @@ -1,11 +1,11 @@ -import type { Middleware } from './BaseServer.js'; +import type { Middleware } from './SimpleRestServer.js'; export const withDelay = - (delayMs: number): Middleware => - (request, context, next) => { + (delayMs: number): Middleware => + (context, next) => { return new Promise((resolve) => { setTimeout(() => { - resolve(next(request, context)); + resolve(next(context)); }, delayMs); }); }; diff --git a/vite.config.ts b/vite.config.ts index 9ac7305..27f1ecc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ // the proper extensions will be added fileName: 'fakerest', }, + minify: false, sourcemap: true, rollupOptions: { // make sure to externalize deps that shouldn't be bundled