├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── build.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── fetcher.ts ├── index.ts ├── types.ts └── utils.ts ├── test ├── examples │ ├── stripe-openapi2.ts │ └── stripe-openapi3.ts ├── fetch.test.ts ├── infer.test.ts ├── mocks │ ├── handlers.ts │ └── server.ts ├── paths.ts └── utils.test.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | test/examples/*.ts 2 | dist 3 | build.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', 5 | 'plugin:prettier/recommended', 6 | ], 7 | parserOptions: { 8 | ecmaVersion: 2018, 9 | sourceType: 'module', 10 | }, 11 | rules: { 12 | '@typescript-eslint/explicit-function-return-type': 'off', 13 | '@typescript-eslint/explicit-module-boundary-types': 'off', 14 | '@typescript-eslint/no-explicit-any': 'off', 15 | }, 16 | env: { 17 | jest: true, 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | coverage 4 | dist 5 | node_modules -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": false, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "explicit" 5 | }, 6 | "typescript.tsdk": "node_modules/typescript/lib", 7 | "files.exclude": { 8 | "**/.git": true, 9 | "**/.svn": true, 10 | "**/.hg": true, 11 | "**/CVS": true, 12 | "**/.DS_Store": true, 13 | "**/lib": true, 14 | "**/node_modules": true, 15 | "**/coverage": true, 16 | "**/tsconfig.tsbuildinfo": true, 17 | "**/yarn-error.log": true 18 | } 19 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2021 Ajai Shankar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![version(scoped)](https://img.shields.io/npm/v/openapi-typescript-fetch.svg)](https://www.npmjs.com/package/openapi-typescript-fetch) 2 | [![codecov](https://codecov.io/gh/ajaishankar/openapi-typescript-fetch/branch/main/graph/badge.svg?token=Z8GQ6M5KAR)](https://codecov.io/gh/ajaishankar/openapi-typescript-fetch) 3 | 4 | # 📘️ openapi-typescript-fetch 5 | 6 | A typed fetch client for [openapi-typescript](https://github.com/drwpow/openapi-typescript) 7 | 8 | ### Install 9 | 10 | ```bash 11 | npm install openapi-typescript-fetch 12 | ``` 13 | Or 14 | ```bash 15 | yarn add openapi-typescript-fetch 16 | ``` 17 | 18 | **Features** 19 | 20 | Supports JSON request and responses 21 | 22 | - ✅ [OpenAPI 3.0](https://swagger.io/specification) 23 | - ✅ [Swagger 2.0](https://swagger.io/specification/v2/) 24 | 25 | ### Usage 26 | 27 | **Generate typescript definition from schema** 28 | 29 | ```bash 30 | npx openapi-typescript https://petstore.swagger.io/v2/swagger.json --output petstore.ts 31 | 32 | # 🔭 Loading spec from https://petstore.swagger.io/v2/swagger.json… 33 | # 🚀 https://petstore.swagger.io/v2/swagger.json -> petstore.ts [650ms] 34 | ``` 35 | 36 | **Typed fetch client** 37 | 38 | ```ts 39 | import { Fetcher } from 'openapi-typescript-fetch' 40 | 41 | import { paths } from './petstore' 42 | 43 | // declare fetcher for paths 44 | const fetcher = Fetcher.for() 45 | 46 | // global configuration 47 | fetcher.configure({ 48 | baseUrl: 'https://petstore.swagger.io/v2', 49 | init: { 50 | headers: { 51 | ... 52 | }, 53 | }, 54 | use: [...] // middlewares 55 | }) 56 | 57 | // create fetch operations 58 | const findPetsByStatus = fetcher.path('/pet/findByStatus').method('get').create() 59 | const addPet = fetcher.path('/pet').method('post').create() 60 | 61 | // fetch 62 | const { status, data: pets } = await findPetsByStatus({ 63 | status: ['available', 'pending'], 64 | }) 65 | 66 | console.log(pets[0]) 67 | ``` 68 | 69 | ### Typed Error Handling 70 | 71 | A non-ok fetch response throws a generic `ApiError` 72 | 73 | But an Openapi document can declare a different response type for each status code, or a default error response type 74 | 75 | These can be accessed via a `discriminated union` on status, as in code snippet below 76 | 77 | ```ts 78 | const findPetsByStatus = fetcher.path('/pet/findByStatus').method('get').create() 79 | const addPet = fetcher.path('/pet').method('post').create() 80 | 81 | try { 82 | await findPetsByStatus({ ... }) 83 | await addPet({ ... }) 84 | } catch(e) { 85 | // check which operation threw the exception 86 | if (e instanceof addPet.Error) { 87 | // get discriminated union { status, data } 88 | const error = e.getActualType() 89 | if (error.status === 400) { 90 | error.data.validationErrors // only available for a 400 response 91 | } else if (error.status === 500) { 92 | error.data.errorMessage // only available for a 500 response 93 | } else { 94 | ... 95 | } 96 | } 97 | } 98 | ``` 99 | 100 | ### Middleware 101 | 102 | Middlewares can be used to pre and post process fetch operations (log api calls, add auth headers etc) 103 | 104 | ```ts 105 | 106 | import { Middleware } from 'openapi-typescript-fetch' 107 | 108 | const logger: Middleware = async (url, init, next) => { 109 | console.log(`fetching ${url}`) 110 | const response = await next(url, init) 111 | console.log(`fetched ${url}`) 112 | return response 113 | } 114 | 115 | fetcher.configure({ 116 | baseUrl: 'https://petstore.swagger.io/v2', 117 | init: { ... }, 118 | use: [logger], 119 | }) 120 | 121 | // or 122 | 123 | fetcher.use(logger) 124 | ``` 125 | 126 | ### Utility Types 127 | 128 | - `OpArgType` - Infer argument type of an operation 129 | - `OpReturnType` - Infer return type of an operation 130 | - `OpErrorType` - Infer error type of an operation 131 | - `FetchArgType` - Argument type of a typed fetch operation 132 | - `FetchReturnType` - Return type of a typed fetch operation 133 | - `FetchErrorType` - Error type of a typed fetch operation 134 | - `TypedFetch` - Fetch operation type 135 | 136 | ```ts 137 | import { paths, operations } from './petstore' 138 | 139 | type Arg = OpArgType 140 | type Ret = OpReturnType 141 | type Err = OpErrorType 142 | 143 | type Arg = OpArgType 144 | type Ret = OpReturnType 145 | type Err = OpErrorType 146 | 147 | type FindPetsByStatus = TypedFetch 148 | 149 | const findPetsByStatus = fetcher.path('/pet/findByStatus').method('get').create() 150 | 151 | type Arg = FetchArgType 152 | type Ret = FetchReturnType 153 | type Err = FetchErrorType 154 | ``` 155 | 156 | ### Utility Methods 157 | 158 | - `arrayRequestBody` - Helper to merge params when request body is an array [see issue](https://github.com/ajaishankar/openapi-typescript-fetch/issues/3#issuecomment-952963986) 159 | 160 | ```ts 161 | 162 | const body = arrayRequestBody([{ item: 1}], { param: 2}) 163 | 164 | // body type is { item: number }[] & { param: number } 165 | ``` 166 | 167 | ### Changing the baseUrl at runtime 168 | 169 | The baseUrl can be configured with a function that returns the url at runtime 170 | 171 | ```ts 172 | fetcher.configure({ 173 | baseUrl: () => getBaseUrl(...) 174 | }) 175 | ``` 176 | 177 | It can also be overriden per method invocation 178 | 179 | ```ts 180 | await findPetsByStatus( 181 | { status: ['available', 'pending'] }, 182 | { baseUrl: "https://staging.petstore.swagger.io/v2" } 183 | ) 184 | ``` 185 | 186 | Happy fetching! 👍 -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | // https://janessagarrow.com/blog/typescript-and-esbuild/ 2 | 3 | const { build } = require("esbuild"); 4 | const { dependencies, peerDependencies } = require('./package.json'); 5 | const { Generator } = require('npm-dts'); 6 | 7 | new Generator({ 8 | entry: 'index.ts', 9 | output: 'dist/index.d.ts', 10 | }).generate(); 11 | 12 | const sharedConfig = { 13 | entryPoints: ["src/index.ts"], 14 | bundle: true, 15 | external: Object.keys(dependencies ?? {}).concat(Object.keys(peerDependencies ?? {})), 16 | }; 17 | 18 | build({ 19 | ...sharedConfig, 20 | platform: 'node', // for CJS 21 | outfile: "dist/index.cjs", 22 | }); 23 | 24 | build({ 25 | ...sharedConfig, 26 | outfile: "dist/index.js", 27 | platform: 'neutral', // for ESM 28 | format: "esm", 29 | }); -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | modulePathIgnorePatterns: ['dist'], 5 | globals: { 6 | 'ts-jest': { 7 | tsconfig: { 8 | sourceMap: true, 9 | }, 10 | }, 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openapi-typescript-fetch", 3 | "description": "A typed fetch client for openapi-typescript", 4 | "version": "2.2.0", 5 | "engines": { 6 | "node": ">= 12.0.0", 7 | "npm": ">= 7.0.0" 8 | }, 9 | "author": "Ajai Shankar", 10 | "license": "MIT", 11 | "module": "dist/index.js", 12 | "main": "dist/index.cjs", 13 | "types": "dist/index.d.ts", 14 | "files": [ 15 | "dist" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/ajaishankar/openapi-typescript-fetch" 20 | }, 21 | "keywords": [ 22 | "fetch", 23 | "client", 24 | "swagger", 25 | "typescript", 26 | "ts", 27 | "openapi", 28 | "openapi 3", 29 | "node" 30 | ], 31 | "bugs": { 32 | "url": "https://github.com/ajaishankar/openapi-typescript-fetch/issues" 33 | }, 34 | "homepage": "https://github.com/ajaishankar/openapi-typescript-fetch#readme", 35 | "devDependencies": { 36 | "@types/jest": "^27.0.0", 37 | "@typescript-eslint/eslint-plugin": "^4.30.0", 38 | "@typescript-eslint/parser": "^4.31.0", 39 | "codecov": "^3.8.2", 40 | "esbuild": "^0.20.1", 41 | "eslint": "^7.32.0", 42 | "eslint-config-prettier": "^8.3.0", 43 | "eslint-plugin-prettier": "^4.0.0", 44 | "jest": "^27.2.5", 45 | "msw": "^0.35.0", 46 | "npm-dts": "^1.3.12", 47 | "prettier": "^2.4.0", 48 | "rimraf": "^3.0.0", 49 | "ts-jest": "^27.0.0", 50 | "ts-node": "^10.0.0", 51 | "typescript": "^4.4.3", 52 | "whatwg-fetch": "^3.6.2" 53 | }, 54 | "prettier": { 55 | "trailingComma": "all", 56 | "singleQuote": true, 57 | "semi": false 58 | }, 59 | "scripts": { 60 | "clean": "rimraf './dist'", 61 | "prebuild": "npm run clean", 62 | "build": "node build.js", 63 | "lint": "eslint .", 64 | "prepack": "npm run test && npm run build", 65 | "test": "npm run build && jest", 66 | "test:codecov": "npm run build && jest --no-cache --coverage && codecov", 67 | "test:coverage": "npm run build && jest --no-cache --collectCoverage", 68 | "typecheck": "tsc --noEmit --project tsconfig.json" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/fetcher.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiError, 3 | ApiResponse, 4 | CreateFetch, 5 | CustomRequestInit, 6 | Fetch, 7 | FetchConfig, 8 | Method, 9 | Middleware, 10 | OpArgType, 11 | OpenapiPaths, 12 | OpErrorType, 13 | Request, 14 | _TypedFetch, 15 | TypedFetch, 16 | } from './types' 17 | 18 | const sendBody = (method: Method) => 19 | method === 'post' || 20 | method === 'put' || 21 | method === 'patch' || 22 | method === 'delete' 23 | 24 | function queryString(params: Record): string { 25 | const qs: string[] = [] 26 | 27 | const encode = (key: string, value: unknown) => 28 | `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}` 29 | 30 | Object.keys(params).forEach((key) => { 31 | const value = params[key] 32 | if (value != null) { 33 | if (Array.isArray(value)) { 34 | value.forEach((value) => qs.push(encode(key, value))) 35 | } else { 36 | qs.push(encode(key, value)) 37 | } 38 | } 39 | }) 40 | 41 | if (qs.length > 0) { 42 | return `?${qs.join('&')}` 43 | } 44 | 45 | return '' 46 | } 47 | 48 | function getPath(path: string, payload: Record) { 49 | return path.replace(/\{([^}]+)\}/g, (_, key) => { 50 | const value = encodeURIComponent(payload[key]) 51 | delete payload[key] 52 | return value 53 | }) 54 | } 55 | 56 | function getQuery( 57 | method: Method, 58 | payload: Record, 59 | query: string[], 60 | ) { 61 | let queryObj = {} as any 62 | 63 | if (sendBody(method)) { 64 | query.forEach((key) => { 65 | queryObj[key] = payload[key] 66 | delete payload[key] 67 | }) 68 | } else { 69 | queryObj = { ...payload } 70 | } 71 | 72 | return queryString(queryObj) 73 | } 74 | 75 | function getHeaders(body?: string, init?: HeadersInit) { 76 | const headers = new Headers(init) 77 | 78 | if (body !== undefined && !headers.has('Content-Type')) { 79 | headers.append('Content-Type', 'application/json') 80 | } 81 | 82 | if (!headers.has('Accept')) { 83 | headers.append('Accept', 'application/json') 84 | } 85 | 86 | return headers 87 | } 88 | 89 | function getBody(method: Method, payload: any) { 90 | const body = sendBody(method) ? JSON.stringify(payload) : undefined 91 | // if delete don't send body if empty 92 | return method === 'delete' && body === '{}' ? undefined : body 93 | } 94 | 95 | function mergeRequestInit( 96 | first?: RequestInit, 97 | second?: RequestInit, 98 | ): RequestInit { 99 | const headers = new Headers(first?.headers) 100 | const other = new Headers(second?.headers) 101 | 102 | for (const key of other.keys()) { 103 | const value = other.get(key) 104 | if (value != null) { 105 | headers.set(key, value) 106 | } 107 | } 108 | return { ...first, ...second, headers } 109 | } 110 | 111 | function getFetchParams(request: Request) { 112 | // clone payload 113 | // if body is a top level array [ 'a', 'b', param: value ] with param values 114 | // using spread [ ...payload ] returns [ 'a', 'b' ] and skips custom keys 115 | // cloning with Object.assign() preserves all keys 116 | const payload = Object.assign( 117 | Array.isArray(request.payload) ? [] : {}, 118 | request.payload, 119 | ) 120 | 121 | const path = getPath(request.path, payload) 122 | const query = getQuery(request.method, payload, request.queryParams) 123 | const body = getBody(request.method, payload) 124 | const headers = getHeaders(body, request.init?.headers) 125 | const url = request.baseUrl + path + query 126 | 127 | const init = { 128 | ...request.init, 129 | method: request.method.toUpperCase(), 130 | headers, 131 | body, 132 | } 133 | 134 | return { url, init } 135 | } 136 | 137 | async function getResponseData(response: Response) { 138 | const contentType = response.headers.get('content-type') 139 | // no content or not modified 140 | if (response.status === 204 || response.status === 304) { 141 | return undefined 142 | } 143 | if (contentType && contentType.indexOf('application/json') !== -1) { 144 | return await response.json() 145 | } 146 | const text = await response.text() 147 | try { 148 | return JSON.parse(text) 149 | } catch (e) { 150 | return text 151 | } 152 | } 153 | 154 | async function fetchJson(url: string, init: RequestInit): Promise { 155 | const response = await fetch(url, init) 156 | 157 | const data = await getResponseData(response) 158 | 159 | const result = { 160 | headers: response.headers, 161 | url: response.url, 162 | ok: response.ok, 163 | status: response.status, 164 | statusText: response.statusText, 165 | data, 166 | } 167 | 168 | if (result.ok) { 169 | return result 170 | } 171 | 172 | throw new ApiError(result) 173 | } 174 | 175 | function wrapMiddlewares(middlewares: Middleware[], fetch: Fetch): Fetch { 176 | type Handler = ( 177 | index: number, 178 | url: string, 179 | init: CustomRequestInit, 180 | ) => Promise 181 | 182 | const handler: Handler = async (index, url, init) => { 183 | if (middlewares == null || index === middlewares.length) { 184 | return fetch(url, init) 185 | } 186 | const current = middlewares[index] 187 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 188 | return await current!(url, init, (nextUrl, nextInit) => 189 | handler(index + 1, nextUrl, nextInit), 190 | ) 191 | } 192 | 193 | return (url, init) => handler(0, url, init) 194 | } 195 | 196 | async function fetchUrl(request: Request) { 197 | const { url, init } = getFetchParams(request) 198 | 199 | const response = await request.fetch(url, init) 200 | 201 | return response as ApiResponse 202 | } 203 | 204 | function createFetch(fetch: _TypedFetch): TypedFetch { 205 | const fun = async (payload: OpArgType, init?: RequestInit) => { 206 | try { 207 | return await fetch(payload, init) 208 | } catch (err) { 209 | if (err instanceof ApiError) { 210 | throw new fun.Error(err) 211 | } 212 | throw err 213 | } 214 | } 215 | 216 | fun.Error = class extends ApiError { 217 | constructor(error: ApiError) { 218 | super(error) 219 | Object.setPrototypeOf(this, new.target.prototype) 220 | } 221 | getActualType() { 222 | return { 223 | status: this.status, 224 | data: this.data, 225 | } as OpErrorType 226 | } 227 | } 228 | 229 | return fun 230 | } 231 | 232 | function fetcher() { 233 | let baseUrl = '' as string | (() => string) 234 | let defaultInit: RequestInit = {} 235 | const middlewares: Middleware[] = [] 236 | const fetch = wrapMiddlewares(middlewares, fetchJson) 237 | 238 | return { 239 | configure: (config: FetchConfig) => { 240 | baseUrl = config.baseUrl || '' 241 | defaultInit = config.init || {} 242 | middlewares.splice(0) 243 | middlewares.push(...(config.use || [])) 244 | }, 245 | use: (mw: Middleware) => middlewares.push(mw), 246 | path:

(path: P) => ({ 247 | method: (method: M) => ({ 248 | create: ((queryParams?: Record) => 249 | createFetch((payload, init) => 250 | fetchUrl({ 251 | baseUrl: 252 | init?.baseUrl ?? 253 | (typeof baseUrl === 'function' ? baseUrl() : baseUrl), 254 | path: path as string, 255 | method: method as Method, 256 | queryParams: Object.keys(queryParams || {}), 257 | payload, 258 | init: mergeRequestInit(defaultInit, init), 259 | fetch, 260 | }), 261 | )) as unknown as CreateFetch, 262 | }), 263 | }), 264 | } 265 | } 266 | 267 | export const Fetcher = { 268 | for: >() => fetcher(), 269 | } 270 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Fetcher } from './fetcher' 2 | import { arrayRequestBody } from './utils' 3 | 4 | import type { 5 | ApiResponse, 6 | FetchArgType, 7 | FetchReturnType, 8 | FetchErrorType, 9 | Middleware, 10 | OpArgType, 11 | OpErrorType, 12 | OpDefaultReturnType, 13 | OpReturnType, 14 | TypedFetch, 15 | } from './types' 16 | 17 | import { ApiError } from './types' 18 | 19 | export type { 20 | OpArgType, 21 | OpErrorType, 22 | OpDefaultReturnType, 23 | OpReturnType, 24 | FetchArgType, 25 | FetchReturnType, 26 | FetchErrorType, 27 | ApiResponse, 28 | Middleware, 29 | TypedFetch, 30 | } 31 | 32 | export { Fetcher, ApiError, arrayRequestBody } 33 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Method = 2 | | 'get' 3 | | 'post' 4 | | 'put' 5 | | 'patch' 6 | | 'delete' 7 | | 'head' 8 | | 'options' 9 | 10 | export type OpenapiPaths = { 11 | [P in keyof Paths]: { 12 | [M in Method]?: unknown 13 | } 14 | } 15 | 16 | export type OpArgType = OP extends { 17 | parameters?: { 18 | path?: infer P 19 | query?: infer Q 20 | body?: infer B 21 | header?: unknown // ignore 22 | cookie?: unknown // ignore 23 | } 24 | // openapi 3 25 | requestBody?: { 26 | content: { 27 | 'application/json': infer RB 28 | } 29 | } 30 | } 31 | ? P & Q & (B extends Record ? B[keyof B] : unknown) & RB 32 | : Record 33 | 34 | type OpResponseTypes = OP extends { 35 | responses: infer R 36 | } 37 | ? { 38 | [S in keyof R]: R[S] extends { schema?: infer S } // openapi 2 39 | ? S 40 | : R[S] extends { content: { 'application/json': infer C } } // openapi 3 41 | ? C 42 | : S extends 'default' 43 | ? R[S] 44 | : unknown 45 | } 46 | : never 47 | 48 | type _OpReturnType = 200 extends keyof T 49 | ? T[200] 50 | : 201 extends keyof T 51 | ? T[201] 52 | : 204 extends keyof T 53 | ? T[204] 54 | : 'default' extends keyof T 55 | ? T['default'] 56 | : unknown 57 | 58 | export type OpReturnType = _OpReturnType> 59 | 60 | type _OpDefaultReturnType = 'default' extends keyof T 61 | ? T['default'] 62 | : unknown 63 | 64 | export type OpDefaultReturnType = _OpDefaultReturnType> 65 | 66 | // private symbol to prevent narrowing on "default" error status 67 | const never: unique symbol = Symbol() 68 | 69 | type _OpErrorType = { 70 | [S in Exclude]: { 71 | status: S extends 'default' ? typeof never : S 72 | data: T[S] 73 | } 74 | }[Exclude] 75 | 76 | type Coalesce = [T] extends [never] ? D : T 77 | 78 | // coalesce default error type 79 | export type OpErrorType = Coalesce< 80 | _OpErrorType>, 81 | { status: number; data: any } 82 | > 83 | 84 | export type CustomRequestInit = Omit & { 85 | readonly headers: Headers 86 | } 87 | 88 | export type Fetch = ( 89 | url: string, 90 | init: CustomRequestInit, 91 | ) => Promise 92 | 93 | export type _TypedFetch = ( 94 | arg: OpArgType, 95 | init?: RequestInit & { baseUrl?: string }, 96 | ) => Promise>> 97 | 98 | export type TypedFetch = _TypedFetch & { 99 | Error: new (error: ApiError) => ApiError & { 100 | getActualType: () => OpErrorType 101 | } 102 | } 103 | 104 | export type FetchArgType = F extends TypedFetch 105 | ? OpArgType 106 | : never 107 | 108 | export type FetchReturnType = F extends TypedFetch 109 | ? OpReturnType 110 | : never 111 | 112 | export type FetchErrorType = F extends TypedFetch 113 | ? OpErrorType 114 | : never 115 | 116 | type _CreateFetch = [Q] extends [never] 117 | ? () => TypedFetch 118 | : (query: Q) => TypedFetch 119 | 120 | export type CreateFetch = M extends 'post' | 'put' | 'patch' | 'delete' 121 | ? OP extends { parameters: { query: infer Q } } 122 | ? _CreateFetch 123 | : _CreateFetch 124 | : _CreateFetch 125 | 126 | export type Middleware = ( 127 | url: string, 128 | init: CustomRequestInit, 129 | next: Fetch, 130 | ) => Promise 131 | 132 | export type FetchConfig = { 133 | baseUrl?: string | (() => string) 134 | init?: RequestInit 135 | use?: Middleware[] 136 | } 137 | 138 | export type Request = { 139 | baseUrl: string 140 | method: Method 141 | path: string 142 | queryParams: string[] // even if a post these will be sent in query 143 | payload: any 144 | init?: RequestInit 145 | fetch: Fetch 146 | } 147 | 148 | export type ApiResponse = { 149 | readonly headers: Headers 150 | readonly url: string 151 | readonly ok: boolean 152 | readonly status: number 153 | readonly statusText: string 154 | readonly data: R 155 | } 156 | 157 | export class ApiError extends Error { 158 | readonly headers: Headers 159 | readonly url: string 160 | readonly status: number 161 | readonly statusText: string 162 | readonly data: any 163 | 164 | constructor(response: Omit) { 165 | super(response.statusText) 166 | Object.setPrototypeOf(this, new.target.prototype) 167 | 168 | this.headers = response.headers 169 | this.url = response.url 170 | this.status = response.status 171 | this.statusText = response.statusText 172 | this.data = response.data 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper to merge params when request body is an array 3 | */ 4 | export function arrayRequestBody(array: T[], params?: O): T[] & O { 5 | return Object.assign([...array], params) 6 | } 7 | -------------------------------------------------------------------------------- /test/fetch.test.ts: -------------------------------------------------------------------------------- 1 | import 'whatwg-fetch' 2 | 3 | import { server } from './mocks/server' 4 | import { ApiError, arrayRequestBody, Fetcher } from '../src' 5 | import { Data, paths } from './paths' 6 | 7 | beforeAll(() => server.listen()) 8 | afterEach(() => server.resetHandlers()) 9 | afterAll(() => server.close()) 10 | 11 | describe('fetch', () => { 12 | const fetcher = Fetcher.for() 13 | 14 | beforeEach(() => { 15 | fetcher.configure({ 16 | baseUrl: 'https://api.backend.dev', 17 | init: { 18 | headers: { 19 | Authorization: 'Bearer token', 20 | }, 21 | }, 22 | }) 23 | }) 24 | 25 | const expectedHeaders = { 26 | authorization: 'Bearer token', 27 | accept: 'application/json', 28 | } 29 | 30 | const headersWithContentType = { 31 | ...expectedHeaders, 32 | 'content-type': 'application/json', 33 | } 34 | 35 | it('GET /query/{a}/{b}', async () => { 36 | const fun = fetcher.path('/query/{a}/{b}').method('get').create() 37 | 38 | const { ok, status, statusText, data } = await fun({ 39 | a: 1, 40 | b: '/', 41 | scalar: 'a', 42 | list: ['b', 'c'], 43 | }) 44 | 45 | expect(data.params).toEqual({ a: '1', b: '%2F' }) 46 | expect(data.query).toEqual({ scalar: 'a', list: ['b', 'c'] }) 47 | expect(data.headers).toEqual(expectedHeaders) 48 | expect(ok).toBe(true) 49 | expect(status).toBe(200) 50 | expect(statusText).toBe('OK') 51 | }) 52 | 53 | const methods = ['post', 'put', 'patch', 'delete'] as const 54 | 55 | methods.forEach((method) => { 56 | it(`${method.toUpperCase()} /body/{id}`, async () => { 57 | const fun = fetcher.path('/body/{id}').method(method).create() 58 | 59 | const { data } = await fun({ 60 | id: 1, 61 | list: ['b', 'c'], 62 | }) 63 | 64 | expect(data.params).toEqual({ id: '1' }) 65 | expect(data.body).toEqual({ list: ['b', 'c'] }) 66 | expect(data.query).toEqual({}) 67 | expect(data.headers).toEqual(headersWithContentType) 68 | }) 69 | }) 70 | 71 | methods.forEach((method) => { 72 | it(`${method.toUpperCase()} /bodyarray/{id}`, async () => { 73 | const fun = fetcher.path('/bodyarray/{id}').method(method).create() 74 | 75 | const { data } = await fun(arrayRequestBody(['b', 'c'], { id: 1 })) 76 | 77 | expect(data.params).toEqual({ id: '1' }) 78 | expect(data.body).toEqual(['b', 'c']) 79 | expect(data.query).toEqual({}) 80 | expect(data.headers).toEqual(headersWithContentType) 81 | }) 82 | }) 83 | 84 | methods.forEach((method) => { 85 | it(`${method.toUpperCase()} /bodyquery/{id}`, async () => { 86 | const fun = fetcher 87 | .path('/bodyquery/{id}') 88 | .method(method) 89 | .create({ scalar: 1 }) 90 | 91 | const { data } = await fun({ 92 | id: 1, 93 | scalar: 'a', 94 | list: ['b', 'c'], 95 | }) 96 | 97 | expect(data.params).toEqual({ id: '1' }) 98 | expect(data.body).toEqual({ list: ['b', 'c'] }) 99 | expect(data.query).toEqual({ scalar: 'a' }) 100 | expect(data.headers).toEqual(headersWithContentType) 101 | }) 102 | }) 103 | 104 | it(`DELETE /body/{id} (empty body)`, async () => { 105 | const fun = fetcher.path('/body/{id}').method('delete').create() 106 | 107 | const { data } = await fun({ id: 1 } as any) 108 | 109 | expect(data.params).toEqual({ id: '1' }) 110 | expect(data.headers).toHaveProperty('accept') 111 | expect(data.headers).not.toHaveProperty('content-type') 112 | }) 113 | 114 | it(`POST /nocontent`, async () => { 115 | const fun = fetcher.path('/nocontent').method('post').create() 116 | const { status, data } = await fun(undefined) 117 | expect(status).toBe(204) 118 | expect(data).toBeUndefined() 119 | }) 120 | 121 | it(`GET /notmodified (should not read body)`, async () => { 122 | const fun = fetcher.path('/notmodified').method('get').create() 123 | try { 124 | await fun(undefined) 125 | throw new Error('should throw api exception') 126 | } catch (err) { 127 | expect(err instanceof ApiError).toBe(true) 128 | if (err instanceof ApiError) { 129 | expect(err.headers.get('content-type')).toBe('application/json') 130 | expect(err.status).toBe(304) 131 | } 132 | } 133 | }) 134 | 135 | it('GET /error', async () => { 136 | expect.assertions(3) 137 | 138 | const fun = fetcher.path('/error/{status}').method('get').create() 139 | 140 | try { 141 | await fun({ status: 400 }) 142 | } catch (err) { 143 | expect(err instanceof ApiError).toBe(true) 144 | expect(err instanceof fun.Error).toBe(true) 145 | 146 | if (err instanceof ApiError) { 147 | expect(err).toMatchObject({ 148 | status: 400, 149 | statusText: 'Bad Request', 150 | data: '', 151 | }) 152 | } 153 | } 154 | }) 155 | 156 | it('GET /error (json body)', async () => { 157 | const fun = fetcher.path('/error/{status}').method('get').create() 158 | 159 | const errors = { 160 | badRequest: false, 161 | internalServer: false, 162 | other: false, 163 | } 164 | 165 | const handleError = (e: any) => { 166 | if (e instanceof fun.Error) { 167 | const error = e.getActualType() 168 | // discriminated union 169 | if (error.status === 400) { 170 | errors.badRequest = error.data.badRequest 171 | } else if (error.status === 500) { 172 | errors.internalServer = error.data.internalServer 173 | } else { 174 | errors.other = error.data.message === 'unknown error' 175 | } 176 | } 177 | } 178 | 179 | for (const status of [400, 500, 503]) { 180 | try { 181 | await fun({ status, detail: true }) 182 | } catch (e) { 183 | handleError(e) 184 | } 185 | } 186 | 187 | expect(errors).toEqual({ 188 | badRequest: true, 189 | internalServer: true, 190 | other: true, 191 | }) 192 | }) 193 | it('default error type {status: number, data: any}', async () => { 194 | expect.assertions(2) 195 | 196 | const fun = fetcher.path('/defaulterror').method('get').create() 197 | 198 | try { 199 | await fun({}) 200 | } catch (e) { 201 | if (e instanceof fun.Error) { 202 | const error = e.getActualType() 203 | expect(error.status).toBe(500) 204 | expect(error.data).toEqual('internal server error') 205 | } 206 | } 207 | }) 208 | 209 | it('network error', async () => { 210 | expect.assertions(1) 211 | 212 | const fun = fetcher.path('/networkerror').method('get').create() 213 | 214 | try { 215 | await fun({}) 216 | } catch (e) { 217 | expect(e).not.toBeInstanceOf(ApiError) 218 | } 219 | }) 220 | 221 | it('operation specific error type', () => { 222 | const one = fetcher.path('/query/{a}/{b}').method('get').create() 223 | const two = fetcher.path('/body/{id}').method('post').create() 224 | 225 | expect(new one.Error({} as any)).not.toBeInstanceOf(two.Error) 226 | expect(new two.Error({} as any)).not.toBeInstanceOf(one.Error) 227 | }) 228 | 229 | it('override init', async () => { 230 | const fun = fetcher.path('/query/{a}/{b}').method('get').create() 231 | 232 | const { data } = await fun( 233 | { 234 | a: 1, 235 | b: '2', 236 | scalar: 'a', 237 | list: ['b', 'c'], 238 | }, 239 | { 240 | headers: { admin: 'true' }, 241 | credentials: 'include', 242 | }, 243 | ) 244 | 245 | expect(data.headers).toEqual({ ...expectedHeaders, admin: 'true' }) 246 | }) 247 | 248 | describe('baseUrl', () => { 249 | const baseUrl = 'https://api2.backend.dev' 250 | const payload = { 251 | a: 1, 252 | b: '2', 253 | scalar: 'a', 254 | list: ['b', 'c'], 255 | } 256 | 257 | it('can override baseUrl per invocation', async () => { 258 | fetcher.configure({}) // empty baseUrl 259 | const fun = fetcher.path('/query/{a}/{b}').method('get').create() 260 | const { data } = await fun(payload, { baseUrl }) 261 | expect(data.host).toBe('api2.backend.dev') 262 | }) 263 | 264 | it('can configure with a function', async () => { 265 | fetcher.configure({ 266 | baseUrl: () => baseUrl, 267 | }) 268 | const fun = fetcher.path('/query/{a}/{b}').method('get').create() 269 | const { data } = await fun(payload) 270 | expect(data.host).toBe('api2.backend.dev') 271 | }) 272 | }) 273 | 274 | it('middleware', async () => { 275 | const fun = fetcher 276 | .path('/bodyquery/{id}') 277 | .method('post') 278 | .create({ scalar: 1 }) 279 | 280 | const captured = { url: '', body: '' } 281 | 282 | fetcher.use(async (url, init, next) => { 283 | init.headers.set('mw1', 'true') 284 | 285 | captured.url = url 286 | captured.body = init.body as string 287 | 288 | const response = await next(url, init) 289 | const data = response.data as Data 290 | data.body.list.push('mw1') 291 | 292 | return response 293 | }) 294 | 295 | fetcher.use(async (url, init, next) => { 296 | const response = await next(url, init) 297 | const data = response.data as Data 298 | data.body.list.push('mw2') 299 | return response 300 | }) 301 | 302 | const { data } = await fun({ 303 | id: 1, 304 | scalar: 'a', 305 | list: ['b', 'c'], 306 | }) 307 | 308 | expect(data.body.list).toEqual(['b', 'c', 'mw2', 'mw1']) 309 | expect(data.headers.mw1).toEqual('true') 310 | expect(captured.url).toEqual('https://api.backend.dev/bodyquery/1?scalar=a') 311 | expect(captured.body).toEqual('{"list":["b","c"]}') 312 | }) 313 | }) 314 | -------------------------------------------------------------------------------- /test/infer.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FetchArgType, 3 | Fetcher, 4 | FetchErrorType, 5 | FetchReturnType, 6 | OpArgType, 7 | OpDefaultReturnType, 8 | OpErrorType, 9 | OpReturnType, 10 | TypedFetch, 11 | } from '../src' 12 | import { paths as paths2 } from './examples/stripe-openapi2' 13 | import { paths as paths3 } from './examples/stripe-openapi3' 14 | 15 | type Op2 = paths2['/v1/account_links']['post'] 16 | 17 | // currently only application/json is supported for body 18 | type Op3 = Omit & { 19 | requestBody: { 20 | content: { 21 | 'application/json': paths3['/v1/account_links']['post']['requestBody']['content']['application/x-www-form-urlencoded'] 22 | } 23 | } 24 | } 25 | 26 | interface Openapi2 { 27 | Argument: OpArgType 28 | Return: OpReturnType 29 | Default: Pick['error'], 'type' | 'message'> 30 | Error: Pick['data']['error'], 'type' | 'message'> 31 | } 32 | 33 | interface Openapi3 { 34 | Argument: OpArgType 35 | Return: OpReturnType 36 | Default: Pick['error'], 'type' | 'message'> 37 | Error: Pick['data']['error'], 'type' | 'message'> 38 | } 39 | 40 | type Same = A extends B ? (B extends A ? true : false) : false 41 | 42 | describe('infer', () => { 43 | it('argument', () => { 44 | const same: Same = true 45 | expect(same).toBe(true) 46 | 47 | const arg: Openapi2['Argument'] = {} as any 48 | expect(arg.account).toBeUndefined() 49 | }) 50 | 51 | it('return', () => { 52 | const same: Same = true 53 | expect(same).toBe(true) 54 | 55 | const ret: Openapi2['Return'] = {} as any 56 | expect(ret.url).toBeUndefined() 57 | }) 58 | 59 | it('default', () => { 60 | const same: Same = true 61 | expect(same).toBe(true) 62 | }) 63 | 64 | it('error', () => { 65 | const same: Same = true 66 | expect(same).toBe(true) 67 | }) 68 | 69 | describe('fetch', () => { 70 | type CreateLink = TypedFetch 71 | 72 | const fetcher = Fetcher.for() 73 | const createLink: CreateLink = fetcher 74 | .path('/v1/account_links') 75 | .method('post') 76 | .create() 77 | 78 | type Arg = FetchArgType 79 | type Ret = FetchReturnType 80 | type Err = FetchErrorType 81 | 82 | it('argument', () => { 83 | const same: Same = true 84 | expect(same).toBe(true) 85 | }) 86 | 87 | it('return', () => { 88 | const same: Same = true 89 | expect(same).toBe(true) 90 | }) 91 | 92 | it('error', () => { 93 | const same: Same< 94 | Err, 95 | OpErrorType 96 | > = true 97 | expect(same).toBe(true) 98 | }) 99 | 100 | it('only header/cookie parameter with requestBody', () => { 101 | type RequestBody = { 102 | requestBody: { 103 | content: { 104 | 'application/json': { bar: boolean } 105 | } 106 | } 107 | } 108 | 109 | type HeaderOnly = { 110 | parameters: { 111 | header: { foo: string } 112 | } 113 | } & RequestBody 114 | 115 | type CookieOnly = { 116 | parameters: { 117 | cookie: { foo: string } 118 | } 119 | } & RequestBody 120 | 121 | const header: Same, { bar: boolean }> = true 122 | const cookie: Same, { bar: boolean }> = true 123 | 124 | expect(header).toBe(true) 125 | expect(cookie).toBe(true) 126 | }) 127 | 128 | const err: Err = { data: { error: {} } } as any 129 | expect(err.data.error.charge).toBeUndefined() 130 | }) 131 | }) 132 | -------------------------------------------------------------------------------- /test/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { ResponseComposition, rest, RestContext, RestRequest } from 'msw' 2 | 3 | function getQuery(req: RestRequest) { 4 | const { searchParams } = req.url 5 | const query = {} as any 6 | 7 | for (const key of searchParams.keys()) { 8 | const value = searchParams.getAll(key) 9 | query[key] = value.length === 1 ? value[0] : value.sort() 10 | } 11 | return query 12 | } 13 | 14 | function getHeaders(req: RestRequest) { 15 | const headers = {} as any 16 | req.headers.forEach((value, key) => { 17 | if (key !== 'cookie' && !key.startsWith('x-msw')) { 18 | headers[key] = value 19 | } 20 | }) 21 | return headers 22 | } 23 | 24 | function getResult( 25 | req: RestRequest, 26 | res: ResponseComposition, 27 | ctx: RestContext, 28 | ) { 29 | return res( 30 | ctx.json({ 31 | params: req.params, 32 | headers: getHeaders(req), 33 | query: getQuery(req), 34 | body: req.body, 35 | }), 36 | ) 37 | } 38 | 39 | function getResultWithHost( 40 | req: RestRequest, 41 | res: ResponseComposition, 42 | ctx: RestContext, 43 | ) { 44 | return res( 45 | ctx.json({ 46 | params: req.params, 47 | headers: getHeaders(req), 48 | query: getQuery(req), 49 | body: req.body, 50 | host: new URL(req.url).host, 51 | }), 52 | ) 53 | } 54 | 55 | const HOST = 'https://api.backend.dev' 56 | const HOST2 = 'https://api2.backend.dev' 57 | 58 | const methods = { 59 | withQuery: [ 60 | rest.get(`${HOST}/query/:a/:b`, getResult), 61 | rest.get(`${HOST2}/query/:a/:b`, getResultWithHost), 62 | ], 63 | withBody: ['post', 'put', 'patch', 'delete'].map((method) => { 64 | return (rest as any)[method](`${HOST}/body/:id`, getResult) 65 | }), 66 | withBodyArray: ['post', 'put', 'patch', 'delete'].map((method) => { 67 | return (rest as any)[method](`${HOST}/bodyarray/:id`, getResult) 68 | }), 69 | withBodyAndQuery: ['post', 'put', 'patch', 'delete'].map((method) => { 70 | return (rest as any)[method](`${HOST}/bodyquery/:id`, getResult) 71 | }), 72 | withError: [ 73 | rest.get(`${HOST}/error/:status`, (req, res, ctx) => { 74 | const status = Number(req.params.status) 75 | const detail = req.url.searchParams.get('detail') === 'true' 76 | return detail 77 | ? res( 78 | ctx.status(status), 79 | status === 400 80 | ? ctx.json({ badRequest: true }) 81 | : status === 500 82 | ? ctx.json({ internalServer: true }) 83 | : ctx.json({ message: 'unknown error' }), 84 | ) 85 | : res(ctx.status(status)) 86 | }), 87 | rest.post(`${HOST}/nocontent`, (req, res, ctx) => { 88 | return res(ctx.status(204)) 89 | }), 90 | rest.get(`${HOST}/notmodified`, (req, res) => { 91 | // force bad json response 92 | return res((res) => { 93 | res.status = 304 94 | res.headers.set('Content-Type', 'application/json') 95 | return res 96 | }) 97 | }), 98 | rest.get(`${HOST}/defaulterror`, (req, res, ctx) => { 99 | return res(ctx.status(500), ctx.body('internal server error')) 100 | }), 101 | rest.get(`${HOST}/networkerror`, (req, res) => { 102 | return res.networkError('failed to connect') 103 | }), 104 | ], 105 | } 106 | 107 | export const handlers = [ 108 | ...methods.withQuery, 109 | ...methods.withBody, 110 | ...methods.withBodyArray, 111 | ...methods.withBodyAndQuery, 112 | ...methods.withError, 113 | ] 114 | -------------------------------------------------------------------------------- /test/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node' 2 | import { handlers } from './handlers' 3 | 4 | export const server = setupServer(...handlers) 5 | -------------------------------------------------------------------------------- /test/paths.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | export type Data = { 3 | params: string[] 4 | headers: Record 5 | query: Record 6 | body: any 7 | host?: string 8 | } 9 | 10 | type Query = { 11 | parameters: { 12 | path: { a: number; b: string } 13 | query: { scalar: string; list: string[] } 14 | } 15 | responses: { 200: { schema: Data } } 16 | } 17 | 18 | type Body = { 19 | parameters: { 20 | path: { id: number } 21 | body: { payload: { list: string[] } } 22 | } 23 | responses: { 200: { schema: Data } } 24 | } 25 | 26 | type BodyArray = { 27 | parameters: { 28 | path: { id: number } 29 | body: { payload: string[] } 30 | } 31 | responses: { 200: { schema: Data } } 32 | } 33 | 34 | type BodyAndQuery = { 35 | parameters: { 36 | path: { id: number } 37 | query: { scalar: string } 38 | body: { payload: { list: string[] } } 39 | } 40 | responses: { 201: { schema: Data } } 41 | } 42 | 43 | export type paths = { 44 | '/query/{a}/{b}': { 45 | get: Query 46 | } 47 | '/body/{id}': { 48 | post: Body 49 | put: Body 50 | patch: Body 51 | delete: Body 52 | } 53 | '/bodyarray/{id}': { 54 | post: BodyArray 55 | put: BodyArray 56 | patch: BodyArray 57 | delete: BodyArray 58 | } 59 | '/bodyquery/{id}': { 60 | post: BodyAndQuery 61 | put: BodyAndQuery 62 | patch: BodyAndQuery 63 | delete: BodyAndQuery 64 | } 65 | '/nocontent': { 66 | post: { 67 | parameters: {} 68 | responses: { 69 | 204: unknown 70 | } 71 | } 72 | } 73 | '/notmodified': { 74 | get: { 75 | parameters: {} 76 | responses: { 77 | 304: unknown 78 | } 79 | } 80 | } 81 | '/error/{status}': { 82 | get: { 83 | parameters: { 84 | path: { status: number } 85 | query: { detail?: boolean } 86 | } 87 | responses: { 88 | 400: { 89 | schema: { badRequest: boolean } // openapi 2 90 | } 91 | 500: { 92 | content: { 93 | 'application/json': { internalServer: boolean } // openapi 3 94 | } 95 | } 96 | default: { message: string } 97 | } 98 | } 99 | } 100 | '/defaulterror': { 101 | get: { 102 | parameters: {} 103 | responses: {} 104 | } 105 | } 106 | '/networkerror': { 107 | get: { 108 | parameters: {} 109 | responses: { 110 | default: string 111 | } 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { arrayRequestBody } from '../src' 2 | 3 | describe('utils', () => { 4 | it('array request body with params', () => { 5 | const body = arrayRequestBody([{ item: 2 }], { param: 3 }) 6 | expect(body.length).toEqual(1) 7 | expect(body[0]?.item).toEqual(2) 8 | expect(body.param).toEqual(3) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "downlevelIteration": false, 6 | "esModuleInterop": true, 7 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "moduleResolution": "Node", 10 | "noUncheckedIndexedAccess": true, 11 | "outDir": "dist", 12 | "skipLibCheck": true, 13 | "sourceMap": false, 14 | "strict": true, 15 | "target": "ESNext" 16 | }, 17 | "include": ["src"], 18 | "exclude": ["node_modules", "dist"] 19 | } --------------------------------------------------------------------------------