├── 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 |
123 |
124 |
Loading...
125 |
126 |
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 | --------------------------------------------------------------------------------