├── CLAUDE.md
├── .gitignore
├── .prettierrc
├── .husky
└── pre-commit
├── example
├── vite-env.d.ts
├── data.ts
├── dataProvider.ts
├── fetchMock.ts
├── msw.ts
├── middlewares.ts
├── index.tsx
├── authProvider.ts
├── sinon.ts
├── App.tsx
└── users.json
├── src
├── withDelay.ts
├── index.ts
├── parseQueryString.ts
├── types.ts
├── adapters
│ ├── MswAdapter.ts
│ ├── FetchMockAdapter.ts
│ ├── SinonAdapter.ts
│ └── SinonAdapter.spec.ts
├── Single.ts
├── Database.ts
├── Database.spec.ts
├── Single.spec.ts
├── SimpleRestServer.spec.ts
├── SimpleRestServer.ts
└── Collection.ts
├── tsconfig.json
├── Makefile
├── biome.json
├── LICENSE
├── vite.config.min.ts
├── vite.config.ts
├── .github
└── workflows
│ └── ci.yml
├── package.json
├── UPGRADE.md
├── AGENTS.md
├── index.html
├── public
└── mockServiceWorker.js
└── README.md
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | @AGENTS.md
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4,
3 | "singleQuote": true
4 | }
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
--------------------------------------------------------------------------------
/example/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | readonly VITE_MOCK: 'msw' | 'fetch-mock' | 'sinon';
5 | }
6 |
7 | interface ImportMeta {
8 | readonly env: ImportMetaEnv;
9 | }
10 |
--------------------------------------------------------------------------------
/src/withDelay.ts:
--------------------------------------------------------------------------------
1 | import type { Middleware } from './SimpleRestServer.ts';
2 |
3 | export const withDelay =
4 | (delayMs: number): Middleware =>
5 | (context, next) => {
6 | return new Promise((resolve) => {
7 | setTimeout(() => {
8 | resolve(next(context));
9 | }, delayMs);
10 | });
11 | };
12 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './types.ts';
2 | export * from './adapters/SinonAdapter.ts';
3 | export * from './adapters/FetchMockAdapter.ts';
4 | export * from './adapters/MswAdapter.ts';
5 | export * from './Database.ts';
6 | export * from './SimpleRestServer.ts';
7 | export * from './Collection.ts';
8 | export * from './Single.ts';
9 | export * from './withDelay.ts';
10 |
--------------------------------------------------------------------------------
/example/data.ts:
--------------------------------------------------------------------------------
1 | export const data = {
2 | authors: [
3 | { id: 0, first_name: 'Leo', last_name: 'Tolstoi' },
4 | { id: 1, first_name: 'Jane', last_name: 'Austen' },
5 | ],
6 | books: [
7 | { id: 0, author_id: 0, title: 'Anna Karenina' },
8 | { id: 1, author_id: 0, title: 'War and Peace' },
9 | { id: 2, author_id: 1, title: 'Pride and Prejudice' },
10 | { id: 3, author_id: 1, title: 'Sense and Sensibility' },
11 | ],
12 | settings: {
13 | language: 'english',
14 | preferred_format: 'hardback',
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "rootDir": "src",
4 | "outDir": "./dist",
5 | "noEmit": true,
6 | "declaration": true,
7 | "strict": true,
8 | "esModuleInterop": true,
9 | "module": "node16",
10 | "moduleResolution": "node16",
11 | "moduleDetection": "force",
12 | "target": "ES2022", // Node.js 18
13 | "types": ["vitest/globals"],
14 | "jsx": "react-jsx",
15 | "allowImportingTsExtensions": true,
16 | "skipLibCheck": true
17 | },
18 | "include": ["src"],
19 | "exclude": ["node_modules/**"]
20 | }
21 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build test
2 |
3 | install:
4 | @npm install
5 |
6 | build-dev:
7 | @NODE_ENV=development npm run build
8 |
9 | build:
10 | @NODE_ENV=production npm run build
11 |
12 | run: run-msw
13 |
14 | run-msw:
15 | @NODE_ENV=development VITE_MOCK=msw npm run dev
16 |
17 | run-fetch-mock:
18 | @NODE_ENV=development VITE_MOCK=fetch-mock npm run dev
19 |
20 | run-sinon:
21 | @NODE_ENV=development VITE_MOCK=sinon npm run dev
22 |
23 | watch:
24 | @NODE_ENV=development npm run build --watch
25 |
26 | test:
27 | @npm run test
28 |
29 | format:
30 | @npm run format
31 |
32 | lint:
33 | @npm run lint
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.7.0/schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "formatter": {
7 | "enabled": true,
8 | "indentStyle": "space",
9 | "indentWidth": 4,
10 | "ignore": ["public/*.js"]
11 | },
12 | "javascript": {
13 | "formatter": {
14 | "quoteStyle": "single"
15 | }
16 | },
17 | "linter": {
18 | "enabled": true,
19 | "rules": {
20 | "recommended": true,
21 | "suspicious": {
22 | "noExplicitAny": "off"
23 | }
24 | },
25 | "ignore": ["public/*.js"]
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/example/dataProvider.ts:
--------------------------------------------------------------------------------
1 | import simpleRestProvider from 'ra-data-simple-rest';
2 | import { fetchUtils } from 'react-admin';
3 |
4 | const httpClient = (url: string, options: any = {}) => {
5 | if (!options.headers) {
6 | options.headers = new Headers({ Accept: 'application/json' });
7 | }
8 | const persistedUser = localStorage.getItem('user');
9 | const user = persistedUser ? JSON.parse(persistedUser) : null;
10 | if (user) {
11 | options.headers.set('Authorization', `Bearer ${user.id}`);
12 | }
13 | return fetchUtils.fetchJson(url, options);
14 | };
15 |
16 | export const dataProvider = simpleRestProvider(
17 | 'http://localhost:3000',
18 | httpClient,
19 | );
20 |
--------------------------------------------------------------------------------
/src/parseQueryString.ts:
--------------------------------------------------------------------------------
1 | export function parseQueryString(queryString: string) {
2 | if (!queryString) {
3 | return {};
4 | }
5 | const queryObject: Record = {};
6 | const queryElements = queryString.split('&');
7 |
8 | queryElements.map((queryElement) => {
9 | if (queryElement.indexOf('=') === -1) {
10 | queryObject[queryElement] = true;
11 | } else {
12 | let [key, value] = queryElement.split('=');
13 | if (value.indexOf('[') === 0 || value.indexOf('{') === 0) {
14 | value = JSON.parse(value);
15 | }
16 | queryObject[key.trim()] = value;
17 | }
18 | });
19 |
20 | return queryObject;
21 | }
22 |
--------------------------------------------------------------------------------
/example/fetchMock.ts:
--------------------------------------------------------------------------------
1 | import fetchMock from 'fetch-mock';
2 | import { FetchMockAdapter } from '../src';
3 | import { data } from './data';
4 | import { dataProvider as defaultDataProvider } from './dataProvider';
5 | import { middlewares } from './middlewares';
6 |
7 | export const initializeFetchMock = () => {
8 | const restServer = new FetchMockAdapter({
9 | baseUrl: 'http://localhost:3000',
10 | data,
11 | loggingEnabled: true,
12 | middlewares,
13 | });
14 | if (window) {
15 | // @ts-ignore
16 | window.restServer = restServer; // give way to update data in the console
17 | }
18 |
19 | fetchMock.mock('begin:http://localhost:3000', restServer.getHandler());
20 | };
21 |
22 | export const dataProvider = defaultDataProvider;
23 |
--------------------------------------------------------------------------------
/example/msw.ts:
--------------------------------------------------------------------------------
1 | import { http } from 'msw';
2 | import { setupWorker } from 'msw/browser';
3 | import { getMswHandler } from '../src';
4 | import { data } from './data';
5 | import { dataProvider as defaultDataProvider } from './dataProvider';
6 | import { middlewares } from './middlewares';
7 |
8 | export const initializeMsw = async () => {
9 | const handler = getMswHandler({
10 | baseUrl: 'http://localhost:3000',
11 | data,
12 | middlewares,
13 | });
14 | const worker = setupWorker(http.all(/http:\/\/localhost:3000/, handler));
15 | return worker.start({
16 | quiet: true, // Instruct MSW to not log requests in the console
17 | onUnhandledRequest: 'bypass', // Instruct MSW to ignore requests we don't handle
18 | });
19 | };
20 |
21 | export const dataProvider = defaultDataProvider;
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2015 marmelab
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/vite.config.min.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { resolve } from 'node:path';
3 | import { defineConfig } from 'vite';
4 |
5 | export default defineConfig({
6 | build: {
7 | lib: {
8 | // Could also be a dictionary or array of multiple entry points
9 | entry: resolve(__dirname, 'src/index.ts'),
10 | name: 'FakeRest',
11 | // the proper extensions will be added
12 | fileName: 'fakerest.min',
13 | },
14 | minify: true,
15 | sourcemap: true,
16 | emptyOutDir: false,
17 | rollupOptions: {
18 | // make sure to externalize deps that shouldn't be bundled
19 | // into your library
20 | external: ['lodash'],
21 | output: {
22 | // Provide global variables to use in the UMD build
23 | // for externalized deps
24 | globals: {
25 | lodash: '_',
26 | },
27 | },
28 | },
29 | },
30 | test: {
31 | globals: true,
32 | environment: 'happy-dom',
33 | },
34 | });
35 |
--------------------------------------------------------------------------------
/example/middlewares.ts:
--------------------------------------------------------------------------------
1 | import { withDelay } from '../src';
2 | import { data } from './data';
3 |
4 | export const middlewares = [
5 | withDelay(300),
6 | async (context, next) => {
7 | if (!context.headers?.get('Authorization')) {
8 | return {
9 | status: 401,
10 | headers: {},
11 | };
12 | }
13 | return next(context);
14 | },
15 | async (context, next) => {
16 | if (context.collection === 'books' && context.method === 'POST') {
17 | if (
18 | data[context.collection].some(
19 | (book) => book.title === context.requestBody?.title,
20 | )
21 | ) {
22 | return {
23 | body: {
24 | errors: {
25 | title: 'An article with this title already exists. The title must be unique.',
26 | },
27 | },
28 | status: 400,
29 | headers: {},
30 | };
31 | }
32 | }
33 | return next(context);
34 | },
35 | ];
36 |
--------------------------------------------------------------------------------
/example/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import { App } from './App';
4 |
5 | const root = createRoot(document.getElementById('root'));
6 |
7 | switch (import.meta.env.VITE_MOCK) {
8 | case 'fetch-mock':
9 | import('./fetchMock')
10 | .then(({ initializeFetchMock, dataProvider }) => {
11 | initializeFetchMock();
12 | return dataProvider;
13 | })
14 | .then((dataProvider) => {
15 | root.render();
16 | });
17 | break;
18 | case 'sinon':
19 | import('./sinon')
20 | .then(({ initializeSinon, dataProvider }) => {
21 | initializeSinon();
22 | return dataProvider;
23 | })
24 | .then((dataProvider) => {
25 | root.render();
26 | });
27 | break;
28 | default:
29 | import('./msw')
30 | .then(({ initializeMsw, dataProvider }) => {
31 | return initializeMsw().then(() => dataProvider);
32 | })
33 | .then((dataProvider) => {
34 | root.render();
35 | });
36 | }
37 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { resolve } from 'node:path';
3 | import { defineConfig } from 'vite';
4 | import dts from 'vite-plugin-dts';
5 | import react from '@vitejs/plugin-react';
6 |
7 | export default defineConfig({
8 | build: {
9 | lib: {
10 | // Could also be a dictionary or array of multiple entry points
11 | entry: resolve(__dirname, 'src/index.ts'),
12 | name: 'FakeRest',
13 | // the proper extensions will be added
14 | fileName: 'fakerest',
15 | },
16 | minify: false,
17 | sourcemap: true,
18 | rollupOptions: {
19 | // make sure to externalize deps that shouldn't be bundled
20 | // into your library
21 | external: ['lodash'],
22 | output: {
23 | // Provide global variables to use in the UMD build
24 | // for externalized deps
25 | globals: {
26 | lodash: '_',
27 | },
28 | },
29 | },
30 | },
31 | plugins: [react(), dts()],
32 | test: {
33 | globals: true,
34 | environment: 'happy-dom',
35 | },
36 | resolve: {
37 | alias: {
38 | fakerest: resolve(__dirname, 'src/FakeRest.ts'),
39 | },
40 | },
41 | });
42 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: "Tests"
2 |
3 | on:
4 | push:
5 | paths-ignore:
6 | - 'docs/**'
7 | branches:
8 | - master
9 | - next
10 | pull_request:
11 | paths-ignore:
12 | - 'docs/**'
13 |
14 | jobs:
15 | lint:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v2
20 | - name: Use Node.js LTS
21 | uses: actions/setup-node@v1
22 | with:
23 | node-version: '18.x'
24 | - uses: bahmutov/npm-install@v1
25 | - name: Lint Check
26 | run: npx @biomejs/biome lint src
27 | - name: Format Check
28 | run: npx @biomejs/biome format src
29 | typecheck:
30 | runs-on: ubuntu-latest
31 | steps:
32 | - name: Checkout
33 | uses: actions/checkout@v2
34 | - name: Use Node.js LTS
35 | uses: actions/setup-node@v1
36 | with:
37 | node-version: '18.x'
38 | - uses: bahmutov/npm-install@v1
39 | - name: Typecheck
40 | run: npx tsc
41 | unit-test:
42 | runs-on: ubuntu-latest
43 | steps:
44 | - name: Checkout
45 | uses: actions/checkout@v2
46 | - name: Use Node.js LTS
47 | uses: actions/setup-node@v1
48 | with:
49 | node-version: '18.x'
50 | - uses: bahmutov/npm-install@v1
51 | - name: Unit Tests
52 | run: make test
53 | env:
54 | CI: true
55 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export type CollectionItem = { [key: string]: any };
2 |
3 | export type SortFunction = (
4 | a: T,
5 | b: T,
6 | ) => number;
7 | export type Sort = string | [string, 'asc' | 'desc'] | SortFunction;
8 |
9 | export type Range = [number, number] | [number];
10 |
11 | export type FilterFunction = (
12 | item: T,
13 | ) => boolean;
14 | export type FilterObject = CollectionItem & { q?: string };
15 | export type Filter = FilterObject | FilterFunction;
16 |
17 | export type Query = {
18 | filter?: Filter;
19 | sort?: Sort;
20 | range?: Range;
21 | embed?: Embed;
22 | };
23 |
24 | export type QueryFunction = (name: string) => Query;
25 |
26 | export type Predicate = (
27 | item: T,
28 | ) => boolean;
29 |
30 | export type Embed = string | string[];
31 |
32 | export type BaseResponse = {
33 | status: number;
34 | body?: Record | Record[];
35 | headers: { [key: string]: string };
36 | };
37 |
38 | export type FakeRestContext = {
39 | url?: string;
40 | headers?: Headers;
41 | method?: string;
42 | collection?: string;
43 | single?: string;
44 | requestBody: Record | undefined;
45 | params: { [key: string]: any };
46 | };
47 |
48 | export type NormalizedRequest = Pick<
49 | FakeRestContext,
50 | 'url' | 'method' | 'params' | 'requestBody' | 'headers'
51 | >;
52 |
53 | export type APIServer = {
54 | baseUrl?: string;
55 | handle: (context: FakeRestContext) => Promise;
56 | };
57 |
--------------------------------------------------------------------------------
/example/authProvider.ts:
--------------------------------------------------------------------------------
1 | import { type AuthProvider, HttpError } from 'react-admin';
2 | import data from './users.json';
3 |
4 | /**
5 | * This authProvider is only for test purposes. Don't use it in production.
6 | */
7 | export const authProvider: AuthProvider = {
8 | login: ({ username, password }) => {
9 | const user = data.users.find(
10 | (u) => u.username === username && u.password === password,
11 | );
12 |
13 | if (user) {
14 | const { password, ...userToPersist } = user;
15 | localStorage.setItem('user', JSON.stringify(userToPersist));
16 | return Promise.resolve();
17 | }
18 |
19 | return Promise.reject(
20 | new HttpError('Unauthorized', 401, {
21 | message: 'Invalid username or password',
22 | }),
23 | );
24 | },
25 | logout: () => {
26 | localStorage.removeItem('user');
27 | return Promise.resolve();
28 | },
29 | checkError: (error) => {
30 | const status = error.status;
31 | if (status === 401 || status === 403) {
32 | localStorage.removeItem('auth');
33 | return Promise.reject();
34 | }
35 | // other error code (404, 500, etc): no need to log out
36 | return Promise.resolve();
37 | },
38 | checkAuth: () =>
39 | localStorage.getItem('user') ? Promise.resolve() : Promise.reject(),
40 | getPermissions: () => {
41 | return Promise.resolve(undefined);
42 | },
43 | getIdentity: () => {
44 | const persistedUser = localStorage.getItem('user');
45 | const user = persistedUser ? JSON.parse(persistedUser) : null;
46 |
47 | return Promise.resolve(user);
48 | },
49 | };
50 |
51 | export default authProvider;
52 |
--------------------------------------------------------------------------------
/src/adapters/MswAdapter.ts:
--------------------------------------------------------------------------------
1 | import { SimpleRestServer } from '../SimpleRestServer.ts';
2 | import type { BaseServerOptions } from '../SimpleRestServer.ts';
3 | import type { APIServer, NormalizedRequest } from '../types.ts';
4 |
5 | export class MswAdapter {
6 | server: APIServer;
7 |
8 | constructor({ server, ...options }: MswAdapterOptions) {
9 | this.server = server || new SimpleRestServer(options);
10 | }
11 |
12 | getHandler() {
13 | return async ({ request }: { request: Request }) => {
14 | const normalizedRequest = await this.getNormalizedRequest(request);
15 | const response = await this.server.handle(normalizedRequest);
16 | return new Response(JSON.stringify(response.body), {
17 | status: response.status,
18 | headers: response.headers,
19 | });
20 | };
21 | }
22 |
23 | async getNormalizedRequest(request: Request): Promise {
24 | const url = new URL(request.url);
25 | const params = Object.fromEntries(
26 | Array.from(new URLSearchParams(url.search).entries()).map(
27 | ([key, value]) => [key, JSON.parse(value)],
28 | ),
29 | );
30 | let requestBody: Record | undefined = undefined;
31 | try {
32 | const text = await request.text();
33 | requestBody = JSON.parse(text);
34 | } catch (e) {
35 | // not JSON, no big deal
36 | }
37 |
38 | return {
39 | url: request.url,
40 | headers: request.headers,
41 | params,
42 | requestBody,
43 | method: request.method,
44 | };
45 | }
46 | }
47 |
48 | export const getMswHandler = (options: MswAdapterOptions) => {
49 | const server = new MswAdapter(options);
50 | return server.getHandler();
51 | };
52 |
53 | export type MswAdapterOptions = BaseServerOptions & {
54 | server?: APIServer;
55 | };
56 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fakerest",
3 | "version": "4.2.0",
4 | "repository": "https://github.com/marmelab/FakeRest",
5 | "description": "Patch XMLHttpRequest to fake a REST server based on JSON data. ",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build && vite build -c vite.config.min.ts",
9 | "format": "biome format --write src",
10 | "lint": "biome lint --apply src",
11 | "test": "vitest",
12 | "prepare": "husky"
13 | },
14 | "type": "module",
15 | "main": "dist/fakerest.min.umd.cjs",
16 | "module": "./dist/fakerest.min.js",
17 | "types": "./dist/index.d.ts",
18 | "exports": {
19 | ".": {
20 | "types": "./dist/index.d.ts",
21 | "import": "./dist/fakerest.min.js",
22 | "require": "./dist/fakerest.umd.cjs"
23 | }
24 | },
25 | "author": "François Zaninotto ",
26 | "license": "MIT",
27 | "devDependencies": {
28 | "@biomejs/biome": "1.7.0",
29 | "@types/lodash": "^4.17.0",
30 | "@types/sinon": "^17.0.3",
31 | "@vitejs/plugin-react": "^5.1.2",
32 | "fetch-mock": "^9.11.0",
33 | "happy-dom": "^20.0.0",
34 | "husky": "^9.0.11",
35 | "lint-staged": "^15.2.2",
36 | "msw": "^2.2.14",
37 | "ra-data-simple-rest": "^5.11.2",
38 | "react": "^18.0.0",
39 | "react-admin": "^5.11.2",
40 | "react-dom": "^18.0.0",
41 | "sinon": "~18.0.0",
42 | "typescript": "^5.4.5",
43 | "vite": "^7.3.0",
44 | "vite-plugin-dts": "^4.5.4",
45 | "vitest": "^4.0.15"
46 | },
47 | "dependencies": {
48 | "lodash": "^4.17.21"
49 | },
50 | "browserslist": "> 0.25%, not dead",
51 | "lint-staged": {
52 | "*.{js,jsx,ts,tsx}": [
53 | "biome lint --apply",
54 | "biome format --write"
55 | ]
56 | },
57 | "msw": {
58 | "workerDirectory": [
59 | "public"
60 | ]
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/example/sinon.ts:
--------------------------------------------------------------------------------
1 | import sinon from 'sinon';
2 | import simpleRestProvider from 'ra-data-simple-rest';
3 | import { HttpError, type Options } from 'react-admin';
4 | import { SinonAdapter } from '../src';
5 | import { data } from './data';
6 | import { middlewares } from './middlewares';
7 |
8 | export const initializeSinon = () => {
9 | const restServer = new SinonAdapter({
10 | baseUrl: 'http://localhost:3000',
11 | data,
12 | loggingEnabled: true,
13 | middlewares,
14 | });
15 |
16 | // use sinon.js to monkey-patch XmlHttpRequest
17 | const server = sinon.fakeServer.create();
18 | // this is required when doing asynchronous XmlHttpRequest
19 | server.autoRespond = true;
20 | if (window) {
21 | // @ts-ignore
22 | window.restServer = restServer; // give way to update data in the console
23 | // @ts-ignore
24 | window.sinonServer = server; // give way to update data in the console
25 | }
26 | server.respondWith(restServer.getHandler());
27 | };
28 |
29 | // An HttpClient based on XMLHttpRequest to use with Sinon
30 | const httpClient = (url: string, options: Options = {}): Promise => {
31 | const request = new XMLHttpRequest();
32 | request.open(options.method ?? 'GET', url);
33 |
34 | const persistedUser = localStorage.getItem('user');
35 | const user = persistedUser ? JSON.parse(persistedUser) : null;
36 | if (user) {
37 | request.setRequestHeader('Authorization', `Bearer ${user.id}`);
38 | }
39 |
40 | // add content-type header
41 | request.overrideMimeType('application/json');
42 | request.send(typeof options.body === 'string' ? options.body : undefined);
43 |
44 | return new Promise((resolve, reject) => {
45 | request.onloadend = (e) => {
46 | let json: any;
47 | try {
48 | json = JSON.parse(request.responseText);
49 | } catch (e) {
50 | // not json, no big deal
51 | }
52 | // Get the raw header string
53 | const headersAsString = request.getAllResponseHeaders();
54 |
55 | // Convert the header string into an array
56 | // of individual headers
57 | const arr = headersAsString.trim().split(/[\r\n]+/);
58 |
59 | // Create a map of header names to values
60 | const headers = new Headers();
61 | for (const line of arr) {
62 | const parts = line.split(': ');
63 | const header = parts.shift();
64 | if (!header) continue;
65 | const value = parts.join(': ');
66 | headers.set(header, value);
67 | }
68 | if (request.status < 200 || request.status >= 300) {
69 | return reject(
70 | new HttpError(
71 | json?.message || request.statusText,
72 | request.status,
73 | json,
74 | ),
75 | );
76 | }
77 | resolve({
78 | status: request.status,
79 | headers,
80 | body: request.responseText,
81 | json,
82 | });
83 | };
84 | });
85 | };
86 |
87 | export const dataProvider = simpleRestProvider(
88 | 'http://localhost:3000',
89 | httpClient,
90 | );
91 |
--------------------------------------------------------------------------------
/example/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Admin,
4 | Create,
5 | type DataProvider,
6 | EditGuesser,
7 | Resource,
8 | ShowGuesser,
9 | required,
10 | AutocompleteInput,
11 | ReferenceInput,
12 | SimpleForm,
13 | TextInput,
14 | SearchInput,
15 | Datagrid,
16 | List,
17 | TextField,
18 | FunctionField,
19 | } from 'react-admin';
20 |
21 | import authProvider from './authProvider';
22 | import { QueryClient } from '@tanstack/react-query';
23 |
24 | const queryClient = new QueryClient({
25 | defaultOptions: {
26 | queries: {
27 | refetchOnWindowFocus: false,
28 | },
29 | },
30 | });
31 |
32 | export const App = ({ dataProvider }: { dataProvider: DataProvider }) => {
33 | return (
34 |
39 |
46 |
52 | `${record.first_name} ${record.last_name}`
53 | }
54 | />
55 |
56 | );
57 | };
58 |
59 | const AuthorList = () => (
60 |
]}>
61 |
62 |
63 |
64 |
65 |
66 |
67 | );
68 |
69 | // The default value for the title field should cause a server validation error as it's not unique
70 | const BookCreate = () => (
71 |
72 |
73 |
74 |
75 |
76 |
81 |
82 |
83 | );
84 |
85 | const bookFilters = [
86 | ,
87 | ,
92 | ];
93 |
94 | const BookList = () => (
95 |
96 |
97 |
98 |
102 | record.author
103 | ? `${record.author.first_name} ${record.author.last_name}`
104 | : ''
105 | }
106 | />
107 |
108 |
109 |
110 | );
111 |
--------------------------------------------------------------------------------
/UPGRADE.md:
--------------------------------------------------------------------------------
1 | # Upgrading to 4.0.0
2 |
3 | ## Dropped bower support
4 |
5 | Fakerest no longer supports bower. You can still use it in your project by installing it via npm:
6 |
7 | ```bash
8 | npm install fakerest
9 | ```
10 |
11 | ## Renamed `Server` to `SinonAdapter`
12 |
13 | The `Server` class has been renamed to `SinonAdapter` and now expects a configuration object instead of a URL.
14 |
15 | ```diff
16 | import sinon from 'sinon';
17 | -import { Server } from 'fakerest';
18 | +import { SinonAdapter } from 'fakerest';
19 | import { data } from './data';
20 |
21 | -const server = new Server('http://myapi.com');
22 | -server.init(data);
23 | +const server = new SinonAdapter({ baseUrl: 'http://myapi.com', data });
24 | const server = sinon.fakeServer.create();
25 | server.respondWith(server.getHandler());
26 | ```
27 |
28 | ## Renamed `FetchServer` to `FetchMockAdapter`
29 |
30 | The `FetchServer` class has been renamed to `FetchMockAdapter` and now expects a configuration object instead of a URL.
31 |
32 | ```diff
33 | import fetchMock from 'fetch-mock';
34 | -import { FetchServer } from 'fakerest';
35 | +import { FetchMockAdapter } from 'fakerest';
36 | import { data } from './data';
37 |
38 | -const server = new FetchServer('http://myapi.com');
39 | -server.init(data);
40 | +const server = new FetchMockAdapter({ baseUrl: 'http://myapi.com', data });
41 | fetchMock.mock('begin:http://myapi.com', server.getHandler());
42 | ```
43 |
44 | ## Constructor Of `Collection` Takes An Object
45 |
46 | ```diff
47 | import { Collection } from 'fakerest';
48 |
49 | -const posts = new Collection([
50 | - { id: 1, title: 'baz' },
51 | - { id: 2, title: 'biz' },
52 | - { id: 3, title: 'boz' },
53 | -]);
54 | +const posts = new Collection({
55 | + items: [
56 | + { id: 1, title: 'baz' },
57 | + { id: 2, title: 'biz' },
58 | + { id: 3, title: 'boz' },
59 | + ],
60 | +});
61 | ```
62 |
63 | ## `addCollection` is now `adapter.server.addCollection`
64 |
65 | ```diff
66 | import fetchMock from 'fetch-mock';
67 | -import { FetchServer } from 'fakerest';
68 | +import { FetchMockAdapter } from 'fakerest';
69 | import { posts } from './posts';
70 |
71 | -const server = new FetchServer('http://myapi.com');
72 | -server.addCollection('posts', posts);
73 | -fetchMock.mock('begin:http://myapi.com', server.getHandler());
74 | +const adapter = new FetchMockAdapter({ baseUrl: 'http://myapi.com', data });
75 | +adapter.server.addCollection('posts', posts);
76 | +fetchMock.mock('begin:http://myapi.com', adapter.getHandler());
77 | ```
78 |
79 | ## Request and Response Interceptors Have Been Replaced By Middlewares
80 |
81 | Fakerest used to have request and response interceptors. We replaced those with middlewares. They allow much more use cases.
82 |
83 | Migrate your request interceptors to middlewares passed when building the handler:
84 |
85 | ```diff
86 | - const myRequestInterceptor = function(request) {
87 | + const myMiddleware = async function(context, next) {
88 | var start = (request.params._start - 1) || 0;
89 | var end = request.params._end !== undefined ? (request.params._end - 1) : 19;
90 | request.params.range = [start, end];
91 | - return request; // always return the modified input
92 | + return next(context);
93 | };
94 |
95 | -restServer.addRequestInterceptor(myRequestInterceptor);
96 | +const handler = new getMswHandler({
97 | + baseUrl: 'http://my.custom.domain',
98 | + data,
99 | + middlewares: [myMiddleware],
100 | });
101 | ```
102 |
103 | Migrate your response interceptors the same way.
104 |
--------------------------------------------------------------------------------
/src/Single.ts:
--------------------------------------------------------------------------------
1 | import cloneDeep from 'lodash/cloneDeep.js';
2 | import type { Database } from './Database.ts';
3 | import type { CollectionItem, Embed, Query } from './types.ts';
4 |
5 | export class Single {
6 | obj: T | null = null;
7 | database: Database | null = null;
8 | name: string | null = null;
9 |
10 | constructor(obj: T) {
11 | if (!(obj instanceof Object)) {
12 | throw new Error(
13 | "Can't initialize a Single with anything except an object",
14 | );
15 | }
16 | this.obj = cloneDeep(obj);
17 | }
18 |
19 | /**
20 | * A Single may need to access other collections (e.g. for embedded
21 | * references) This is done through a reference to the parent database.
22 | */
23 | setDatabase(database: Database) {
24 | this.database = database;
25 | }
26 |
27 | setName(name: string) {
28 | this.name = name;
29 | }
30 |
31 | // No need to embed Singles, since they are by their nature top-level
32 | // No need to worry about remote references, (i.e. mysingleton_id=1) since
33 | // it is by definition a singleton
34 | _oneToManyEmbedder(resourceName: string) {
35 | return (item: T) => {
36 | if (this.database == null) {
37 | throw new Error("Can't embed references without a database");
38 | }
39 | const otherCollection = this.database.collections[resourceName];
40 | if (!otherCollection)
41 | throw new Error(
42 | `Can't embed a non-existing collection ${resourceName}`,
43 | );
44 | // We have an array of ids {posts: [1,2]} (back refs are not valid
45 | // for singleton)
46 | // @ts-expect-error - For some reason, TS does not accept writing a generic types with the index signature
47 | item[resourceName] = otherCollection.getAll({
48 | filter: (i: CollectionItem) =>
49 | item[resourceName].indexOf(
50 | i[otherCollection.identifierName],
51 | ) !== -1,
52 | });
53 | return item;
54 | };
55 | }
56 |
57 | _manyToOneEmbedder(resourceName: string) {
58 | const pluralResourceName = `${resourceName}s`;
59 | const referenceName = `${resourceName}_id`;
60 | return (item: T) => {
61 | if (this.database == null) {
62 | throw new Error("Can't embed references without a database");
63 | }
64 | const otherCollection =
65 | this.database.collections[pluralResourceName];
66 | if (!otherCollection)
67 | throw new Error(
68 | `Can't embed a non-existing collection ${resourceName}`,
69 | );
70 | try {
71 | // @ts-expect-error - For some reason, TS does not accept writing a generic types with the index signature
72 | item[resourceName] = otherCollection.getOne(
73 | item[referenceName],
74 | );
75 | } catch (e) {
76 | // Resource doesn't exist, so don't embed
77 | }
78 | return item;
79 | };
80 | }
81 |
82 | _itemEmbedder(embed: Embed) {
83 | const resourceNames = Array.isArray(embed) ? embed : [embed];
84 | const resourceEmbedders = resourceNames.map((resourceName) =>
85 | resourceName.endsWith('s')
86 | ? this._oneToManyEmbedder(resourceName)
87 | : this._manyToOneEmbedder(resourceName),
88 | );
89 | return (item: T) =>
90 | resourceEmbedders.reduce(
91 | (itemWithEmbeds, embedder) => embedder(itemWithEmbeds),
92 | item,
93 | );
94 | }
95 |
96 | getOnly(query?: Query) {
97 | let item = this.obj;
98 | if (query?.embed && this.database) {
99 | item = Object.assign({}, item); // Clone
100 | item = this._itemEmbedder(query.embed)(item);
101 | }
102 | return item;
103 | }
104 |
105 | updateOnly(item: T) {
106 | if (this.obj == null) {
107 | throw new Error("Can't update a non-existing object");
108 | }
109 |
110 | for (const key in item) {
111 | this.obj[key] = item[key];
112 | }
113 | return this.obj;
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/adapters/FetchMockAdapter.ts:
--------------------------------------------------------------------------------
1 | import { SimpleRestServer } from '../SimpleRestServer.ts';
2 | import { parseQueryString } from '../parseQueryString.ts';
3 | import type { BaseServerOptions } from '../SimpleRestServer.ts';
4 | import type { BaseResponse, APIServer, NormalizedRequest } from '../types.ts';
5 | import type { MockResponseObject } from 'fetch-mock';
6 |
7 | export class FetchMockAdapter {
8 | loggingEnabled = false;
9 | server: APIServer;
10 |
11 | constructor({
12 | loggingEnabled = false,
13 | server,
14 | ...options
15 | }: FetchMockAdapterOptions = {}) {
16 | this.server = server || new SimpleRestServer(options);
17 | this.loggingEnabled = loggingEnabled;
18 | }
19 |
20 | getHandler() {
21 | const handler = async (url: string, options: RequestInit) => {
22 | const request = new Request(url, options);
23 | const normalizedRequest = await this.getNormalizedRequest(request);
24 | const response = await this.server.handle(normalizedRequest);
25 | this.log(request, response, normalizedRequest);
26 | return response as MockResponseObject;
27 | };
28 |
29 | return handler;
30 | }
31 |
32 | async getNormalizedRequest(request: Request): Promise {
33 | const req =
34 | typeof request === 'string' ? new Request(request) : request;
35 | const queryString = req.url
36 | ? decodeURIComponent(req.url.slice(req.url.indexOf('?') + 1))
37 | : '';
38 | const params = parseQueryString(queryString);
39 | const text = await req.text();
40 | let requestBody: Record | undefined = undefined;
41 | try {
42 | requestBody = JSON.parse(text);
43 | } catch (e) {
44 | // not JSON, no big deal
45 | }
46 |
47 | return {
48 | url: req.url,
49 | headers: req.headers,
50 | params,
51 | requestBody,
52 | method: req.method,
53 | };
54 | }
55 |
56 | log(
57 | request: FetchMockFakeRestRequest,
58 | response: BaseResponse,
59 | normalizedRequest: NormalizedRequest,
60 | ) {
61 | if (!this.loggingEnabled) return;
62 | if (console.group) {
63 | // Better logging in Chrome
64 | console.groupCollapsed(
65 | normalizedRequest.method,
66 | normalizedRequest.url,
67 | '(FakeRest)',
68 | );
69 | console.group('request');
70 | console.log(normalizedRequest.method, normalizedRequest.url);
71 | console.log('headers', request.headers);
72 | console.log('body ', request.requestJson);
73 | console.groupEnd();
74 | console.group('response', response.status);
75 | console.log('headers', response.headers);
76 | console.log('body ', response.body);
77 | console.groupEnd();
78 | console.groupEnd();
79 | } else {
80 | console.log(
81 | 'FakeRest request ',
82 | normalizedRequest.method,
83 | normalizedRequest.url,
84 | 'headers',
85 | request.headers,
86 | 'body',
87 | request.requestJson,
88 | );
89 | console.log(
90 | 'FakeRest response',
91 | response.status,
92 | 'headers',
93 | response.headers,
94 | 'body',
95 | response.body,
96 | );
97 | }
98 | }
99 |
100 | toggleLogging() {
101 | this.loggingEnabled = !this.loggingEnabled;
102 | }
103 | }
104 |
105 | export const getFetchMockHandler = (options: FetchMockAdapterOptions) => {
106 | const server = new FetchMockAdapter(options);
107 | return server.getHandler();
108 | };
109 |
110 | /**
111 | * @deprecated Use FetchServer instead
112 | */
113 | export const FetchServer = FetchMockAdapter;
114 |
115 | export type FetchMockFakeRestRequest = Partial & {
116 | requestBody?: string;
117 | responseText?: string;
118 | requestJson?: Record;
119 | queryString?: string;
120 | params?: { [key: string]: any };
121 | };
122 |
123 | export type FetchMockAdapterOptions = BaseServerOptions & {
124 | server?: APIServer;
125 | loggingEnabled?: boolean;
126 | };
127 |
--------------------------------------------------------------------------------
/src/Database.ts:
--------------------------------------------------------------------------------
1 | import { Collection } from './Collection.ts';
2 | import { Single } from './Single.ts';
3 | import type { CollectionItem, Query, QueryFunction } from './types.ts';
4 |
5 | export class Database {
6 | identifierName = 'id';
7 | collections: Record> = {};
8 | singles: Record> = {};
9 | getNewId?: () => number | string;
10 |
11 | constructor({
12 | data,
13 | identifierName = 'id',
14 | getNewId,
15 | }: DatabaseOptions = {}) {
16 | this.getNewId = getNewId;
17 | this.identifierName = identifierName;
18 |
19 | if (data) {
20 | this.init(data);
21 | }
22 | }
23 |
24 | /**
25 | * Shortcut for adding several collections if identifierName is always the same
26 | */
27 | init(data: Record) {
28 | for (const name in data) {
29 | const value = data[name];
30 | if (Array.isArray(value)) {
31 | this.addCollection(
32 | name,
33 | new Collection({
34 | items: value,
35 | identifierName: this.identifierName,
36 | getNewId: this.getNewId,
37 | }),
38 | );
39 | } else {
40 | this.addSingle(name, new Single(value));
41 | }
42 | }
43 | }
44 |
45 | addCollection(
46 | name: string,
47 | collection: Collection,
48 | ) {
49 | this.collections[name] = collection;
50 | collection.setDatabase(this);
51 | collection.setName(name);
52 | }
53 |
54 | getCollection(name: string) {
55 | return this.collections[name];
56 | }
57 |
58 | getCollectionNames() {
59 | return Object.keys(this.collections);
60 | }
61 |
62 | addSingle(
63 | name: string,
64 | single: Single,
65 | ) {
66 | this.singles[name] = single;
67 | single.setDatabase(this);
68 | single.setName(name);
69 | }
70 |
71 | getSingle(name: string) {
72 | return this.singles[name];
73 | }
74 |
75 | getSingleNames() {
76 | return Object.keys(this.singles);
77 | }
78 |
79 | /**
80 | * @param {string} name
81 | * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] }
82 | */
83 | getCount(name: string, params?: Query) {
84 | return this.collections[name].getCount(params);
85 | }
86 |
87 | /**
88 | * @param {string} name
89 | * @param {string} params As decoded from the query string, e.g. { sort: "name", filter: {enabled:true}, slice: [10, 20] }
90 | */
91 | getAll(name: string, params?: Query) {
92 | return this.collections[name].getAll(params);
93 | }
94 |
95 | getOne(name: string, identifier: string | number, params?: Query) {
96 | return this.collections[name].getOne(identifier, params);
97 | }
98 |
99 | addOne(name: string, item: CollectionItem) {
100 | if (!Object.prototype.hasOwnProperty.call(this.collections, name)) {
101 | this.addCollection(
102 | name,
103 | new Collection({
104 | items: [],
105 | identifierName: this.identifierName,
106 | getNewId: this.getNewId,
107 | }),
108 | );
109 | }
110 | return this.collections[name].addOne(item);
111 | }
112 |
113 | updateOne(name: string, identifier: string | number, item: CollectionItem) {
114 | return this.collections[name].updateOne(identifier, item);
115 | }
116 |
117 | removeOne(name: string, identifier: string | number) {
118 | return this.collections[name].removeOne(identifier);
119 | }
120 |
121 | getOnly(name: string, params?: Query) {
122 | return this.singles[name].getOnly();
123 | }
124 |
125 | updateOnly(name: string, item: CollectionItem) {
126 | return this.singles[name].updateOnly(item);
127 | }
128 | }
129 |
130 | export type DatabaseOptions = {
131 | baseUrl?: string;
132 | batchUrl?: string | null;
133 | data?: Record;
134 | defaultQuery?: QueryFunction;
135 | identifierName?: string;
136 | getNewId?: () => number | string;
137 | loggingEnabled?: boolean;
138 | };
139 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Project Overview
6 |
7 | FakeRest is a browser library that intercepts AJAX calls to mock a REST server based on JSON data. It provides adapters for MSW, fetch-mock, and Sinon.js to enable testing of JavaScript REST clients without a backend server.
8 |
9 | ## Common Commands
10 |
11 | ### Development
12 | ```bash
13 | npm run dev # Run dev server (uses MSW by default)
14 | make run-msw # Run with MSW adapter
15 | make run-fetch-mock # Run with fetch-mock adapter
16 | make run-sinon # Run with Sinon adapter
17 | ```
18 |
19 | ### Testing & Quality
20 | ```bash
21 | npm test # Run tests with Vitest
22 | npm run format # Format code with Biome
23 | npm run lint # Lint code with Biome
24 | ```
25 |
26 | ### Building
27 | ```bash
28 | npm run build # Build both minified and non-minified versions
29 | make build # Production build via Make
30 | ```
31 |
32 | ### Running Single Tests
33 | ```bash
34 | npx vitest run [test-file-pattern] # Run specific test file
35 | npx vitest [test-name-pattern] # Run tests matching pattern
36 | ```
37 |
38 | ## Architecture
39 |
40 | ### Core Components
41 |
42 | The library has a layered architecture:
43 |
44 | 1. **Adapters Layer** (`src/adapters/`)
45 | - `MswAdapter`: Integrates with MSW (Mock Service Worker)
46 | - `FetchMockAdapter`: Integrates with fetch-mock
47 | - `SinonAdapter`: Integrates with Sinon.js fake server
48 | - Each adapter normalizes requests from their respective mocking library and transforms responses back
49 |
50 | 2. **Server Layer** (`SimpleRestServer`)
51 | - Implements REST semantics (GET, POST, PUT, PATCH, DELETE)
52 | - Handles routing to collections vs singles
53 | - Processes middleware chain
54 | - URL pattern: `/{collection}` or `/{collection}/{id}` or `/{single}`
55 |
56 | 3. **Database Layer** (`Database`)
57 | - Manages collections (arrays of records) and singles (single objects)
58 | - Routes CRUD operations to appropriate collection/single
59 | - Handles initialization from data objects
60 |
61 | 4. **Collection/Single Layer**
62 | - `Collection`: Implements filtering, sorting, pagination, and embedding for array data
63 | - `Single`: Manages single object resources (e.g., user profile, settings)
64 | - Both support embedding related resources
65 |
66 | ### Request Flow
67 |
68 | ```
69 | Mocking Library (MSW/fetch-mock/Sinon)
70 | ↓
71 | Adapter (normalizes request)
72 | ↓
73 | SimpleRestServer.handle()
74 | ↓
75 | Middleware chain (optional)
76 | ↓
77 | SimpleRestServer.handleRequest()
78 | ↓
79 | Database → Collection/Single
80 | ↓
81 | Response (normalized)
82 | ↓
83 | Adapter (transforms to library format)
84 | ↓
85 | Mocking Library
86 | ```
87 |
88 | ### Key Concepts
89 |
90 | - **Collections**: Array-based resources that support filtering (including `q` for full-text search, operators like `_gte`, `_lte`, `_eq`, `_neq`), sorting, range queries, and embedding
91 | - **Singles**: Single object resources (not arrays) for endpoints like `/settings` or `/me`
92 | - **Embedding**: Automatically resolve relationships by embedding related collections/singles in responses via `embed` parameter
93 | - **Middleware**: Functions that intercept requests to add authentication, validation, delays, or dynamic values
94 | - **Identifiers**: Customizable per collection (default: `id`, common alternative: `_id` for MongoDB-style APIs)
95 |
96 | ### File Structure
97 |
98 | ```
99 | src/
100 | ├── adapters/ # Adapter implementations for different mocking libraries
101 | ├── Collection.ts # Collection logic (filtering, sorting, pagination)
102 | ├── Database.ts # Database managing collections and singles
103 | ├── SimpleRestServer.ts # REST server implementation with middleware support
104 | ├── Single.ts # Single object resource logic
105 | ├── types.ts # TypeScript type definitions
106 | ├── withDelay.ts # Middleware helper for simulating delays
107 | ├── parseQueryString.ts # Query parameter parsing
108 | └── index.ts # Main entry point, exports public API
109 | ```
110 |
111 | ## Code Style
112 |
113 | - **Formatter/Linter**: Biome (configured in `biome.json`)
114 | - 4-space indentation
115 | - Single quotes for strings
116 | - Explicit any types allowed (`noExplicitAny: off`)
117 | - **TypeScript**: All source files use `.ts` extension with explicit `.ts` imports
118 | - **Testing**: Vitest with happy-dom environment for DOM emulation
119 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
11 |
12 | FakeRest Demo
13 |
109 |
110 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
127 |
128 |
129 |
--------------------------------------------------------------------------------
/src/Database.spec.ts:
--------------------------------------------------------------------------------
1 | import { Database } from './Database.ts';
2 | import { Single } from './Single.ts';
3 | import { Collection } from './Collection.ts';
4 |
5 | describe('Database', () => {
6 | describe('init', () => {
7 | it('should populate several collections', () => {
8 | const server = new Database();
9 | server.init({
10 | foo: [{ a: 1 }, { a: 2 }, { a: 3 }],
11 | bar: [{ b: true }, { b: false }],
12 | baz: { name: 'baz' },
13 | });
14 | expect(server.getAll('foo')).toEqual([
15 | { id: 0, a: 1 },
16 | { id: 1, a: 2 },
17 | { id: 2, a: 3 },
18 | ]);
19 | expect(server.getAll('bar')).toEqual([
20 | { id: 0, b: true },
21 | { id: 1, b: false },
22 | ]);
23 | expect(server.getOnly('baz')).toEqual({ name: 'baz' });
24 | });
25 | });
26 |
27 | describe('addCollection', () => {
28 | it('should add a collection and index it by name', () => {
29 | const server = new Database();
30 | const collection = new Collection({
31 | items: [
32 | { id: 1, name: 'foo' },
33 | { id: 2, name: 'bar' },
34 | ],
35 | });
36 | server.addCollection('foo', collection);
37 | const newcollection = server.getCollection('foo');
38 | expect(newcollection).toEqual(collection);
39 | });
40 | });
41 |
42 | describe('addSingle', () => {
43 | it('should add a single object and index it by name', () => {
44 | const server = new Database();
45 | const single = new Single({ name: 'foo', description: 'bar' });
46 | server.addSingle('foo', single);
47 | expect(server.getSingle('foo')).toEqual(single);
48 | });
49 | });
50 |
51 | describe('getAll', () => {
52 | it('should return all items for a given name', () => {
53 | const server = new Database();
54 | server.addCollection(
55 | 'foo',
56 | new Collection({
57 | items: [
58 | { id: 1, name: 'foo' },
59 | { id: 2, name: 'bar' },
60 | ],
61 | }),
62 | );
63 | server.addCollection(
64 | 'baz',
65 | new Collection({ items: [{ id: 1, name: 'baz' }] }),
66 | );
67 | expect(server.getAll('foo')).toEqual([
68 | { id: 1, name: 'foo' },
69 | { id: 2, name: 'bar' },
70 | ]);
71 | expect(server.getAll('baz')).toEqual([{ id: 1, name: 'baz' }]);
72 | });
73 |
74 | it('should support a query', () => {
75 | const server = new Database();
76 | server.addCollection(
77 | 'foo',
78 | new Collection({
79 | items: [
80 | { id: 0, name: 'c', arg: false },
81 | { id: 1, name: 'b', arg: true },
82 | { id: 2, name: 'a', arg: true },
83 | ],
84 | }),
85 | );
86 | const params = {
87 | filter: { arg: true },
88 | sort: 'name',
89 | slice: [0, 10],
90 | };
91 | const expected = [
92 | { id: 2, name: 'a', arg: true },
93 | { id: 1, name: 'b', arg: true },
94 | ];
95 | expect(server.getAll('foo', params)).toEqual(expected);
96 | });
97 | });
98 |
99 | describe('getOne', () => {
100 | it('should return an error when no collection match the identifier', () => {
101 | const server = new Database();
102 | server.addCollection(
103 | 'foo',
104 | new Collection({ items: [{ id: 1, name: 'foo' }] }),
105 | );
106 | expect(() => {
107 | server.getOne('foo', 2);
108 | }).toThrow(new Error('No item with identifier 2'));
109 | });
110 |
111 | it('should return the first collection matching the identifier', () => {
112 | const server = new Database();
113 | server.addCollection(
114 | 'foo',
115 | new Collection({
116 | items: [
117 | { id: 1, name: 'foo' },
118 | { id: 2, name: 'bar' },
119 | ],
120 | }),
121 | );
122 | expect(server.getOne('foo', 1)).toEqual({ id: 1, name: 'foo' });
123 | expect(server.getOne('foo', 2)).toEqual({ id: 2, name: 'bar' });
124 | });
125 |
126 | it('should use the identifierName', () => {
127 | const server = new Database();
128 | server.addCollection(
129 | 'foo',
130 | new Collection({
131 | items: [
132 | { _id: 1, name: 'foo' },
133 | { _id: 2, name: 'bar' },
134 | ],
135 | identifierName: '_id',
136 | }),
137 | );
138 | expect(server.getOne('foo', 1)).toEqual({ _id: 1, name: 'foo' });
139 | expect(server.getOne('foo', 2)).toEqual({ _id: 2, name: 'bar' });
140 | });
141 | });
142 |
143 | describe('getOnly', () => {
144 | it('should return the single matching the identifier', () => {
145 | const server = new Database();
146 | server.addSingle('foo', new Single({ name: 'foo' }));
147 | expect(server.getOnly('foo')).toEqual({ name: 'foo' });
148 | });
149 | });
150 | });
151 |
--------------------------------------------------------------------------------
/src/adapters/SinonAdapter.ts:
--------------------------------------------------------------------------------
1 | import type { SinonFakeXMLHttpRequest } from 'sinon';
2 | import {
3 | SimpleRestServer,
4 | type BaseServerOptions,
5 | } from '../SimpleRestServer.ts';
6 | import { parseQueryString } from '../parseQueryString.ts';
7 | import type { BaseResponse, APIServer, NormalizedRequest } from '../types.ts';
8 |
9 | export class SinonAdapter {
10 | loggingEnabled = false;
11 | server: APIServer;
12 |
13 | constructor({
14 | loggingEnabled = false,
15 | server,
16 | ...options
17 | }: SinonAdapterOptions = {}) {
18 | this.server = server || new SimpleRestServer(options);
19 | this.loggingEnabled = loggingEnabled;
20 | }
21 |
22 | getHandler() {
23 | return async (request: SinonFakeXMLHttpRequest) => {
24 | // This is an internal property of SinonFakeXMLHttpRequest but we have to set it to 4 to
25 | // suppress sinon's synchronous processing (which would result in HTTP 404). This allows us
26 | // to handle the request asynchronously.
27 | // See https://github.com/sinonjs/sinon/issues/637
28 | // @ts-expect-error
29 | request.readyState = 4;
30 | const normalizedRequest = this.getNormalizedRequest(request);
31 | const response = await this.server.handle(normalizedRequest);
32 | this.respond(response, request);
33 | };
34 | }
35 |
36 | getNormalizedRequest(request: SinonFakeXMLHttpRequest): NormalizedRequest {
37 | const req: Request | SinonFakeXMLHttpRequest =
38 | typeof request === 'string' ? new Request(request) : request;
39 |
40 | const queryString = req.url
41 | ? decodeURIComponent(req.url.slice(req.url.indexOf('?') + 1))
42 | : '';
43 | const params = parseQueryString(queryString);
44 | let requestBody: Record | undefined = undefined;
45 | if ((req as SinonFakeXMLHttpRequest).requestBody) {
46 | try {
47 | requestBody = JSON.parse(
48 | (req as SinonFakeXMLHttpRequest).requestBody,
49 | );
50 | } catch (error) {
51 | // body isn't JSON, skipping
52 | }
53 | }
54 |
55 | return {
56 | url: req.url,
57 | headers: new Headers(request.requestHeaders),
58 | params,
59 | requestBody,
60 | method: req.method,
61 | };
62 | }
63 |
64 | respond(response: BaseResponse, request: SinonFakeXMLHttpRequest) {
65 | const sinonResponse = {
66 | status: response.status,
67 | body: response.body ?? '',
68 | headers: response.headers ?? {},
69 | };
70 |
71 | if (Array.isArray(sinonResponse.headers)) {
72 | if (
73 | !(
74 | sinonResponse.headers as Array<{
75 | name: string;
76 | value: string;
77 | }>
78 | ).find((header) => header.name.toLowerCase() === 'content-type')
79 | ) {
80 | sinonResponse.headers.push({
81 | name: 'Content-Type',
82 | value: 'application/json',
83 | });
84 | }
85 | } else if (
86 | !(sinonResponse.headers as Record)['Content-Type']
87 | ) {
88 | sinonResponse.headers['Content-Type'] = 'application/json';
89 | }
90 |
91 | // This is an internal property of SinonFakeXMLHttpRequest but we have to reset it to 1
92 | // to handle the request asynchronously.
93 | // See https://github.com/sinonjs/sinon/issues/637
94 | // @ts-expect-error
95 | request.readyState = 1;
96 |
97 | request.respond(
98 | sinonResponse.status,
99 | sinonResponse.headers,
100 | JSON.stringify(sinonResponse.body),
101 | );
102 |
103 | this.log(request, sinonResponse);
104 | }
105 |
106 | log(request: SinonFakeXMLHttpRequest, response: SinonFakeRestResponse) {
107 | if (!this.loggingEnabled) return;
108 | if (console.group) {
109 | // Better logging in Chrome
110 | console.groupCollapsed(request.method, request.url, '(FakeRest)');
111 | console.group('request');
112 | console.log(request.method, request.url);
113 | console.log('headers', request.requestHeaders);
114 | console.log('body ', request.requestBody);
115 | console.groupEnd();
116 | console.group('response', response.status);
117 | console.log('headers', response.headers);
118 | console.log('body ', response.body);
119 | console.groupEnd();
120 | console.groupEnd();
121 | } else {
122 | console.log(
123 | 'FakeRest request ',
124 | request.method,
125 | request.url,
126 | 'headers',
127 | request.requestHeaders,
128 | 'body',
129 | request.requestBody,
130 | );
131 | console.log(
132 | 'FakeRest response',
133 | response.status,
134 | 'headers',
135 | response.headers,
136 | 'body',
137 | response.body,
138 | );
139 | }
140 | }
141 |
142 | toggleLogging() {
143 | this.loggingEnabled = !this.loggingEnabled;
144 | }
145 | }
146 |
147 | export const getSinonHandler = (options: SinonAdapterOptions) => {
148 | const server = new SinonAdapter(options);
149 | return server.getHandler();
150 | };
151 |
152 | /**
153 | * @deprecated Use SinonServer instead
154 | */
155 | export const Server = SinonAdapter;
156 |
157 | export type SinonFakeRestResponse = {
158 | status: number;
159 | body: any;
160 | headers: Record;
161 | };
162 |
163 | export type SinonAdapterOptions = BaseServerOptions & {
164 | server?: APIServer;
165 | loggingEnabled?: boolean;
166 | };
167 |
--------------------------------------------------------------------------------
/src/Single.spec.ts:
--------------------------------------------------------------------------------
1 | import { Single } from './Single.ts';
2 | import { Collection } from './Collection.ts';
3 | import { Database } from './Database.ts';
4 |
5 | describe('Single', () => {
6 | describe('constructor', () => {
7 | it('should set the intial set of data', () => {
8 | const single = new Single({ foo: 'bar' });
9 | expect(single.getOnly()).toEqual({ foo: 'bar' });
10 | });
11 | });
12 |
13 | describe('getOnly', () => {
14 | it('should return the passed in object', () => {
15 | const single = new Single({ foo: 'bar' });
16 | expect(single.getOnly()).toEqual({ foo: 'bar' });
17 | });
18 |
19 | describe('embed query', () => {
20 | it('should throw an error when trying to embed a non-existing collection', () => {
21 | const foo = new Single({ name: 'foo', bar_id: 123 });
22 | const database = new Database();
23 | database.addSingle('foo', foo);
24 | expect(() => {
25 | foo.getOnly({ embed: ['bar'] });
26 | }).toThrow(
27 | new Error("Can't embed a non-existing collection bar"),
28 | );
29 | });
30 |
31 | it('should return the original object for missing embed one', () => {
32 | const foo = new Single({ name: 'foo', bar_id: 123 });
33 | const bars = new Collection({ items: [] });
34 | const database = new Database();
35 | database.addSingle('foo', foo);
36 | database.addCollection('bars', bars);
37 | const expected = { name: 'foo', bar_id: 123 };
38 | expect(foo.getOnly({ embed: ['bar'] })).toEqual(expected);
39 | });
40 |
41 | it('should return the object with the reference object for embed one', () => {
42 | const foo = new Single({ name: 'foo', bar_id: 123 });
43 | const bars = new Collection({
44 | items: [
45 | { id: 1, bar: 'nobody wants me' },
46 | { id: 123, bar: 'baz' },
47 | { id: 456, bar: 'bazz' },
48 | ],
49 | });
50 | const database = new Database();
51 | database.addSingle('foo', foo);
52 | database.addCollection('bars', bars);
53 | const expected = {
54 | name: 'foo',
55 | bar_id: 123,
56 | bar: { id: 123, bar: 'baz' },
57 | };
58 | expect(foo.getOnly({ embed: ['bar'] })).toEqual(expected);
59 | });
60 |
61 | it('should throw an error when trying to embed many a non-existing collection', () => {
62 | const foo = new Single({ name: 'foo', bar_id: 123 });
63 | const database = new Database();
64 | database.addSingle('foo', foo);
65 | expect(() => {
66 | foo.getOnly({ embed: ['bars'] });
67 | }).toThrow(
68 | new Error("Can't embed a non-existing collection bars"),
69 | );
70 | });
71 |
72 | it('should return the object with an array of references for embed many using inner array', () => {
73 | const foo = new Single({ name: 'foo', bars: [1, 3] });
74 | const bars = new Collection({
75 | items: [
76 | { id: 1, bar: 'baz' },
77 | { id: 2, bar: 'biz' },
78 | { id: 3, bar: 'boz' },
79 | ],
80 | });
81 | const database = new Database();
82 | database.addSingle('foo', foo);
83 | database.addCollection('bars', bars);
84 | const expected = {
85 | name: 'foo',
86 | bars: [
87 | { id: 1, bar: 'baz' },
88 | { id: 3, bar: 'boz' },
89 | ],
90 | };
91 | expect(foo.getOnly({ embed: ['bars'] })).toEqual(expected);
92 | });
93 |
94 | it('should allow multiple embeds', () => {
95 | const foo = new Single({
96 | name: 'foo',
97 | bars: [1, 3],
98 | bazs: [4, 5],
99 | });
100 | const bars = new Collection({
101 | items: [
102 | { id: 1, name: 'bar1' },
103 | { id: 2, name: 'bar2' },
104 | { id: 3, name: 'bar3' },
105 | ],
106 | });
107 | const bazs = new Collection({
108 | items: [
109 | { id: 4, name: 'baz1' },
110 | { id: 5, name: 'baz2' },
111 | { id: 6, name: 'baz3' },
112 | ],
113 | });
114 | const database = new Database();
115 | database.addSingle('foo', foo);
116 | database.addCollection('bars', bars);
117 | database.addCollection('bazs', bazs);
118 | const expected = {
119 | name: 'foo',
120 | bars: [
121 | { id: 1, name: 'bar1' },
122 | { id: 3, name: 'bar3' },
123 | ],
124 | bazs: [
125 | { id: 4, name: 'baz1' },
126 | { id: 5, name: 'baz2' },
127 | ],
128 | };
129 | expect(foo.getOnly({ embed: ['bars', 'bazs'] })).toEqual(
130 | expected,
131 | );
132 | });
133 | });
134 | });
135 |
136 | describe('updateOnly', () => {
137 | it('should return the updated item', () => {
138 | const single = new Single({ name: 'foo' });
139 | expect(single.updateOnly({ name: 'bar' })).toEqual({ name: 'bar' });
140 | });
141 |
142 | it('should update the item', () => {
143 | const single = new Single({ name: 'foo' });
144 | single.updateOnly({ name: 'bar' });
145 | expect(single.getOnly()).toEqual({ name: 'bar' });
146 | });
147 |
148 | it('should not update the original item', () => {
149 | const data = { name: 'foo' };
150 | const single = new Single(data);
151 | single.updateOnly({ name: 'bar' });
152 | expect(single.getOnly()).toEqual({ name: 'bar' });
153 | expect(data).toEqual({ name: 'foo' });
154 | });
155 | });
156 | });
157 |
--------------------------------------------------------------------------------
/src/SimpleRestServer.spec.ts:
--------------------------------------------------------------------------------
1 | import { SimpleRestServer } from './SimpleRestServer.ts';
2 |
3 | describe('SimpleRestServer', () => {
4 | describe('getAll', () => {
5 | it('should return list results according to the request parameters', async () => {
6 | const data = {
7 | posts: [
8 | {
9 | id: 1,
10 | title: 'bazingaaa',
11 | },
12 | {
13 | id: 2,
14 | title: 'bazinga',
15 | },
16 | {
17 | id: 3,
18 | title: 'nope',
19 | },
20 | ],
21 | };
22 |
23 | const server = new SimpleRestServer({
24 | baseUrl: 'http://localhost:4000',
25 | data,
26 | });
27 |
28 | const response = await server.handleRequest({
29 | url: 'http://localhost:4000/posts',
30 | method: 'GET',
31 | params: {
32 | filter: { q: 'bazin' },
33 | range: [0, 1],
34 | sort: 'title',
35 | },
36 | requestBody: undefined,
37 | });
38 |
39 | expect(response).toEqual(
40 | expect.objectContaining({
41 | status: 200,
42 | body: [
43 | {
44 | id: 2,
45 | title: 'bazinga',
46 | },
47 | {
48 | id: 1,
49 | title: 'bazingaaa',
50 | },
51 | ],
52 | }),
53 | );
54 | });
55 | });
56 | describe('getOne', () => {
57 | it('should correctly get records with a numeric identifier', async () => {
58 | const data = {
59 | posts: [
60 | {
61 | id: 1,
62 | title: 'test',
63 | },
64 | ],
65 | };
66 |
67 | const server = new SimpleRestServer({
68 | baseUrl: 'http://localhost:4000',
69 | data,
70 | });
71 |
72 | const response = await server.handleRequest({
73 | url: 'http://localhost:4000/posts/1',
74 | method: 'GET',
75 | params: {},
76 | requestBody: undefined,
77 | });
78 |
79 | expect(response).toEqual(
80 | expect.objectContaining({
81 | status: 200,
82 | body: {
83 | id: 1,
84 | title: 'test',
85 | },
86 | }),
87 | );
88 | });
89 | it('should correctly get records with a string identifier', async () => {
90 | const data = {
91 | posts: [
92 | {
93 | id: 'bazinga',
94 | title: 'test',
95 | },
96 | ],
97 | };
98 |
99 | const server = new SimpleRestServer({
100 | baseUrl: 'http://localhost:4000',
101 | data,
102 | });
103 |
104 | const response = await server.handleRequest({
105 | url: 'http://localhost:4000/posts/bazinga',
106 | method: 'GET',
107 | params: {},
108 | requestBody: undefined,
109 | });
110 |
111 | expect(response).toEqual(
112 | expect.objectContaining({
113 | status: 200,
114 | body: {
115 | id: 'bazinga',
116 | title: 'test',
117 | },
118 | }),
119 | );
120 | });
121 | });
122 | describe('update', () => {
123 | it('should correctly update records with a numeric identifier', async () => {
124 | const data = {
125 | posts: [
126 | {
127 | id: 1,
128 | title: 'test',
129 | },
130 | ],
131 | };
132 |
133 | const server = new SimpleRestServer({
134 | baseUrl: 'http://localhost:4000',
135 | data,
136 | });
137 |
138 | const response = await server.handleRequest({
139 | url: 'http://localhost:4000/posts/1',
140 | method: 'PUT',
141 | params: {},
142 | requestBody: {
143 | id: 1,
144 | title: 'test42',
145 | },
146 | });
147 |
148 | expect(response).toEqual(
149 | expect.objectContaining({
150 | status: 200,
151 | body: {
152 | id: 1,
153 | title: 'test42',
154 | },
155 | }),
156 | );
157 | });
158 | it('should correctly update records with a string identifier', async () => {
159 | const data = {
160 | posts: [
161 | {
162 | id: 'bazinga',
163 | title: 'test',
164 | },
165 | ],
166 | };
167 |
168 | const server = new SimpleRestServer({
169 | baseUrl: 'http://localhost:4000',
170 | data,
171 | });
172 |
173 | const response = await server.handleRequest({
174 | url: 'http://localhost:4000/posts/bazinga',
175 | method: 'PUT',
176 | params: {},
177 | requestBody: {
178 | id: 'bazinga',
179 | title: 'test42',
180 | },
181 | });
182 |
183 | expect(response).toEqual(
184 | expect.objectContaining({
185 | status: 200,
186 | body: {
187 | id: 'bazinga',
188 | title: 'test42',
189 | },
190 | }),
191 | );
192 | });
193 | });
194 | describe('delete', () => {
195 | it('should correctly delete records with a numeric identifier', async () => {
196 | const data = {
197 | posts: [
198 | {
199 | id: 1,
200 | title: 'test',
201 | },
202 | ],
203 | };
204 |
205 | const server = new SimpleRestServer({
206 | baseUrl: 'http://localhost:4000',
207 | data,
208 | });
209 |
210 | const response = await server.handleRequest({
211 | url: 'http://localhost:4000/posts/1',
212 | method: 'DELETE',
213 | params: {},
214 | requestBody: undefined,
215 | });
216 |
217 | expect(response).toEqual(
218 | expect.objectContaining({
219 | status: 200,
220 | body: {
221 | id: 1,
222 | title: 'test',
223 | },
224 | }),
225 | );
226 | });
227 | it('should correctly delete records with a string identifier', async () => {
228 | const data = {
229 | posts: [
230 | {
231 | id: 'bazinga',
232 | title: 'test',
233 | },
234 | ],
235 | };
236 |
237 | const server = new SimpleRestServer({
238 | baseUrl: 'http://localhost:4000',
239 | data,
240 | });
241 |
242 | const response = await server.handleRequest({
243 | url: 'http://localhost:4000/posts/bazinga',
244 | method: 'DELETE',
245 | params: {},
246 | requestBody: {
247 | id: 'bazinga',
248 | title: 'test',
249 | },
250 | });
251 |
252 | expect(response).toEqual(
253 | expect.objectContaining({
254 | status: 200,
255 | body: {
256 | id: 'bazinga',
257 | title: 'test',
258 | },
259 | }),
260 | );
261 | });
262 | });
263 | });
264 |
--------------------------------------------------------------------------------
/public/mockServiceWorker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /* tslint:disable */
3 |
4 | /**
5 | * Mock Service Worker.
6 | * @see https://github.com/mswjs/msw
7 | * - Please do NOT modify this file.
8 | */
9 |
10 | const PACKAGE_VERSION = '2.10.5'
11 | const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
12 | const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
13 | const activeClientIds = new Set()
14 |
15 | addEventListener('install', function () {
16 | self.skipWaiting()
17 | })
18 |
19 | addEventListener('activate', function (event) {
20 | event.waitUntil(self.clients.claim())
21 | })
22 |
23 | addEventListener('message', async function (event) {
24 | const clientId = Reflect.get(event.source || {}, 'id')
25 |
26 | if (!clientId || !self.clients) {
27 | return
28 | }
29 |
30 | const client = await self.clients.get(clientId)
31 |
32 | if (!client) {
33 | return
34 | }
35 |
36 | const allClients = await self.clients.matchAll({
37 | type: 'window',
38 | })
39 |
40 | switch (event.data) {
41 | case 'KEEPALIVE_REQUEST': {
42 | sendToClient(client, {
43 | type: 'KEEPALIVE_RESPONSE',
44 | })
45 | break
46 | }
47 |
48 | case 'INTEGRITY_CHECK_REQUEST': {
49 | sendToClient(client, {
50 | type: 'INTEGRITY_CHECK_RESPONSE',
51 | payload: {
52 | packageVersion: PACKAGE_VERSION,
53 | checksum: INTEGRITY_CHECKSUM,
54 | },
55 | })
56 | break
57 | }
58 |
59 | case 'MOCK_ACTIVATE': {
60 | activeClientIds.add(clientId)
61 |
62 | sendToClient(client, {
63 | type: 'MOCKING_ENABLED',
64 | payload: {
65 | client: {
66 | id: client.id,
67 | frameType: client.frameType,
68 | },
69 | },
70 | })
71 | break
72 | }
73 |
74 | case 'MOCK_DEACTIVATE': {
75 | activeClientIds.delete(clientId)
76 | break
77 | }
78 |
79 | case 'CLIENT_CLOSED': {
80 | activeClientIds.delete(clientId)
81 |
82 | const remainingClients = allClients.filter((client) => {
83 | return client.id !== clientId
84 | })
85 |
86 | // Unregister itself when there are no more clients
87 | if (remainingClients.length === 0) {
88 | self.registration.unregister()
89 | }
90 |
91 | break
92 | }
93 | }
94 | })
95 |
96 | addEventListener('fetch', function (event) {
97 | // Bypass navigation requests.
98 | if (event.request.mode === 'navigate') {
99 | return
100 | }
101 |
102 | // Opening the DevTools triggers the "only-if-cached" request
103 | // that cannot be handled by the worker. Bypass such requests.
104 | if (
105 | event.request.cache === 'only-if-cached' &&
106 | event.request.mode !== 'same-origin'
107 | ) {
108 | return
109 | }
110 |
111 | // Bypass all requests when there are no active clients.
112 | // Prevents the self-unregistered worked from handling requests
113 | // after it's been deleted (still remains active until the next reload).
114 | if (activeClientIds.size === 0) {
115 | return
116 | }
117 |
118 | const requestId = crypto.randomUUID()
119 | event.respondWith(handleRequest(event, requestId))
120 | })
121 |
122 | /**
123 | * @param {FetchEvent} event
124 | * @param {string} requestId
125 | */
126 | async function handleRequest(event, requestId) {
127 | const client = await resolveMainClient(event)
128 | const requestCloneForEvents = event.request.clone()
129 | const response = await getResponse(event, client, requestId)
130 |
131 | // Send back the response clone for the "response:*" life-cycle events.
132 | // Ensure MSW is active and ready to handle the message, otherwise
133 | // this message will pend indefinitely.
134 | if (client && activeClientIds.has(client.id)) {
135 | const serializedRequest = await serializeRequest(requestCloneForEvents)
136 |
137 | // Clone the response so both the client and the library could consume it.
138 | const responseClone = response.clone()
139 |
140 | sendToClient(
141 | client,
142 | {
143 | type: 'RESPONSE',
144 | payload: {
145 | isMockedResponse: IS_MOCKED_RESPONSE in response,
146 | request: {
147 | id: requestId,
148 | ...serializedRequest,
149 | },
150 | response: {
151 | type: responseClone.type,
152 | status: responseClone.status,
153 | statusText: responseClone.statusText,
154 | headers: Object.fromEntries(responseClone.headers.entries()),
155 | body: responseClone.body,
156 | },
157 | },
158 | },
159 | responseClone.body ? [serializedRequest.body, responseClone.body] : [],
160 | )
161 | }
162 |
163 | return response
164 | }
165 |
166 | /**
167 | * Resolve the main client for the given event.
168 | * Client that issues a request doesn't necessarily equal the client
169 | * that registered the worker. It's with the latter the worker should
170 | * communicate with during the response resolving phase.
171 | * @param {FetchEvent} event
172 | * @returns {Promise}
173 | */
174 | async function resolveMainClient(event) {
175 | const client = await self.clients.get(event.clientId)
176 |
177 | if (activeClientIds.has(event.clientId)) {
178 | return client
179 | }
180 |
181 | if (client?.frameType === 'top-level') {
182 | return client
183 | }
184 |
185 | const allClients = await self.clients.matchAll({
186 | type: 'window',
187 | })
188 |
189 | return allClients
190 | .filter((client) => {
191 | // Get only those clients that are currently visible.
192 | return client.visibilityState === 'visible'
193 | })
194 | .find((client) => {
195 | // Find the client ID that's recorded in the
196 | // set of clients that have registered the worker.
197 | return activeClientIds.has(client.id)
198 | })
199 | }
200 |
201 | /**
202 | * @param {FetchEvent} event
203 | * @param {Client | undefined} client
204 | * @param {string} requestId
205 | * @returns {Promise}
206 | */
207 | async function getResponse(event, client, requestId) {
208 | // Clone the request because it might've been already used
209 | // (i.e. its body has been read and sent to the client).
210 | const requestClone = event.request.clone()
211 |
212 | function passthrough() {
213 | // Cast the request headers to a new Headers instance
214 | // so the headers can be manipulated with.
215 | const headers = new Headers(requestClone.headers)
216 |
217 | // Remove the "accept" header value that marked this request as passthrough.
218 | // This prevents request alteration and also keeps it compliant with the
219 | // user-defined CORS policies.
220 | const acceptHeader = headers.get('accept')
221 | if (acceptHeader) {
222 | const values = acceptHeader.split(',').map((value) => value.trim())
223 | const filteredValues = values.filter(
224 | (value) => value !== 'msw/passthrough',
225 | )
226 |
227 | if (filteredValues.length > 0) {
228 | headers.set('accept', filteredValues.join(', '))
229 | } else {
230 | headers.delete('accept')
231 | }
232 | }
233 |
234 | return fetch(requestClone, { headers })
235 | }
236 |
237 | // Bypass mocking when the client is not active.
238 | if (!client) {
239 | return passthrough()
240 | }
241 |
242 | // Bypass initial page load requests (i.e. static assets).
243 | // The absence of the immediate/parent client in the map of the active clients
244 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
245 | // and is not ready to handle requests.
246 | if (!activeClientIds.has(client.id)) {
247 | return passthrough()
248 | }
249 |
250 | // Notify the client that a request has been intercepted.
251 | const serializedRequest = await serializeRequest(event.request)
252 | const clientMessage = await sendToClient(
253 | client,
254 | {
255 | type: 'REQUEST',
256 | payload: {
257 | id: requestId,
258 | ...serializedRequest,
259 | },
260 | },
261 | [serializedRequest.body],
262 | )
263 |
264 | switch (clientMessage.type) {
265 | case 'MOCK_RESPONSE': {
266 | return respondWithMock(clientMessage.data)
267 | }
268 |
269 | case 'PASSTHROUGH': {
270 | return passthrough()
271 | }
272 | }
273 |
274 | return passthrough()
275 | }
276 |
277 | /**
278 | * @param {Client} client
279 | * @param {any} message
280 | * @param {Array} transferrables
281 | * @returns {Promise}
282 | */
283 | function sendToClient(client, message, transferrables = []) {
284 | return new Promise((resolve, reject) => {
285 | const channel = new MessageChannel()
286 |
287 | channel.port1.onmessage = (event) => {
288 | if (event.data && event.data.error) {
289 | return reject(event.data.error)
290 | }
291 |
292 | resolve(event.data)
293 | }
294 |
295 | client.postMessage(message, [
296 | channel.port2,
297 | ...transferrables.filter(Boolean),
298 | ])
299 | })
300 | }
301 |
302 | /**
303 | * @param {Response} response
304 | * @returns {Response}
305 | */
306 | function respondWithMock(response) {
307 | // Setting response status code to 0 is a no-op.
308 | // However, when responding with a "Response.error()", the produced Response
309 | // instance will have status code set to 0. Since it's not possible to create
310 | // a Response instance with status code 0, handle that use-case separately.
311 | if (response.status === 0) {
312 | return Response.error()
313 | }
314 |
315 | const mockedResponse = new Response(response.body, response)
316 |
317 | Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
318 | value: true,
319 | enumerable: true,
320 | })
321 |
322 | return mockedResponse
323 | }
324 |
325 | /**
326 | * @param {Request} request
327 | */
328 | async function serializeRequest(request) {
329 | return {
330 | url: request.url,
331 | mode: request.mode,
332 | method: request.method,
333 | headers: Object.fromEntries(request.headers.entries()),
334 | cache: request.cache,
335 | credentials: request.credentials,
336 | destination: request.destination,
337 | integrity: request.integrity,
338 | redirect: request.redirect,
339 | referrer: request.referrer,
340 | referrerPolicy: request.referrerPolicy,
341 | body: await request.arrayBuffer(),
342 | keepalive: request.keepalive,
343 | }
344 | }
345 |
--------------------------------------------------------------------------------
/src/SimpleRestServer.ts:
--------------------------------------------------------------------------------
1 | import type { Collection } from './Collection.ts';
2 | import { Database, type DatabaseOptions } from './Database.ts';
3 | import type { Single } from './Single.ts';
4 | import type {
5 | APIServer,
6 | BaseResponse,
7 | FakeRestContext,
8 | CollectionItem,
9 | QueryFunction,
10 | NormalizedRequest,
11 | } from './types.ts';
12 |
13 | export class SimpleRestServer implements APIServer {
14 | baseUrl = '';
15 | defaultQuery: QueryFunction = () => ({});
16 | middlewares: Array;
17 | database: Database;
18 |
19 | constructor({
20 | baseUrl = '',
21 | defaultQuery = () => ({}),
22 | database,
23 | middlewares,
24 | ...options
25 | }: BaseServerOptions = {}) {
26 | this.baseUrl = baseUrl;
27 | this.defaultQuery = defaultQuery;
28 | this.middlewares = middlewares || [];
29 |
30 | if (database) {
31 | this.database = database;
32 | } else {
33 | this.database = new Database(options);
34 | }
35 | }
36 |
37 | /**
38 | * @param Function ResourceName => object
39 | */
40 | setDefaultQuery(query: QueryFunction) {
41 | this.defaultQuery = query;
42 | }
43 |
44 | getContext(normalizedRequest: NormalizedRequest): FakeRestContext {
45 | for (const name of this.database.getSingleNames()) {
46 | const matches = normalizedRequest.url?.match(
47 | new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`),
48 | );
49 | if (!matches) continue;
50 | return {
51 | ...normalizedRequest,
52 | single: name,
53 | };
54 | }
55 |
56 | const matches = normalizedRequest.url?.match(
57 | new RegExp(
58 | `^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w+|\\d+))?(\\?.*)?$`,
59 | ),
60 | );
61 | if (matches) {
62 | const name = matches[1];
63 | const params = Object.assign(
64 | {},
65 | this.defaultQuery(name),
66 | normalizedRequest.params,
67 | );
68 |
69 | return {
70 | ...normalizedRequest,
71 | collection: name,
72 | params,
73 | };
74 | }
75 |
76 | return normalizedRequest;
77 | }
78 |
79 | async handle(normalizedRequest: NormalizedRequest): Promise {
80 | const context = this.getContext(normalizedRequest);
81 | // Call middlewares
82 | let index = 0;
83 | const middlewares = [...this.middlewares];
84 |
85 | const next = (context: FakeRestContext) => {
86 | const middleware = middlewares[index++];
87 | if (middleware) {
88 | return middleware(context, next);
89 | }
90 | return this.handleRequest(context);
91 | };
92 |
93 | try {
94 | const response = await next(context);
95 | return response;
96 | } catch (error) {
97 | if (error instanceof Error) {
98 | throw error;
99 | }
100 |
101 | return error as BaseResponse;
102 | }
103 | }
104 |
105 | handleRequest(context: FakeRestContext): BaseResponse {
106 | // Handle Single Objects
107 | for (const name of this.database.getSingleNames()) {
108 | const matches = context.url?.match(
109 | new RegExp(`^${this.baseUrl}\\/(${name})(\\/?.*)?$`),
110 | );
111 | if (!matches) continue;
112 |
113 | if (context.method === 'GET') {
114 | try {
115 | return {
116 | status: 200,
117 | body: this.database.getOnly(name),
118 | headers: {
119 | 'Content-Type': 'application/json',
120 | },
121 | };
122 | } catch (error) {
123 | return {
124 | status: 404,
125 | headers: {},
126 | };
127 | }
128 | }
129 | if (context.method === 'PUT') {
130 | try {
131 | if (context.requestBody == null) {
132 | return {
133 | status: 400,
134 | headers: {},
135 | };
136 | }
137 | return {
138 | status: 200,
139 | body: this.database.updateOnly(
140 | name,
141 | context.requestBody,
142 | ),
143 | headers: {
144 | 'Content-Type': 'application/json',
145 | },
146 | };
147 | } catch (error) {
148 | return {
149 | status: 404,
150 | headers: {},
151 | };
152 | }
153 | }
154 | if (context.method === 'PATCH') {
155 | try {
156 | if (context.requestBody == null) {
157 | return {
158 | status: 400,
159 | headers: {},
160 | };
161 | }
162 | return {
163 | status: 200,
164 | body: this.database.updateOnly(
165 | name,
166 | context.requestBody,
167 | ),
168 | headers: {
169 | 'Content-Type': 'application/json',
170 | },
171 | };
172 | } catch (error) {
173 | return {
174 | status: 404,
175 | headers: {},
176 | };
177 | }
178 | }
179 | }
180 |
181 | // handle collections
182 | const matches = context.url?.match(
183 | new RegExp(
184 | `^${this.baseUrl}\\/([^\\/?]+)(\\/(\\w+|\\d+))?(\\?.*)?$`,
185 | ),
186 | );
187 | if (!matches) {
188 | return { status: 404, headers: {} };
189 | }
190 | const name = matches[1];
191 | const params = Object.assign(
192 | {},
193 | this.defaultQuery(name),
194 | context.params,
195 | );
196 | if (!matches[2]) {
197 | if (context.method === 'GET') {
198 | if (!this.database.getCollection(name)) {
199 | return { status: 404, headers: {} };
200 | }
201 | const count = this.database.getCount(
202 | name,
203 | params.filter || params.embed
204 | ? { filter: params.filter, embed: params.embed }
205 | : {},
206 | );
207 | if (count > 0) {
208 | const items = this.database.getAll(name, params);
209 | const first = params.range ? params.range[0] : 0;
210 | const last =
211 | params.range && params.range.length === 2
212 | ? Math.min(
213 | items.length - 1 + first,
214 | params.range[1],
215 | )
216 | : items.length - 1;
217 |
218 | return {
219 | status: items.length === count ? 200 : 206,
220 | body: items,
221 | headers: {
222 | 'Content-Type': 'application/json',
223 | 'Content-Range': `items ${first}-${last}/${count}`,
224 | },
225 | };
226 | }
227 |
228 | return {
229 | status: 200,
230 | body: [],
231 | headers: {
232 | 'Content-Type': 'application/json',
233 | 'Content-Range': 'items */0',
234 | },
235 | };
236 | }
237 | if (context.method === 'POST') {
238 | if (context.requestBody == null) {
239 | return {
240 | status: 400,
241 | headers: {},
242 | };
243 | }
244 |
245 | const newResource = this.database.addOne(
246 | name,
247 | context.requestBody,
248 | );
249 | const newResourceURI = `${this.baseUrl}/${name}/${
250 | newResource[
251 | this.database.getCollection(name).identifierName
252 | ]
253 | }`;
254 |
255 | return {
256 | status: 201,
257 | body: newResource,
258 | headers: {
259 | 'Content-Type': 'application/json',
260 | Location: newResourceURI,
261 | },
262 | };
263 | }
264 | } else {
265 | if (!this.database.getCollection(name)) {
266 | return { status: 404, headers: {} };
267 | }
268 | const id = matches[3];
269 | if (context.method === 'GET') {
270 | try {
271 | return {
272 | status: 200,
273 | body: this.database.getOne(name, id, params),
274 | headers: {
275 | 'Content-Type': 'application/json',
276 | },
277 | };
278 | } catch (error) {
279 | return {
280 | status: 404,
281 | headers: {},
282 | };
283 | }
284 | }
285 | if (context.method === 'PUT') {
286 | try {
287 | if (context.requestBody == null) {
288 | return {
289 | status: 400,
290 | headers: {},
291 | };
292 | }
293 | return {
294 | status: 200,
295 | body: this.database.updateOne(
296 | name,
297 | id,
298 | context.requestBody,
299 | ),
300 | headers: {
301 | 'Content-Type': 'application/json',
302 | },
303 | };
304 | } catch (error) {
305 | return {
306 | status: 404,
307 | headers: {},
308 | };
309 | }
310 | }
311 | if (context.method === 'PATCH') {
312 | try {
313 | if (context.requestBody == null) {
314 | return {
315 | status: 400,
316 | headers: {},
317 | };
318 | }
319 | return {
320 | status: 200,
321 | body: this.database.updateOne(
322 | name,
323 | id,
324 | context.requestBody,
325 | ),
326 | headers: {
327 | 'Content-Type': 'application/json',
328 | },
329 | };
330 | } catch (error) {
331 | return {
332 | status: 404,
333 | headers: {},
334 | };
335 | }
336 | }
337 | if (context.method === 'DELETE') {
338 | try {
339 | return {
340 | status: 200,
341 | body: this.database.removeOne(name, id),
342 | headers: {
343 | 'Content-Type': 'application/json',
344 | },
345 | };
346 | } catch (error) {
347 | return {
348 | status: 404,
349 | headers: {},
350 | };
351 | }
352 | }
353 | }
354 | return {
355 | status: 404,
356 | headers: {},
357 | };
358 | }
359 |
360 | addMiddleware(middleware: Middleware) {
361 | this.middlewares.push(middleware);
362 | }
363 |
364 | addCollection(
365 | name: string,
366 | collection: Collection,
367 | ) {
368 | this.database.addCollection(name, collection);
369 | }
370 |
371 | getCollection(name: string) {
372 | return this.database.getCollection(name);
373 | }
374 |
375 | getCollectionNames() {
376 | return this.database.getCollectionNames();
377 | }
378 |
379 | addSingle(
380 | name: string,
381 | single: Single,
382 | ) {
383 | this.database.addSingle(name, single);
384 | }
385 |
386 | getSingle(name: string) {
387 | return this.database.getSingle(name);
388 | }
389 |
390 | getSingleNames() {
391 | return this.database.getSingleNames();
392 | }
393 | }
394 |
395 | export type Middleware = (
396 | context: FakeRestContext,
397 | next: (context: FakeRestContext) => Promise | BaseResponse,
398 | ) => Promise | BaseResponse;
399 |
400 | export type BaseServerOptions = DatabaseOptions & {
401 | database?: Database;
402 | baseUrl?: string;
403 | batchUrl?: string | null;
404 | defaultQuery?: QueryFunction;
405 | middlewares?: Array;
406 | };
407 |
--------------------------------------------------------------------------------
/example/users.json:
--------------------------------------------------------------------------------
1 | {
2 | "users": [
3 | {
4 | "id": 1,
5 | "username": "janedoe",
6 | "password": "password",
7 | "fullName": "Jane Doe",
8 | "avatar": "data:image/jpeg;base64,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"
9 | },
10 | {
11 | "id": 2,
12 | "username": "johndoe",
13 | "password": "password",
14 | "fullName": "John Doe",
15 | "avatar": "data:image/jpeg;base64,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"
16 | }
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/src/Collection.ts:
--------------------------------------------------------------------------------
1 | import get from 'lodash/get.js';
2 | import matches from 'lodash/matches.js';
3 | import cloneDeep from 'lodash/cloneDeep.js';
4 | import type { Database } from './Database.ts';
5 | import type {
6 | CollectionItem,
7 | Embed,
8 | Filter,
9 | Predicate,
10 | Query,
11 | Range,
12 | Sort,
13 | } from './types.js';
14 |
15 | export class Collection {
16 | sequence = 0;
17 | items: T[] = [];
18 | database: Database | null = null;
19 | name: string | null = null;
20 | identifierName = 'id';
21 | getNewId: () => number | string;
22 |
23 | constructor({
24 | items = [],
25 | identifierName = 'id',
26 | getNewId,
27 | }: {
28 | items?: T[];
29 | identifierName?: string;
30 | getNewId?: () => number | string;
31 | } = {}) {
32 | if (!Array.isArray(items)) {
33 | throw new Error(
34 | "Can't initialize a Collection with anything else than an array of items",
35 | );
36 | }
37 | this.identifierName = identifierName;
38 | this.getNewId = getNewId || this.getNewIdFromSequence;
39 | items.map(this.addOne.bind(this));
40 | }
41 |
42 | /**
43 | * A Collection may need to access other collections (e.g. for embedding references)
44 | * This is done through a reference to the parent database.
45 | */
46 | setDatabase(database: Database) {
47 | this.database = database;
48 | }
49 |
50 | setName(name: string) {
51 | this.name = name;
52 | }
53 |
54 | /**
55 | * Get a one to many embedder function for a given resource name
56 | *
57 | * @example embed posts for an author
58 | *
59 | * authorsCollection._oneToManyEmbedder('posts')
60 | *
61 | * @returns Function item => item
62 | */
63 | _oneToManyEmbedder(resourceName: string) {
64 | if (this.name == null) {
65 | throw new Error("Can't embed references without a collection name");
66 | }
67 | const singularResourceName = this.name.slice(0, -1);
68 | const referenceName = `${singularResourceName}_id`;
69 | return (item: T) => {
70 | if (this.database == null) {
71 | throw new Error("Can't embed references without a database");
72 | }
73 | const otherCollection = this.database.collections[resourceName];
74 | if (!otherCollection)
75 | throw new Error(
76 | `Can't embed a non-existing collection ${resourceName}`,
77 | );
78 | if (Array.isArray(item[resourceName])) {
79 | // the many to one relationship is carried by an array of ids, e.g. { posts: [1, 2] } in authors
80 | // @ts-expect-error - For some reason, TS does not accept writing a generic types with the index signature
81 | item[resourceName] = otherCollection.getAll({
82 | filter: (i: T) =>
83 | item[resourceName].indexOf(
84 | i[otherCollection.identifierName],
85 | ) !== -1,
86 | });
87 | } else {
88 | // the many to one relationship is carried by references in the related collection, e.g. { author_id: 1 } in posts
89 | // @ts-expect-error - For some reason, TS does not accept writing a generic types with the index signature
90 | item[resourceName] = otherCollection.getAll({
91 | filter: (i: T) =>
92 | i[referenceName] === item[this.identifierName],
93 | });
94 | }
95 | return item;
96 | };
97 | }
98 |
99 | /**
100 | * Get a many to one embedder function for a given resource name
101 | *
102 | * @example embed author for a post
103 | *
104 | * postsCollection._manyToOneEmbedder('author')
105 | *
106 | * @returns Function item => item
107 | */
108 | _manyToOneEmbedder(resourceName: string) {
109 | const pluralResourceName = `${resourceName}s`;
110 | const referenceName = `${resourceName}_id`;
111 | return (item: T) => {
112 | if (this.database == null) {
113 | throw new Error("Can't embed references without a database");
114 | }
115 | const otherCollection =
116 | this.database.collections[pluralResourceName];
117 | if (!otherCollection)
118 | throw new Error(
119 | `Can't embed a non-existing collection ${resourceName}`,
120 | );
121 | try {
122 | // @ts-expect-error - For some reason, TS does not accept writing a generic types with the index signature
123 | item[resourceName] = otherCollection.getOne(
124 | item[referenceName],
125 | );
126 | } catch (e) {
127 | // resource doesn't exist in the related collection - do not embed
128 | }
129 | return item;
130 | };
131 | }
132 |
133 | /**
134 | * @param String[] An array of resource names, e.g. ['books', 'country']
135 | * @returns Function item => item
136 | */
137 | _itemEmbedder(embed: Embed) {
138 | const resourceNames = Array.isArray(embed) ? embed : [embed];
139 | const resourceEmbedders = resourceNames.map((resourceName) =>
140 | resourceName.endsWith('s')
141 | ? this._oneToManyEmbedder(resourceName)
142 | : this._manyToOneEmbedder(resourceName),
143 | );
144 | return (item: T) =>
145 | resourceEmbedders.reduce(
146 | (itemWithEmbeds, embedder) => embedder(itemWithEmbeds),
147 | item,
148 | );
149 | }
150 |
151 | getCount(query?: Query) {
152 | return this.getAll(query).length;
153 | }
154 |
155 | getAll(query?: Query) {
156 | let items = this.items.slice(0); // clone the array to avoid updating the core one
157 | if (query) {
158 | items = items.map((item) => Object.assign({}, item)); // clone item to avoid returning the original
159 |
160 | // Embed relationships first if requested
161 | if (query.embed && this.database) {
162 | items = items.map(this._itemEmbedder(query.embed));
163 | }
164 |
165 | // Apply filter
166 | if (query.filter) {
167 | items = filterItems(items, query.filter);
168 | }
169 |
170 | // Apply sort
171 | if (query.sort) {
172 | items = sortItems(items, query.sort);
173 | }
174 |
175 | // Apply range
176 | if (query.range) {
177 | items = rangeItems(items, query.range);
178 | }
179 | }
180 | return items;
181 | }
182 |
183 | getIndex(identifier: number | string) {
184 | return this.items.findIndex(
185 | // biome-ignore lint/suspicious/noDoubleEquals: we want implicit type coercion
186 | (item) => item[this.identifierName] == identifier,
187 | );
188 | }
189 |
190 | getOne(identifier: number | string, query?: Query) {
191 | const index = this.getIndex(identifier);
192 | if (index === -1) {
193 | throw new Error(`No item with identifier ${identifier}`);
194 | }
195 | let item = this.items[index];
196 | item = Object.assign({}, item); // clone item to avoid returning the original
197 | if (query?.embed && this.database) {
198 | item = this._itemEmbedder(query.embed)(item); // embed reference
199 | }
200 | return item;
201 | }
202 |
203 | getNewIdFromSequence() {
204 | return this.sequence++;
205 | }
206 |
207 | addOne(item: T) {
208 | const clone = cloneDeep(item);
209 | const identifier = clone[this.identifierName];
210 | if (identifier != null) {
211 | if (this.getIndex(identifier) !== -1) {
212 | throw new Error(
213 | `An item with the identifier ${identifier} already exists`,
214 | );
215 | }
216 | if (typeof identifier === 'number') {
217 | this.sequence = Math.max(this.sequence, identifier) + 1;
218 | }
219 | } else {
220 | // @ts-expect-error - For some reason, TS does not accept writing a generic types with the index signature
221 | clone[this.identifierName] = this.getNewId();
222 | }
223 | this.items.push(clone);
224 | return clone; // clone item to avoid returning the original;
225 | }
226 |
227 | updateOne(identifier: number | string, item: T) {
228 | const index = this.getIndex(identifier);
229 | if (index === -1) {
230 | throw new Error(`No item with identifier ${identifier}`);
231 | }
232 | for (const key in item) {
233 | this.items[index][key] = item[key];
234 | }
235 | return Object.assign({}, this.items[index]); // clone item to avoid returning the original
236 | }
237 |
238 | removeOne(identifier: number | string) {
239 | const index = this.getIndex(identifier);
240 | if (index === -1) {
241 | throw new Error(`No item with identifier ${identifier}`);
242 | }
243 | const item = this.items[index];
244 | this.items.splice(index, 1);
245 | // biome-ignore lint/suspicious/noDoubleEquals: we want implicit type coercion
246 | if (typeof identifier === 'number' && identifier == this.sequence - 1) {
247 | this.sequence--;
248 | }
249 | return item;
250 | }
251 | }
252 |
253 | const every = (
254 | array: T[],
255 | predicate: Predicate,
256 | ) => array.reduce((acc, value) => acc && predicate(value), true);
257 |
258 | const some = (
259 | array: T[],
260 | predicate: Predicate,
261 | ) => array.reduce((acc, value) => acc || predicate(value), false);
262 |
263 | const getArrayOfObjectsPaths = (
264 | keyParts: string[],
265 | item: T,
266 | ) =>
267 | keyParts.reduce(
268 | (acc, key, index) => {
269 | // If we already found an array, we don't need to explore further
270 | // For example with path `tags.name` when tags is an array of objects
271 | if (acc != null) {
272 | return acc;
273 | }
274 |
275 | const keyToArray = keyParts.slice(0, index + 1).join('.');
276 | const keyToItem = keyParts.slice(index + 1).join('.');
277 | const itemValue = get(item, keyToArray);
278 |
279 | // If the array is at the end of the key path, we will process it like we do normally with arrays
280 | // For example with path `deep.tags` where tags is the array. In this case, we return undefined
281 | return Array.isArray(itemValue) && index < keyParts.length - 1
282 | ? [keyToArray, keyToItem]
283 | : undefined;
284 | },
285 | undefined as Array | undefined,
286 | );
287 |
288 | const getSimpleFilter = (key: string, value: any) => {
289 | if (key.indexOf('_q') !== -1) {
290 | // text search
291 | const realKey = key.replace(/(_q)$/, '');
292 | const regex = new RegExp(value.toString(), 'i');
293 |
294 | return (item: T) =>
295 | get(item, realKey)?.toString().match(regex) !== null;
296 | }
297 | if (key.indexOf('_lte') !== -1) {
298 | // less than or equal
299 | const realKey = key.replace(/(_lte)$/, '');
300 | return (item: T) =>
301 | get(item, realKey) <= value;
302 | }
303 | if (key.indexOf('_gte') !== -1) {
304 | // less than or equal
305 | const realKey = key.replace(/(_gte)$/, '');
306 | return (item: T) =>
307 | get(item, realKey) >= value;
308 | }
309 | if (key.indexOf('_lt') !== -1) {
310 | // less than or equal
311 | const realKey = key.replace(/(_lt)$/, '');
312 | return (item: T) =>
313 | get(item, realKey) < value;
314 | }
315 | if (key.indexOf('_gt') !== -1) {
316 | // less than or equal
317 | const realKey = key.replace(/(_gt)$/, '');
318 | return (item: T) =>
319 | get(item, realKey) > value;
320 | }
321 | if (key.indexOf('_neq_any') !== -1) {
322 | // not equal to any
323 | const realKey = key.replace(/(_neq_any)$/, '');
324 | const finalValue = Array.isArray(value) ? value : [value];
325 | return (
326 | item: T,
327 | // biome-ignore lint/suspicious/noDoubleEquals: we want implicit type coercion
328 | ) => finalValue.every((val) => get(item, realKey) != val);
329 | }
330 | if (key.indexOf('_neq') !== -1) {
331 | // not equal
332 | const realKey = key.replace(/(_neq)$/, '');
333 | return (
334 | item: T,
335 | // biome-ignore lint/suspicious/noDoubleEquals: we want implicit type coercion
336 | ) => get(item, realKey) != value;
337 | }
338 | if (key.indexOf('_eq_any') !== -1) {
339 | // equal any
340 | const realKey = key.replace(/(_eq_any)$/, '');
341 | const finalValue = Array.isArray(value) ? value : [value];
342 | return (
343 | item: T,
344 | // biome-ignore lint/suspicious/noDoubleEquals: we want implicit type coercion
345 | ) => finalValue.some((val) => get(item, realKey) == val);
346 | }
347 | if (key.indexOf('_eq') !== -1) {
348 | // equal
349 | const realKey = key.replace(/(_eq)$/, '');
350 | return (
351 | item: T,
352 | // biome-ignore lint/suspicious/noDoubleEquals: we want implicit type coercion
353 | ) => get(item, realKey) == value;
354 | }
355 | if (key.indexOf('_inc_any') !== -1) {
356 | // include any
357 | const realKey = key.replace(/(_inc_any)$/, '');
358 | const finalValue = Array.isArray(value) ? value : [value];
359 | return (item: T) =>
360 | finalValue.some((val) => {
361 | const itemValue = get(item, realKey);
362 | if (Array.isArray(itemValue)) {
363 | return itemValue.includes(val);
364 | }
365 | if (typeof itemValue === 'string') {
366 | return itemValue.includes(val);
367 | }
368 | return false;
369 | });
370 | }
371 | if (key.indexOf('_inc') !== -1) {
372 | // includes all
373 | const realKey = key.replace(/(_inc)$/, '');
374 | const finalValue = Array.isArray(value) ? value : [value];
375 | return (item: T) =>
376 | finalValue.every((val) => {
377 | const itemValue = get(item, realKey);
378 | if (Array.isArray(itemValue)) {
379 | return itemValue.includes(val);
380 | }
381 | if (typeof itemValue === 'string') {
382 | return itemValue.includes(val);
383 | }
384 | return false;
385 | });
386 | }
387 | if (key.indexOf('_ninc_any') !== -1) {
388 | // does not include any
389 | const realKey = key.replace(/(_ninc_any)$/, '');
390 | const finalValue = Array.isArray(value) ? value : [value];
391 | return (item: T) =>
392 | finalValue.every((val) => {
393 | const itemValue = get(item, realKey);
394 | if (Array.isArray(itemValue)) {
395 | return !itemValue.includes(val);
396 | }
397 | if (typeof itemValue === 'string') {
398 | return !itemValue.includes(val);
399 | }
400 | return false;
401 | });
402 | }
403 | if (Array.isArray(value)) {
404 | return (item: T) => {
405 | if (Array.isArray(get(item, key))) {
406 | // array filter and array item value: where all items in values
407 | return every(value, (v) => {
408 | const itemValue = get(item, key);
409 | if (Array.isArray(itemValue)) {
410 | // biome-ignore lint/suspicious/noDoubleEquals: we want implicit type coercion
411 | return some(itemValue, (itemValue) => itemValue == v);
412 | }
413 | return false;
414 | });
415 | }
416 | // where item in values
417 | // biome-ignore lint/suspicious/noDoubleEquals: we want implicit type coercion
418 | return value.filter((v) => v == get(item, key)).length > 0;
419 | };
420 | }
421 |
422 | if (value == null) {
423 | // null or undefined filter
424 | return (item: T) =>
425 | get(item, key) == null;
426 | }
427 |
428 | if (typeof value === 'object') {
429 | return (item: T) =>
430 | matches(value)(get(item, key));
431 | }
432 |
433 | return (item: T) => {
434 | const itemValue = get(item, key);
435 | if (Array.isArray(itemValue) && typeof value === 'string') {
436 | // simple filter but array item value: where value in item
437 | return itemValue.indexOf(value) !== -1;
438 | }
439 | if (typeof itemValue === 'boolean' && typeof value === 'string') {
440 | // simple filter but boolean item value: boolean where
441 | return itemValue === (value === 'true');
442 | }
443 | // simple filter
444 | // biome-ignore lint/suspicious/noDoubleEquals: we want implicit type coercion
445 | return itemValue == value;
446 | };
447 | };
448 |
449 | function filterItems(
450 | items: T[],
451 | filter: Filter,
452 | ) {
453 | if (typeof filter === 'function') {
454 | return items.filter(filter);
455 | }
456 | if (filter instanceof Object) {
457 | // turn filter properties to functions
458 | const filterFunctions = Object.keys(filter).map((key) => {
459 | if (key === 'q' && typeof filter.q === 'string') {
460 | const regex = buildRegexSearch(filter.q);
461 |
462 | const filterWithQuery = <
463 | T2 extends CollectionItem = CollectionItem,
464 | >(
465 | item: T2,
466 | ) => {
467 | for (const itemKey in item) {
468 | const itemValue = item[itemKey];
469 | if (typeof itemValue === 'object') {
470 | if (filterWithQuery(itemValue as CollectionItem)) {
471 | return true;
472 | }
473 | }
474 |
475 | if (
476 | itemValue &&
477 | typeof itemValue === 'string' &&
478 | itemValue.match &&
479 | itemValue.match(regex) !== null
480 | )
481 | return true;
482 | }
483 | return false;
484 | };
485 | // full-text filter
486 | return filterWithQuery;
487 | }
488 |
489 | const keyParts = key.split('.');
490 | const value = filter[key];
491 | if (keyParts.length > 1) {
492 | return (
493 | item: T2,
494 | ): boolean => {
495 | const arrayOfObjectsPaths = getArrayOfObjectsPaths(
496 | keyParts,
497 | item,
498 | );
499 |
500 | if (arrayOfObjectsPaths) {
501 | const [arrayPath, valuePath] = arrayOfObjectsPaths;
502 | const itemValue = get(item, arrayPath);
503 | if (Array.isArray(itemValue)) {
504 | // Check wether any item in the array matches the filter
505 | const filteredArrayItems = filterItems(itemValue, {
506 | [valuePath]: value,
507 | });
508 | return filteredArrayItems.length > 0;
509 | }
510 | return false;
511 | }
512 | return getSimpleFilter(key, value)(item);
513 | };
514 | }
515 |
516 | return getSimpleFilter(key, value);
517 | });
518 | // only the items matching all filters functions are in (AND logic)
519 | return items.filter((item) =>
520 | filterFunctions.reduce(
521 | (selected, filterFunction) => selected && filterFunction(item),
522 | true,
523 | ),
524 | );
525 | }
526 | throw new Error('Unsupported filter type');
527 | }
528 |
529 | function sortItems(
530 | items: T[],
531 | sort: Sort,
532 | ) {
533 | if (typeof sort === 'function') {
534 | return items.sort(sort);
535 | }
536 | if (typeof sort === 'string') {
537 | return items.sort((a, b) => {
538 | const aValue = get(a, sort);
539 | const bValue = get(b, sort);
540 | if (aValue > bValue) {
541 | return 1;
542 | }
543 | if (aValue < bValue) {
544 | return -1;
545 | }
546 | return 0;
547 | });
548 | }
549 | if (Array.isArray(sort)) {
550 | const key = sort[0];
551 | const direction = sort[1].toLowerCase() === 'asc' ? 1 : -1;
552 | return items.sort((a: T, b: T) => {
553 | const aValue = get(a, key);
554 | const bValue = get(b, key);
555 | if (aValue > bValue) {
556 | return direction;
557 | }
558 | if (aValue < bValue) {
559 | return -1 * direction;
560 | }
561 | return 0;
562 | });
563 | }
564 | throw new Error('Unsupported sort type');
565 | }
566 |
567 | function rangeItems(
568 | items: T[],
569 | range: Range,
570 | ) {
571 | if (Array.isArray(range)) {
572 | return items.slice(
573 | range[0],
574 | range[1] !== undefined ? range[1] + 1 : undefined,
575 | );
576 | }
577 | throw new Error('Unsupported range type');
578 | }
579 |
580 | function buildRegexSearch(input: string) {
581 | // Trim the input to remove leading and trailing whitespace
582 | const trimmedInput = input.trim();
583 |
584 | // Escape special characters in the input to prevent regex injection
585 | const escapedInput = trimmedInput.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
586 |
587 | // Split the input into words
588 | const words = escapedInput.split(' ');
589 |
590 | // Create a regex pattern to match any of the words
591 | const pattern = words.map((word) => `(${word})`).join('|');
592 |
593 | // Create a new RegExp object with the pattern, case insensitive
594 | const regex = new RegExp(pattern, 'i');
595 |
596 | return regex;
597 | }
598 |
--------------------------------------------------------------------------------
/src/adapters/SinonAdapter.spec.ts:
--------------------------------------------------------------------------------
1 | import sinon, { type SinonFakeXMLHttpRequest } from 'sinon';
2 |
3 | import { SinonAdapter } from './SinonAdapter.ts';
4 | import type { BaseResponse } from '../types.ts';
5 |
6 | function getFakeXMLHTTPRequest(
7 | method: string,
8 | url: string,
9 | data?: any,
10 | ): SinonFakeXMLHttpRequest | null {
11 | const xhr = sinon.useFakeXMLHttpRequest();
12 | let request: SinonFakeXMLHttpRequest | null = null;
13 | xhr.onCreate = (xhr) => {
14 | request = xhr;
15 | };
16 | const myRequest = new XMLHttpRequest();
17 | myRequest.open(method, url, false);
18 | myRequest.send(data);
19 | xhr.restore();
20 | return request;
21 | }
22 |
23 | describe('SinonServer', () => {
24 | describe('addMiddleware', () => {
25 | it('should allow request transformation', async () => {
26 | const server = new SinonAdapter({
27 | data: {
28 | foo: [
29 | { id: 1, name: 'foo' },
30 | { id: 2, name: 'bar' },
31 | ],
32 | },
33 | middlewares: [
34 | (context, next) => {
35 | const start = context.params?._start
36 | ? context.params._start - 1
37 | : 0;
38 | const end =
39 | context.params?._end !== undefined
40 | ? context.params._end - 1
41 | : 19;
42 | if (!context.params) {
43 | context.params = {};
44 | }
45 | context.params.range = [start, end];
46 | return next(context);
47 | },
48 | ],
49 | });
50 | const handle = server.getHandler();
51 | let request: SinonFakeXMLHttpRequest | null;
52 | request = getFakeXMLHTTPRequest('GET', '/foo?_start=1&_end=1');
53 | if (request == null) throw new Error('request is null');
54 | await handle(request);
55 | expect(request?.status).toEqual(206);
56 | // @ts-ignore
57 | expect(request.responseText).toEqual('[{"id":1,"name":"foo"}]');
58 | expect(request?.getResponseHeader('Content-Range')).toEqual(
59 | 'items 0-0/2',
60 | );
61 | request = getFakeXMLHTTPRequest('GET', '/foo?_start=2&_end=2');
62 | if (request == null) throw new Error('request is null');
63 | await handle(request);
64 | expect(request?.status).toEqual(206);
65 | // @ts-ignore
66 | expect(request?.responseText).toEqual('[{"id":2,"name":"bar"}]');
67 | expect(request?.getResponseHeader('Content-Range')).toEqual(
68 | 'items 1-1/2',
69 | );
70 | });
71 |
72 | it('should allow response transformation', async () => {
73 | const server = new SinonAdapter({
74 | data: {
75 | foo: [
76 | { id: 1, name: 'foo' },
77 | { id: 2, name: 'bar' },
78 | ],
79 | },
80 | middlewares: [
81 | (context, next) => {
82 | const response = next(context);
83 | (response as BaseResponse).status = 418;
84 | return response;
85 | },
86 | (context, next) => {
87 | const response = next(context) as BaseResponse;
88 | response.body = {
89 | data: response.body,
90 | status: response.status,
91 | };
92 | return response;
93 | },
94 | ],
95 | });
96 | const handle = server.getHandler();
97 | const request = getFakeXMLHTTPRequest('GET', '/foo');
98 | if (request == null) throw new Error('request is null');
99 | await handle(request);
100 | expect(request.status).toEqual(418);
101 | // @ts-ignore
102 | expect(request.responseText).toEqual(
103 | '{"data":[{"id":1,"name":"foo"},{"id":2,"name":"bar"}],"status":200}',
104 | );
105 | });
106 | });
107 |
108 | describe('handle', () => {
109 | it('should respond a 404 to GET /whatever on non existing collection', async () => {
110 | const server = new SinonAdapter();
111 | const handle = server.getHandler();
112 | const request = getFakeXMLHTTPRequest('GET', '/foo');
113 | if (request == null) throw new Error('request is null');
114 | await handle(request);
115 | expect(request.status).toEqual(404); // not responded
116 | });
117 |
118 | it('should respond to GET /foo by sending all items in collection foo', async () => {
119 | const server = new SinonAdapter({
120 | data: {
121 | foo: [
122 | { id: 1, name: 'foo' },
123 | { id: 2, name: 'bar' },
124 | ],
125 | },
126 | });
127 | const handle = server.getHandler();
128 | const request = getFakeXMLHTTPRequest('GET', '/foo');
129 | if (request == null) throw new Error('request is null');
130 | await handle(request);
131 | expect(request.status).toEqual(200);
132 | // @ts-ignore
133 | expect(request.responseText).toEqual(
134 | '[{"id":1,"name":"foo"},{"id":2,"name":"bar"}]',
135 | );
136 | expect(request.getResponseHeader('Content-Type')).toEqual(
137 | 'application/json',
138 | );
139 | expect(request.getResponseHeader('Content-Range')).toEqual(
140 | 'items 0-1/2',
141 | );
142 | });
143 |
144 | it('should respond to GET /foo?queryString by sending all items in collection foo satisfying query', async () => {
145 | const server = new SinonAdapter({
146 | data: {
147 | foos: [
148 | { id: 0, name: 'c', arg: false },
149 | { id: 1, name: 'b', arg: true },
150 | { id: 2, name: 'a', arg: true },
151 | ],
152 | bars: [{ id: 0, name: 'a', foo_id: 1 }],
153 | },
154 | });
155 | const handle = server.getHandler();
156 | const request = getFakeXMLHTTPRequest(
157 | 'GET',
158 | '/foos?filter={"arg":true}&sort=name&slice=[0,10]&embed=["bars"]',
159 | );
160 | if (request == null) throw new Error('request is null');
161 | await handle(request);
162 | expect(request.status).toEqual(200);
163 | // @ts-ignore
164 | expect(request.responseText).toEqual(
165 | '[{"id":2,"name":"a","arg":true,"bars":[]},{"id":1,"name":"b","arg":true,"bars":[{"id":0,"name":"a","foo_id":1}]}]',
166 | );
167 | expect(request.getResponseHeader('Content-Type')).toEqual(
168 | 'application/json',
169 | );
170 | expect(request.getResponseHeader('Content-Range')).toEqual(
171 | 'items 0-1/2',
172 | );
173 | });
174 |
175 | it('should respond to GET /foo?queryString with pagination by sending the correct content-range header', async () => {
176 | const server = new SinonAdapter({
177 | data: { foo: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}] }, // 11 items
178 | });
179 | const handle = server.getHandler();
180 | let request: SinonFakeXMLHttpRequest | null;
181 | request = getFakeXMLHTTPRequest('GET', '/foo');
182 | if (request == null) throw new Error('request is null');
183 | await handle(request);
184 | expect(request.status).toEqual(200);
185 | expect(request.getResponseHeader('Content-Range')).toEqual(
186 | 'items 0-10/11',
187 | );
188 | request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]');
189 | if (request == null) throw new Error('request is null');
190 | await handle(request);
191 | expect(request.status).toEqual(206);
192 | expect(request.getResponseHeader('Content-Range')).toEqual(
193 | 'items 0-4/11',
194 | );
195 | request = getFakeXMLHTTPRequest('GET', '/foo?range=[5,9]');
196 | if (request == null) throw new Error('request is null');
197 | await handle(request);
198 | expect(request.status).toEqual(206);
199 | expect(request.getResponseHeader('Content-Range')).toEqual(
200 | 'items 5-9/11',
201 | );
202 | request = getFakeXMLHTTPRequest('GET', '/foo?range=[10,14]');
203 | if (request == null) throw new Error('request is null');
204 | await handle(request);
205 | expect(request.status).toEqual(206);
206 | expect(request.getResponseHeader('Content-Range')).toEqual(
207 | 'items 10-10/11',
208 | );
209 | });
210 |
211 | it('should respond to GET /foo on an empty collection with a []', async () => {
212 | const server = new SinonAdapter({
213 | data: { foo: [] },
214 | });
215 | const handle = server.getHandler();
216 | const request = getFakeXMLHTTPRequest('GET', '/foo');
217 | if (request == null) throw new Error('request is null');
218 | await handle(request);
219 | expect(request.status).toEqual(200);
220 | // @ts-ignore
221 | expect(request.responseText).toEqual('[]');
222 | expect(request.getResponseHeader('Content-Range')).toEqual(
223 | 'items */0',
224 | );
225 | });
226 |
227 | it('should respond to POST /foo by adding an item to collection foo', async () => {
228 | const server = new SinonAdapter({
229 | data: {
230 | foo: [
231 | { id: 1, name: 'foo' },
232 | { id: 2, name: 'bar' },
233 | ],
234 | },
235 | });
236 | const handle = server.getHandler();
237 | const request = getFakeXMLHTTPRequest(
238 | 'POST',
239 | '/foo',
240 | JSON.stringify({ name: 'baz' }),
241 | );
242 | if (request == null) throw new Error('request is null');
243 | await handle(request);
244 | expect(request.status).toEqual(201);
245 | // @ts-ignore
246 | expect(request.responseText).toEqual('{"name":"baz","id":3}');
247 | expect(request.getResponseHeader('Content-Type')).toEqual(
248 | 'application/json',
249 | );
250 | expect(request.getResponseHeader('Location')).toEqual('/foo/3');
251 | // @ts-ignore
252 | expect(server.server.database.getAll('foo')).toEqual([
253 | { id: 1, name: 'foo' },
254 | { id: 2, name: 'bar' },
255 | { id: 3, name: 'baz' },
256 | ]);
257 | });
258 |
259 | it('should respond to POST /foo by adding an item to collection foo, even if the collection does not exist', async () => {
260 | const server = new SinonAdapter();
261 | const handle = server.getHandler();
262 | const request = getFakeXMLHTTPRequest(
263 | 'POST',
264 | '/foo',
265 | JSON.stringify({ name: 'baz' }),
266 | );
267 | if (request == null) throw new Error('request is null');
268 | await handle(request);
269 | expect(request.status).toEqual(201);
270 | // @ts-ignore
271 | expect(request.responseText).toEqual('{"name":"baz","id":0}');
272 | expect(request.getResponseHeader('Content-Type')).toEqual(
273 | 'application/json',
274 | );
275 | expect(request.getResponseHeader('Location')).toEqual('/foo/0');
276 | // @ts-ignore
277 | expect(server.server.database.getAll('foo')).toEqual([
278 | { id: 0, name: 'baz' },
279 | ]);
280 | });
281 |
282 | it('should respond to GET /foo/:id by sending element of identifier id in collection foo', async () => {
283 | const server = new SinonAdapter({
284 | data: {
285 | foo: [
286 | { id: 1, name: 'foo' },
287 | { id: 2, name: 'bar' },
288 | ],
289 | },
290 | });
291 | const handle = server.getHandler();
292 | const request = getFakeXMLHTTPRequest('GET', '/foo/2');
293 | if (request == null) throw new Error('request is null');
294 | await handle(request);
295 | expect(request.status).toEqual(200);
296 | // @ts-ignore
297 | expect(request.responseText).toEqual('{"id":2,"name":"bar"}');
298 | expect(request.getResponseHeader('Content-Type')).toEqual(
299 | 'application/json',
300 | );
301 | });
302 |
303 | it('should respond to GET /foo/:id on a non-existing id with a 404', async () => {
304 | const server = new SinonAdapter({ data: { foo: [] } });
305 | const handle = server.getHandler();
306 | const request = getFakeXMLHTTPRequest('GET', '/foo/3');
307 | if (request == null) throw new Error('request is null');
308 | await handle(request);
309 | expect(request.status).toEqual(404);
310 | });
311 |
312 | it('should respond to PUT /foo/:id by updating element of identifier id in collection foo', async () => {
313 | const server = new SinonAdapter({
314 | data: {
315 | foo: [
316 | { id: 1, name: 'foo' },
317 | { id: 2, name: 'bar' },
318 | ],
319 | },
320 | });
321 | const handle = server.getHandler();
322 | const request = getFakeXMLHTTPRequest(
323 | 'PUT',
324 | '/foo/2',
325 | JSON.stringify({ name: 'baz' }),
326 | );
327 | if (request == null) throw new Error('request is null');
328 | await handle(request);
329 | expect(request.status).toEqual(200);
330 | // @ts-ignore
331 | expect(request.responseText).toEqual('{"id":2,"name":"baz"}');
332 | expect(request.getResponseHeader('Content-Type')).toEqual(
333 | 'application/json',
334 | );
335 | // @ts-ignore
336 | expect(server.server.database.getAll('foo')).toEqual([
337 | { id: 1, name: 'foo' },
338 | { id: 2, name: 'baz' },
339 | ]);
340 | });
341 |
342 | it('should respond to PUT /foo/:id on a non-existing id with a 404', async () => {
343 | const server = new SinonAdapter();
344 | const handle = server.getHandler();
345 | const request = getFakeXMLHTTPRequest(
346 | 'PUT',
347 | '/foo/3',
348 | JSON.stringify({ name: 'baz' }),
349 | );
350 | if (request == null) throw new Error('request is null');
351 | await handle(request);
352 | expect(request.status).toEqual(404);
353 | });
354 |
355 | it('should respond to PATCH /foo/:id by updating element of identifier id in collection foo', async () => {
356 | const server = new SinonAdapter({
357 | data: {
358 | foo: [
359 | { id: 1, name: 'foo' },
360 | { id: 2, name: 'bar' },
361 | ],
362 | },
363 | });
364 | const handle = server.getHandler();
365 | const request = getFakeXMLHTTPRequest(
366 | 'PATCH',
367 | '/foo/2',
368 | JSON.stringify({ name: 'baz' }),
369 | );
370 | if (request == null) throw new Error('request is null');
371 | await handle(request);
372 | expect(request.status).toEqual(200);
373 | // @ts-ignore
374 | expect(request.responseText).toEqual('{"id":2,"name":"baz"}');
375 | expect(request.getResponseHeader('Content-Type')).toEqual(
376 | 'application/json',
377 | );
378 | // @ts-ignore
379 | expect(server.server.database.getAll('foo')).toEqual([
380 | { id: 1, name: 'foo' },
381 | { id: 2, name: 'baz' },
382 | ]);
383 | });
384 |
385 | it('should respond to PATCH /foo/:id on a non-existing id with a 404', async () => {
386 | const server = new SinonAdapter({ data: { foo: [] } });
387 | const handle = server.getHandler();
388 | const request = getFakeXMLHTTPRequest(
389 | 'PATCH',
390 | '/foo/3',
391 | JSON.stringify({ name: 'baz' }),
392 | );
393 | if (request == null) throw new Error('request is null');
394 | await handle(request);
395 | expect(request.status).toEqual(404);
396 | });
397 |
398 | it('should respond to DELETE /foo/:id by removing element of identifier id in collection foo', async () => {
399 | const server = new SinonAdapter({
400 | data: {
401 | foo: [
402 | { id: 1, name: 'foo' },
403 | { id: 2, name: 'bar' },
404 | ],
405 | },
406 | });
407 | const handle = server.getHandler();
408 | const request = getFakeXMLHTTPRequest('DELETE', '/foo/2');
409 | if (request == null) throw new Error('request is null');
410 | await handle(request);
411 | expect(request.status).toEqual(200);
412 | // @ts-ignore
413 | expect(request.responseText).toEqual('{"id":2,"name":"bar"}');
414 | expect(request.getResponseHeader('Content-Type')).toEqual(
415 | 'application/json',
416 | );
417 | // @ts-ignore
418 | expect(server.server.database.getAll('foo')).toEqual([
419 | { id: 1, name: 'foo' },
420 | ]);
421 | });
422 |
423 | it('should respond to DELETE /foo/:id on a non-existing id with a 404', async () => {
424 | const server = new SinonAdapter({ data: { foo: [] } });
425 | const handle = server.getHandler();
426 | const request = getFakeXMLHTTPRequest('DELETE', '/foo/3');
427 | if (request == null) throw new Error('request is null');
428 | await handle(request);
429 | expect(request.status).toEqual(404);
430 | });
431 |
432 | it('should respond to GET /foo/ with single item', async () => {
433 | const server = new SinonAdapter({
434 | data: { foo: { name: 'foo' } },
435 | });
436 | const handle = server.getHandler();
437 | const request = getFakeXMLHTTPRequest('GET', '/foo');
438 | if (request == null) throw new Error('request is null');
439 | await handle(request);
440 | expect(request.status).toEqual(200);
441 | // @ts-ignore
442 | expect(request.responseText).toEqual('{"name":"foo"}');
443 | expect(request.getResponseHeader('Content-Type')).toEqual(
444 | 'application/json',
445 | );
446 | });
447 |
448 | it('should respond to PUT /foo/ by updating the singleton record', async () => {
449 | const server = new SinonAdapter({
450 | data: { foo: { name: 'foo' } },
451 | });
452 | const handle = server.getHandler();
453 | const request = getFakeXMLHTTPRequest(
454 | 'PUT',
455 | '/foo/',
456 | JSON.stringify({ name: 'baz' }),
457 | );
458 | if (request == null) throw new Error('request is null');
459 | await handle(request);
460 | expect(request.status).toEqual(200);
461 | // @ts-ignore
462 | expect(request.responseText).toEqual('{"name":"baz"}');
463 | expect(request.getResponseHeader('Content-Type')).toEqual(
464 | 'application/json',
465 | );
466 | // @ts-ignore
467 | expect(server.server.database.getOnly('foo')).toEqual({
468 | name: 'baz',
469 | });
470 | });
471 |
472 | it('should respond to PATCH /foo/ by updating the singleton record', async () => {
473 | const server = new SinonAdapter({
474 | data: { foo: { name: 'foo' } },
475 | });
476 | const handle = server.getHandler();
477 | const request = getFakeXMLHTTPRequest(
478 | 'PATCH',
479 | '/foo/',
480 | JSON.stringify({ name: 'baz' }),
481 | );
482 | if (request == null) throw new Error('request is null');
483 | await handle(request);
484 | expect(request.status).toEqual(200);
485 | // @ts-ignore
486 | expect(request.responseText).toEqual('{"name":"baz"}');
487 | expect(request.getResponseHeader('Content-Type')).toEqual(
488 | 'application/json',
489 | );
490 | // @ts-ignore
491 | expect(server.server.database.getOnly('foo')).toEqual({
492 | name: 'baz',
493 | });
494 | });
495 | });
496 |
497 | describe('setDefaultQuery', () => {
498 | it('should set the default query string', async () => {
499 | const server = new SinonAdapter({
500 | data: { foo: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}] }, // 10 items
501 | defaultQuery: () => ({ range: [2, 4] }),
502 | });
503 | const handle = server.getHandler();
504 | const request = getFakeXMLHTTPRequest('GET', '/foo');
505 | if (request == null) throw new Error('request is null');
506 | await handle(request);
507 | expect(request.status).toEqual(206);
508 | expect(request.getResponseHeader('Content-Range')).toEqual(
509 | 'items 2-4/10',
510 | );
511 | const expected = [{ id: 2 }, { id: 3 }, { id: 4 }];
512 | // @ts-ignore
513 | expect(request.responseText).toEqual(JSON.stringify(expected));
514 | });
515 |
516 | it('should not override any provided query string', async () => {
517 | const server = new SinonAdapter({
518 | data: { foo: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}] }, // 10 items
519 | defaultQuery: () => ({ range: [2, 4] }),
520 | });
521 | const handle = server.getHandler();
522 | const request = getFakeXMLHTTPRequest('GET', '/foo?range=[0,4]');
523 | if (request == null) throw new Error('request is null');
524 | await handle(request);
525 | expect(request.status).toEqual(206);
526 | expect(request.getResponseHeader('Content-Range')).toEqual(
527 | 'items 0-4/10',
528 | );
529 | const expected = [
530 | { id: 0 },
531 | { id: 1 },
532 | { id: 2 },
533 | { id: 3 },
534 | { id: 4 },
535 | ];
536 | // @ts-ignore
537 | expect(request.responseText).toEqual(JSON.stringify(expected));
538 | });
539 | });
540 | });
541 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FakeRest
2 |
3 | A browser library that intercepts AJAX calls to mock a REST server based on JSON data.
4 |
5 | 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.
6 |
7 | 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)).
8 |
9 | ## Installation
10 |
11 | ```sh
12 | npm install fakerest --save-dev
13 | ```
14 |
15 | ## Usage
16 |
17 | 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.
18 |
19 | ### MSW
20 |
21 | Install [MSW](https://mswjs.io/) and initialize it:
22 |
23 | ```sh
24 | npm install msw@latest --save-dev
25 | npx msw init # eg: public
26 | ```
27 |
28 | Then configure an MSW worker:
29 |
30 | ```js
31 | // in ./src/fakeServer.js
32 | import { http } from 'msw';
33 | import { setupWorker } from "msw/browser";
34 | import { getMswHandler } from "fakerest";
35 |
36 | const handler = getMswHandler({
37 | baseUrl: 'http://localhost:3000',
38 | data: {
39 | 'authors': [
40 | { id: 0, first_name: 'Leo', last_name: 'Tolstoi' },
41 | { id: 1, first_name: 'Jane', last_name: 'Austen' }
42 | ],
43 | 'books': [
44 | { id: 0, author_id: 0, title: 'Anna Karenina' },
45 | { id: 1, author_id: 0, title: 'War and Peace' },
46 | { id: 2, author_id: 1, title: 'Pride and Prejudice' },
47 | { id: 3, author_id: 1, title: 'Sense and Sensibility' }
48 | ],
49 | 'settings': {
50 | language: 'english',
51 | preferred_format: 'hardback',
52 | }
53 | }
54 | });
55 | export const worker = setupWorker(
56 | // Make sure you use a RegExp to target all calls to the API
57 | http.all(/http:\/\/localhost:3000/, handler)
58 | );
59 | ```
60 |
61 | Finally, call the `worker.start()` method before rendering your application. For instance, in a Vite React application:
62 |
63 | ```js
64 | import React from "react";
65 | import ReactDom from "react-dom";
66 | import { App } from "./App";
67 | import { worker } from "./fakeServer";
68 |
69 | worker.start({
70 | quiet: true, // Instruct MSW to not log requests in the console
71 | onUnhandledRequest: 'bypass', // Instruct MSW to ignore requests we don't handle
72 | }).then(() => {
73 | ReactDom.render(, document.getElementById("root"));
74 | });
75 | ```
76 |
77 | FakeRest will now intercept every `fetch` request to the REST server.
78 |
79 | ### fetch-mock
80 |
81 | Install [fetch-mock](https://www.wheresrhys.co.uk/fetch-mock/):
82 |
83 | ```sh
84 | npm install fetch-mock --save-dev
85 | ```
86 |
87 | You can then create a handler and pass it to fetch-mock:
88 |
89 | ```js
90 | import fetchMock from 'fetch-mock';
91 | import { getFetchMockHandler } from "fakerest";
92 |
93 | const handler = getFetchMockHandler({
94 | baseUrl: 'http://localhost:3000',
95 | data: {
96 | 'authors': [
97 | { id: 0, first_name: 'Leo', last_name: 'Tolstoi' },
98 | { id: 1, first_name: 'Jane', last_name: 'Austen' }
99 | ],
100 | 'books': [
101 | { id: 0, author_id: 0, title: 'Anna Karenina' },
102 | { id: 1, author_id: 0, title: 'War and Peace' },
103 | { id: 2, author_id: 1, title: 'Pride and Prejudice' },
104 | { id: 3, author_id: 1, title: 'Sense and Sensibility' }
105 | ],
106 | 'settings': {
107 | language: 'english',
108 | preferred_format: 'hardback',
109 | }
110 | }
111 | });
112 |
113 | fetchMock.mock('begin:http://localhost:3000', handler);
114 | ```
115 |
116 | FakeRest will now intercept every `fetch` request to the REST server.
117 |
118 | ### Sinon
119 |
120 | Install [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/):
121 |
122 | ```sh
123 | npm install sinon --save-dev
124 | ```
125 |
126 | Then, configure a Sinon server:
127 |
128 | ```js
129 | import sinon from 'sinon';
130 | import { getSinonHandler } from "fakerest";
131 |
132 | const handler = getSinonHandler({
133 | baseUrl: 'http://localhost:3000',
134 | data: {
135 | 'authors': [
136 | { id: 0, first_name: 'Leo', last_name: 'Tolstoi' },
137 | { id: 1, first_name: 'Jane', last_name: 'Austen' }
138 | ],
139 | 'books': [
140 | { id: 0, author_id: 0, title: 'Anna Karenina' },
141 | { id: 1, author_id: 0, title: 'War and Peace' },
142 | { id: 2, author_id: 1, title: 'Pride and Prejudice' },
143 | { id: 3, author_id: 1, title: 'Sense and Sensibility' }
144 | ],
145 | 'settings': {
146 | language: 'english',
147 | preferred_format: 'hardback',
148 | }
149 | },
150 | });
151 |
152 | // use sinon.js to monkey-patch XmlHttpRequest
153 | const sinonServer = sinon.fakeServer.create();
154 | // this is required when doing asynchronous XmlHttpRequest
155 | sinonServer.autoRespond = true;
156 | sinonServer.respondWith(handler);
157 | ```
158 |
159 | FakeRest will now intercept every `XMLHttpRequest` request to the REST server.
160 |
161 | ## REST Syntax
162 |
163 | FakeRest uses a simple REST syntax described below.
164 |
165 | ### Get A Collection of records
166 |
167 | `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.
168 |
169 | GET /books?filter={"author_id":1}&embed=["author"]&sort=["title","desc"]&range=[0-9]
170 |
171 | HTTP 1.1 200 OK
172 | Content-Range: items 0-1/2
173 | Content-Type: application/json
174 | [
175 | { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } },
176 | { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }
177 | ]
178 |
179 | 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.
180 |
181 | GET /books?filter={"author_id":1} // return books where author_id is equal to 1
182 | HTTP 1.1 200 OK
183 | Content-Range: items 0-1/2
184 | Content-Type: application/json
185 | [
186 | { "id": 2, "author_id": 1, "title": "Pride and Prejudice" },
187 | { "id": 3, "author_id": 1, "title": "Sense and Sensibility" }
188 | ]
189 |
190 | // array values are possible
191 | GET /books?filter={"id":[2,3]} // return books where id is in [2,3]
192 | HTTP 1.1 200 OK
193 | Content-Range: items 0-1/2
194 | Content-Type: application/json
195 | [
196 | { "id": 2, "author_id": 1, "title": "Pride and Prejudice" },
197 | { "id": 3, "author_id": 1, "title": "Sense and Sensibility" }
198 | ]
199 |
200 | // use the special "q" filter to make a full-text search on all text fields
201 | GET /books?filter={"q":"and"} // return books where any of the book properties contains the string 'and'
202 |
203 | HTTP 1.1 200 OK
204 | Content-Range: items 0-2/3
205 | Content-Type: application/json
206 | [
207 | { "id": 1, "author_id": 0, "title": "War and Peace" },
208 | { "id": 2, "author_id": 1, "title": "Pride and Prejudice" },
209 | { "id": 3, "author_id": 1, "title": "Sense and Sensibility" }
210 | ]
211 |
212 | // use _gt, _gte, _lte, _lt, or _neq suffix on filter names to make range queries
213 | GET /books?filter={"price_lte":20} // return books where the price is less than or equal to 20
214 | GET /books?filter={"price_gt":20} // return books where the price is greater than 20
215 |
216 | // when the filter object contains more than one property, the criteria combine with an AND logic
217 | GET /books?filter={"published_at_gte":"2015-06-12","published_at_lte":"2015-06-15"} // return books published between two dates
218 |
219 | You can also filter by relationship fields when using embed:
220 |
221 | GET /books?embed=["author"]&filter={"author.name":"Leo Tolstoi"} // return books by Leo Tolstoi
222 | GET /books?embed=["author"]&filter={"author.age_gte":50} // return books by authors aged 50 or more
223 |
224 | The `sort` param must be a serialized array literal defining first the property used for sorting, then the sorting direction.
225 |
226 | GET /author?sort=["date_of_birth","asc"] // return authors, the oldest first
227 | GET /author?sort=["date_of_birth","desc"] // return authors, the youngest first
228 |
229 | You can also sort by relationship fields when using embed:
230 |
231 | GET /books?embed=["author"]&sort=["author.name","asc"] // return books sorted by author name
232 | GET /books?embed=["author"]&sort=["author.name","desc"] // return books sorted by author name in reverse order
233 |
234 | The `range` param defines the number of results by specifying the rank of the first and last results. The first result is #0.
235 |
236 | GET /books?range=[0-9] // return the first 10 books
237 | GET /books?range=[10-19] // return the 10 next books
238 |
239 | The `embed` param sets the related objects or collections to be embedded in the response.
240 |
241 | // embed author in books
242 | GET /books?embed=["author"]
243 | HTTP 1.1 200 OK
244 | Content-Range: items 0-3/4
245 | Content-Type: application/json
246 | [
247 | { "id": 0, "author_id": 0, "title": "Anna Karenina", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } },
248 | { "id": 1, "author_id": 0, "title": "War and Peace", "author": { "id": 0, "first_name": "Leo", "last_name": "Tolstoi" } },
249 | { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } },
250 | { "id": 3, "author_id": 1, "title": "Sense and Sensibility", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }
251 | ]
252 |
253 | // embed books in author
254 | GET /authors?embed=["books"]
255 | HTTP 1.1 200 OK
256 | Content-Range: items 0-1/2
257 | Content-Type: application/json
258 | [
259 | { 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' }] },
260 | { 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' }] }
261 | ]
262 |
263 | // you can embed several objects
264 | GET /authors?embed=["books","country"]
265 |
266 | ### Get A Single Record
267 |
268 | `GET /[name]/:id` returns a JSON object, and a status 200, unless the resource doesn't exist.
269 |
270 | GET /books/2
271 |
272 | HTTP 1.1 200 OK
273 | Content-Type: application/json
274 | { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }
275 |
276 | The `embed` param sets the related objects or collections to be embedded in the response.
277 |
278 | GET /books/2?embed=['author']
279 |
280 | HTTP 1.1 200 OK
281 | Content-Type: application/json
282 | { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }
283 |
284 | ### Create A Record
285 |
286 | `POST /[name]` returns a status 201 with a `Location` header for the newly created resource, and the new resource in the body.
287 |
288 | POST /books
289 | { "author_id": 1, "title": "Emma" }
290 |
291 | HTTP 1.1 201 Created
292 | Location: /books/4
293 | Content-Type: application/json
294 | { "author_id": 1, "title": "Emma", "id": 4 }
295 |
296 | ### Update A Record
297 |
298 | `PUT /[name]/:id` returns the modified JSON object, and a status 200, unless the resource doesn't exist.
299 |
300 | PUT /books/2
301 | { "author_id": 1, "title": "Pride and Prejudice" }
302 |
303 | HTTP 1.1 200 OK
304 | Content-Type: application/json
305 | { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }
306 |
307 | ### Delete A Single Record
308 |
309 | `DELETE /[name]/:id` returns the deleted JSON object, and a status 200, unless the resource doesn't exist.
310 |
311 | DELETE /books/2
312 |
313 | HTTP 1.1 200 OK
314 | Content-Type: application/json
315 | { "id": 2, "author_id": 1, "title": "Pride and Prejudice" }
316 |
317 | ### Supported Filters
318 |
319 | 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:
320 |
321 | GET /books?filter={"price_lte":20} // return books where the price is less than or equal to 20
322 |
323 | - `_eq`: check for equality on simple values:
324 |
325 | GET /books?filter={"price_eq":20} // return books where the price is equal to 20
326 |
327 | - `_neq`: check for inequality on simple values
328 |
329 | GET /books?filter={"price_neq":20} // return books where the price is not equal to 20
330 |
331 | - `_eq_any`: check for equality on any passed values
332 |
333 | GET /books?filter={"price_eq_any":[20, 30]} // return books where the price is equal to 20 or 30
334 |
335 | - `_neq_any`: check for inequality on any passed values
336 |
337 | GET /books?filter={"price_neq_any":[20, 30]} // return books where the price is not equal to 20 nor 30
338 |
339 | - `_inc_any`: check for items that include any of the passed values
340 |
341 | GET /books?filter={"authors_inc_any":['William Gibson', 'Pat Cadigan']} // return books where authors include either 'William Gibson' or 'Pat Cadigan' or both
342 |
343 | - `_q`: check for items that contain the provided text
344 |
345 | GET /books?filter={"author_q":['Gibson']} // return books where the author includes 'Gibson' not considering the other fields
346 |
347 | - `_lt`: check for items that have a value lower than the provided value
348 |
349 | GET /books?filter={"price_lte":100} // return books that have a price lower that 100
350 |
351 | - `_lte`: check for items that have a value lower than or equal to the provided value
352 |
353 | GET /books?filter={"price_lte":100} // return books that have a price lower or equal to 100
354 |
355 | - `_gt`: check for items that have a value greater than the provided value
356 |
357 | GET /books?filter={"price_gte":100} // return books that have a price greater that 100
358 |
359 | - `_gte`: check for items that have a value greater than or equal to the provided value
360 |
361 | GET /books?filter={"price_gte":100} // return books that have a price greater or equal to 100
362 |
363 | ### Single Elements
364 |
365 | FakeRest allows you to define a single element, such as a user profile or global settings, that can be fetched, updated, or deleted.
366 |
367 | GET /settings
368 |
369 | HTTP 1.1 200 OK
370 | Content-Type: application/json
371 | { "language": "english", "preferred_format": "hardback" }
372 |
373 | PUT /settings
374 | { "language": "french", "preferred_format": "paperback" }
375 |
376 | HTTP 1.1 200 OK
377 | Content-Type: application/json
378 | { "language": "french", "preferred_format": "paperback" }
379 |
380 | DELETE /settings
381 |
382 | HTTP 1.1 200 OK
383 | Content-Type: application/json
384 | { "language": "french", "preferred_format": "paperback" }
385 |
386 | ## Middlewares
387 |
388 | Middlewares let you intercept requests and simulate server features such as:
389 | - authentication checks
390 | - server-side validation
391 | - server dynamically generated values
392 | - simulate response delays
393 |
394 | You can define middlewares on all handlers, by passing a `middlewares` option:
395 |
396 | ```js
397 | import { getMswHandler } from 'fakerest';
398 | import { data } from './data';
399 |
400 | const handler = getMswHandler({
401 | baseUrl: 'http://my.custom.domain',
402 | data,
403 | middlewares: [
404 | async (context, next) => {
405 | if (context.headers.Authorization === undefined) {
406 | return {
407 | status: 401,
408 | headers: {},
409 | };
410 | }
411 |
412 | return next(context);
413 | },
414 | withDelay(300),
415 | ],
416 | });
417 | ```
418 |
419 | A middleware is a function that receives 2 parameters:
420 | - The FakeRest `context`, an object containing the data extracted from the request that FakeRest uses to build the response. It has the following properties:
421 | - `method`: The request method as a string (`GET`, `POST`, `PATCH` or `PUT`)
422 | - `url`: The request URL as a string
423 | - `headers`: The request headers as an object where keys are header names
424 | - `requestBody`: The parsed request data if any
425 | - `params`: The request parameters from the URL search (e.g. the identifier of the requested record)
426 | - `collection`: The name of the targeted [collection](#collection) (e.g. `posts`)
427 | - `single`: The name of the targeted [single](#single) (e.g. `settings`)
428 | - A `next` function to call the next middleware in the chain, to which you must pass the `context`
429 |
430 | 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:
431 | - `status`: The response status as a number (e.g. `200`)
432 | - `headers`: The response HTTP headers as an object where keys are header names
433 | - `body`: The response body which will be stringified
434 |
435 | ### Authentication Checks
436 |
437 | Here's how to implement an authentication check:
438 |
439 | ```js
440 | const handler = getMswHandler({
441 | baseUrl: 'http://my.custom.domain',
442 | data,
443 | middlewares: [
444 | async (context, next) => {
445 | if (context.headers.Authorization === undefined) {
446 | return { status: 401, headers: {} };
447 | }
448 | return next(context);
449 | }
450 | ]
451 | });
452 | ```
453 |
454 | ### Server-Side Validation
455 |
456 | Here's how to implement server-side validation:
457 |
458 | ```js
459 | const handler = getMswHandler({
460 | baseUrl: 'http://my.custom.domain',
461 | data,
462 | middlewares: [
463 | async (context, next) => {
464 | if (
465 | context.collection === "books" &&
466 | request.method === "POST" &&
467 | !context.requestBody?.title
468 | ) {
469 | return {
470 | status: 400,
471 | headers: {},
472 | body: {
473 | errors: {
474 | title: 'An article with this title already exists. The title must be unique.',
475 | },
476 | },
477 | };
478 | }
479 |
480 | return next(context);
481 | }
482 | ]
483 | });
484 | ```
485 |
486 | ### Dynamically Generated Values
487 |
488 | Here's how to implement dynamically generated values on creation:
489 |
490 | ```js
491 | const handler = getMswHandler({
492 | baseUrl: 'http://my.custom.domain',
493 | data,
494 | middlewares: [
495 | async (context, next) => {
496 | if (
497 | context.collection === 'books' &&
498 | context.method === 'POST'
499 | ) {
500 | const response = await next(context);
501 | response.body.updatedAt = new Date().toISOString();
502 | return response;
503 | }
504 |
505 | return next(context);
506 | }
507 | ]
508 | });
509 | ```
510 |
511 | ### Simulate Response Delays
512 |
513 | Here's how to simulate response delays:
514 |
515 | ```js
516 | const handler = getMswHandler({
517 | baseUrl: 'http://my.custom.domain',
518 | data,
519 | middlewares: [
520 | async (context, next) => {
521 | return new Promise((resolve) => {
522 | setTimeout(() => {
523 | resolve(next(context));
524 | }, 500);
525 | });
526 | }
527 | ]
528 | });
529 | ```
530 |
531 | This is so common FakeRest provides the `withDelay` function for that:
532 |
533 | ```js
534 | import { getMswHandler, withDelay } from 'fakerest';
535 |
536 | const handler = getMswHandler({
537 | baseUrl: 'http://my.custom.domain',
538 | data,
539 | middlewares: [
540 | withDelay(500), // delay in ms
541 | ]
542 | });
543 | ```
544 |
545 | ## Configuration
546 |
547 | All handlers can be customized to accommodate your API structure.
548 |
549 | ### Identifiers
550 |
551 | By default, FakeRest assumes all records have a unique `id` field.
552 | Some databases such as [MongoDB](https://www.mongodb.com) use `_id` instead of `id` for collection identifiers.
553 | You can customize FakeRest to do the same by setting the `identifierName` option:
554 |
555 | ```js
556 | const handler = getMswHandler({
557 | baseUrl: 'http://my.custom.domain',
558 | data,
559 | identifierName: '_id'
560 | });
561 | ```
562 |
563 | You can also specify that on a per-collection basis:
564 |
565 | ```js
566 | import { MswAdapter, Collection } from 'fakerest';
567 |
568 | const adapter = new MswAdapter({ baseUrl: 'http://my.custom.domain', data });
569 | const authorsCollection = new Collection({ items: [], identifierName: '_id' });
570 | adapter.server.addCollection('authors', authorsCollection);
571 | const handler = adapter.getHandler();
572 | ```
573 |
574 | ### Primary Keys
575 |
576 | By default, FakeRest uses an auto-incremented sequence for the item identifiers.
577 | 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:
578 |
579 | ```js
580 | import { getMswHandler } from 'fakerest';
581 | import uuid from 'uuid';
582 |
583 | const handler = new getMswHandler({
584 | baseUrl: 'http://my.custom.domain',
585 | data,
586 | getNewId: () => uuid.v5()
587 | });
588 | ```
589 |
590 | You can also specify that on a per-collection basis:
591 |
592 | ```js
593 | import { MswAdapter, Collection } from 'fakerest';
594 | import uuid from 'uuid';
595 |
596 | const adapter = new MswAdapter({ baseUrl: 'http://my.custom.domain', data });
597 | const authorsCollection = new Collection({ items: [], getNewId: () => uuid.v5() });
598 | adapter.server.addCollection('authors', authorsCollection);
599 | const handler = adapter.getHandler();
600 | ```
601 |
602 | ### Default Queries
603 |
604 | Some APIs might enforce some parameters on queries. For instance, an API might always include an [embed](#embed) or enforce a query filter.
605 | You can simulate this using the `defaultQuery` parameter:
606 |
607 | ```js
608 | import { getMswHandler } from 'fakerest';
609 | import uuid from 'uuid';
610 |
611 | const handler = getMswHandler({
612 | baseUrl: 'http://my.custom.domain',
613 | data,
614 | defaultQuery: (collection) => {
615 | if (resourceName == 'authors') return { embed: ['books'] }
616 | if (resourceName == 'books') return { filter: { published: true } }
617 | return {};
618 | }
619 | });
620 | ```
621 |
622 | ## Architecture
623 |
624 | 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.
625 |
626 | ### Mocking Adapter
627 |
628 | `getXXXHandler` is a shortcut to an object-oriented API of adapter classes:
629 |
630 | ```js
631 | export const getMswHandler = (options: MswAdapterOptions) => {
632 | const server = new MswAdapter(options);
633 | return server.getHandler();
634 | };
635 | ```
636 |
637 | FakeRest provides 3 adapter classes:
638 |
639 | - `MswAdapter`: Based on [MSW](https://mswjs.io/)
640 | - `FetchMockAdapter`: Based on [`fetch-mock`](https://www.wheresrhys.co.uk/fetch-mock/)
641 | - `SinonAdapter`: Based on [Sinon](https://sinonjs.org/releases/v18/fake-xhr-and-server/)
642 |
643 | 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:
644 |
645 | ```js
646 | import { MsWAdapter } from 'fakerest';
647 |
648 | const adapter = new MswAdapter({
649 | baseUrl: 'http://my.custom.domain',
650 | data: {
651 | 'authors': [
652 | { id: 0, first_name: 'Leo', last_name: 'Tolstoi' },
653 | { id: 1, first_name: 'Jane', last_name: 'Austen' }
654 | ],
655 | 'books': [
656 | { id: 0, author_id: 0, title: 'Anna Karenina' },
657 | { id: 1, author_id: 0, title: 'War and Peace' },
658 | { id: 2, author_id: 1, title: 'Pride and Prejudice' },
659 | { id: 3, author_id: 1, title: 'Sense and Sensibility' }
660 | ],
661 | 'settings': {
662 | language: 'english',
663 | preferred_format: 'hardback',
664 | }
665 | }
666 | });
667 | window.fakerest = adapter;
668 | const handler = adapter.getHandler();
669 | ```
670 |
671 | ### REST Server
672 |
673 | 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.
674 |
675 | 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`.
676 |
677 | You can specify the server to use in an adapter by passing the `server` option:
678 |
679 | ```js
680 | const server = new SimpleRestServer({
681 | baseUrl: 'http://my.custom.domain',
682 | data: {
683 | 'authors': [
684 | { id: 0, first_name: 'Leo', last_name: 'Tolstoi' },
685 | { id: 1, first_name: 'Jane', last_name: 'Austen' }
686 | ],
687 | 'books': [
688 | { id: 0, author_id: 0, title: 'Anna Karenina' },
689 | { id: 1, author_id: 0, title: 'War and Peace' },
690 | { id: 2, author_id: 1, title: 'Pride and Prejudice' },
691 | { id: 3, author_id: 1, title: 'Sense and Sensibility' }
692 | ],
693 | 'settings': {
694 | language: 'english',
695 | preferred_format: 'hardback',
696 | }
697 | }
698 | });
699 | const adapter = new MswAdapter({ server });
700 | const handler = adapter.getHandler();
701 | ```
702 |
703 | You can provide an alternative server implementation. This class must implement the `APIServer` type:
704 |
705 | ```ts
706 | export type APIServer = {
707 | baseUrl?: string;
708 | handle: (context: FakeRestContext) => Promise;
709 | };
710 |
711 | export type BaseResponse = {
712 | status: number;
713 | body?: Record | Record[];
714 | headers: { [key: string]: string };
715 | };
716 |
717 | export type FakeRestContext = {
718 | url?: string;
719 | headers?: Headers;
720 | method?: string;
721 | collection?: string;
722 | single?: string;
723 | requestBody: Record | undefined;
724 | params: { [key: string]: any };
725 | };
726 | ```
727 |
728 | 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.
729 |
730 | ### Database
731 |
732 | The querying logic is implemented in a class called `Database`, which is independent of the server. It contains [collections](#collections) and [single](#single).
733 |
734 | You can specify the database used by a server by setting its `database` property:
735 |
736 | ```js
737 | const database = new Database({
738 | data: {
739 | 'authors': [
740 | { id: 0, first_name: 'Leo', last_name: 'Tolstoi' },
741 | { id: 1, first_name: 'Jane', last_name: 'Austen' }
742 | ],
743 | 'books': [
744 | { id: 0, author_id: 0, title: 'Anna Karenina' },
745 | { id: 1, author_id: 0, title: 'War and Peace' },
746 | { id: 2, author_id: 1, title: 'Pride and Prejudice' },
747 | { id: 3, author_id: 1, title: 'Sense and Sensibility' }
748 | ],
749 | 'settings': {
750 | language: 'english',
751 | preferred_format: 'hardback',
752 | }
753 | }
754 | });
755 | const server = new SimpleRestServer({ baseUrl: 'http://my.custom.domain', database });
756 | ```
757 |
758 | You can even use the database object if you want to manipulate the data:
759 |
760 | ```js
761 | database.updateOne('authors', 0, { first_name: 'Lev' });
762 | ```
763 |
764 | ### Collections & Singles
765 |
766 | The Database may contain collections and singles. In the following example, `authors` and `books` are collections, and `settings` is a single.
767 |
768 | ```js
769 | const handler = getMswHandler({
770 | baseUrl: 'http://localhost:3000',
771 | data: {
772 | 'authors': [
773 | { id: 0, first_name: 'Leo', last_name: 'Tolstoi' },
774 | { id: 1, first_name: 'Jane', last_name: 'Austen' }
775 | ],
776 | 'books': [
777 | { id: 0, author_id: 0, title: 'Anna Karenina' },
778 | { id: 1, author_id: 0, title: 'War and Peace' },
779 | { id: 2, author_id: 1, title: 'Pride and Prejudice' },
780 | { id: 3, author_id: 1, title: 'Sense and Sensibility' }
781 | ],
782 | 'settings': {
783 | language: 'english',
784 | preferred_format: 'hardback',
785 | }
786 | }
787 | });
788 | ```
789 |
790 | A collection is the equivalent of a classic database table. It supports filtering and direct access to records by their identifier.
791 |
792 | 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`).
793 |
794 | ### Embeds
795 |
796 | FakeRest supports embedding other resources in a main resource query result. For instance, embedding the author of a book.
797 |
798 | GET /books/2?embed=['author']
799 |
800 | HTTP 1.1 200 OK
801 | Content-Type: application/json
802 | { "id": 2, "author_id": 1, "title": "Pride and Prejudice", "author": { "id": 1, "first_name": "Jane", "last_name": "Austen" } }
803 |
804 | Embeds are defined by the query, they require no setup in the database.
805 |
806 | ## Development
807 |
808 | ```sh
809 | # Install dependencies
810 | make install
811 |
812 | # Run the demo with MSW
813 | make run-msw
814 |
815 | # Run the demo with fetch-mock
816 | make run-fetch-mock
817 |
818 | # Run the demo with sinon
819 | make run-sinon
820 |
821 | # Run tests
822 | make test
823 |
824 | # Build minified version
825 | make build
826 | ```
827 |
828 | You can sign-in to the demo with `janedoe` and `password`
829 |
830 | ## License
831 |
832 | FakeRest is licensed under the [MIT License](LICENSE), sponsored by [marmelab](http://marmelab.com).
833 |
--------------------------------------------------------------------------------