mockaton 13.5.0 → 13.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,34 +1,13 @@
1
- # Mockaton
2
-
3
- An HTTP mock server for simulating APIs with minimal setup — ideal
4
- for testing difficult to reproduce backend states.
5
-
1
+ <!-- SKILLS_IGNORE_BEGIN -->
6
2
  ![NPM Version](https://img.shields.io/npm/v/mockaton)
7
3
  [![Test](https://github.com/ericfortis/mockaton/actions/workflows/test.yml/badge.svg)](https://github.com/ericfortis/mockaton/actions/workflows/test.yml)
8
4
  [![codecov](https://codecov.io/github/ericfortis/mockaton/graph/badge.svg?token=90NYLMMG1J)](https://codecov.io/github/ericfortis/mockaton)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
6
 
7
+ ## [Docs ↗](https://mockaton.com) | [Changelog ↗](https://mockaton.com/changelog) | [Skills ↗](https://mockaton.com/assets/SKILLS.md)
10
8
 
11
- ## [Documentation ↗](https://mockaton.com) | [Changelog ↗](https://mockaton.com/changelog)
12
-
13
- ## TL;DR
14
- ```shell
15
- npx mockaton my-mocks-dir
16
- ```
17
-
18
- It’s like `servedir`, but supports dynamic segments in filenames. For example:
19
-
20
- **Route**: [/api/company/123](#) <br/>
21
- **File**: my-mocks-dir/api/company/[id].GET.200.json
22
-
23
- Statics assets don’t need that extension:
24
-
25
- **Route**: [/media/avatar.png](#) <br/>
26
- **File**: my-mocks-dir/media/avatar.png
27
-
28
-
29
- ## Dashboard
30
- Besides the dashboard, there’s a programmatic [Control API](https://mockaton.com/api).
31
- Also, there’s a [Browser Extension](https://mockaton.com/scraping) for scraping responses from your backend.
9
+ Mockaton is an HTTP mock server for simulating APIs, designed
10
+ for testing difficult to reproduce backend states with minimal setup.
32
11
 
33
12
  <picture>
34
13
  <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.light.gold.png">
@@ -36,52 +15,91 @@ Also, there’s a [Browser Extension](https://mockaton.com/scraping) for scrapin
36
15
  <img alt="Mockaton Dashboard" src="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.dark.gold.png">
37
16
  </picture>
38
17
 
39
- <br/>
40
-
41
18
 
42
- ## Quick Start (Docker)
43
- This will spin up Mockaton with the sample directory
44
- included in this repo mounted on the container. Mentioned dir is: [mockaton-mocks/](./mockaton-mocks).
19
+ ## Demo (Docker)
20
+ This will spin up Mockaton with the [sample directory](./mockaton-mocks)
21
+ included in this repo mounted on the container.
45
22
 
46
23
  ```sh
47
24
  git clone https://github.com/ericfortis/mockaton.git --depth 1
48
25
  cd mockaton
49
26
  make docker
50
27
  ```
51
- Dashboard: [localhost:2020/mockaton](http://localhost:2020/mockaton)
52
-
53
28
  Test it:
54
- ```shell
29
+ ```sh
55
30
  curl localhost:2020/api/user
56
31
  ```
32
+ Dashboard: [localhost:2020/mockaton](http://localhost:2020/mockaton)
33
+ <!-- SKILLS_IGNORE_END -->
34
+
35
+ ## Basic Usage
36
+ ```sh
37
+ npx mockaton my-mocks-dir/
38
+ ```
39
+
40
+ Mockaton will serve the files on the given directory. It's a file-system
41
+ based router, so filenames can have dynamic parameters and comments.
42
+ Also, each route can have different mock file variants.
43
+
44
+
45
+ | Route | Filename | Description |
46
+ | -----| -----| ---|
47
+ | /api/company/123 | api/company/[id].GET.200.json | `[id]` is a dynamic parameter |
48
+ | /media/avatar.png | media/avatar.png | Statics assets don't need the above extension |
49
+ | /api/login | api/login(invalid attempt).POST.401.json | Anything within parenthesis is a **comment**, they are ignored when routing |
50
+ | /api/login | api/login(default).GET.200.json | `(default)` is a special comment; otherwise, the first mock variant in alphabetical order wins |
51
+ | /api/login | api/login(locked out user).POST.423.ts | TypeScript or JavaScript mocks are sent as JSON by default |
57
52
 
58
53
 
59
- ## Examples
60
- [/api/company/123](#)
54
+ ## Docs
55
+ - How to **configure** Mockaton? See [CLI and mockaton.config.js](https://mockaton.com/config) docs.
56
+ - How to **control** Mockaton? Besides the dashboard, there's a [Programmatic API](https://mockaton.com/api).
57
+ - How to **add plugins**? You can write [Plugins](https://mockaton.com/plugins) for customizing responses.
58
+
59
+ <!-- SKILLS_IGNORE_BEGIN -->
60
+ ## How to scrape your backend APIs?
61
+ Mockaton has a [Browser Extension](https://mockaton.com/scraping) that lets
62
+ you download in bulk all your API responses following Mockaton's filename convention.
63
+ <!-- SKILLS_IGNORE_END -->
64
+
65
+ ## How to create mocks?
66
+
67
+ Write to your mocks directory. Alternatively, there's an API [PATCH /mockaton/write-mock](https://mockaton.com/api).
68
+ ```sh
69
+ mkdir -p my-mocks-dir/api
70
+ echo '{ "name": "John" }' > my-mocks-dir/api/user.GET.200.json
71
+ sleep 0.1 # Wait for the watcher to register it
72
+ ```
73
+
74
+ ### Example A: JSON
75
+ - **Route:** /api/company/123
76
+ - **Filename:** api/company/[id].GET.200.json
61
77
 
62
- <code>my_mocks_dir/<b>api/company/[id]</b>.GET.200.json</code>
63
78
  ```json
64
79
  {
65
80
  "name": "Acme, Inc."
66
81
  }
67
82
  ```
68
83
 
69
- <br/>
84
+ ### Example B: TypeScript or JavaScript
85
+ Exporting an Object, Array, or String is sent as JSON.
70
86
 
71
- Or, you can write it in TypeScript (it will be sent as JSON).
87
+ - **Route:** /api/company/abc
88
+ - **Filename:** api/company/[id].GET.200.ts
72
89
 
73
- <code>my_mocks_dir/<b>api/company/[id]</b>.GET.200.ts</code>
74
90
  ```ts
75
91
  export default {
76
92
  name: 'Acme, Inc.'
77
93
  }
78
94
  ```
79
95
 
80
- <br/>
96
+ ### Example C: [Function Mocks](https://mockaton.com/function-mocks)
97
+ With a function mock you can do pretty much anything you could do with a normal backend handler.</p>
98
+ For example, you can handle complex logic, URL parsing, saving toa database, etc.
81
99
 
82
- Similarly, you can handle logic with [Function Mocks](https://mockaton.com/function-mocks):
100
+ - **Route:** /api/company/abc/user/999
101
+ - **Filename:** api/company/[companyId]/user/[userId].GET.200.ts
83
102
 
84
- <code>my_mocks_dir/<b>api/company/[companyId]/user/[userId]</b>.GET.200.ts</code>
85
103
  ```ts
86
104
  import { IncomingMessage, OutgoingMessage } from 'node:http'
87
105
  import { parseSegments } from 'mockaton'
package/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Server, IncomingMessage, OutgoingMessage } from 'node:http'
2
2
 
3
- export type Plugin = (
3
+ export declare type Plugin = (
4
4
  filePath: string,
5
5
  request: IncomingMessage,
6
6
  response: OutgoingMessage
@@ -9,7 +9,7 @@ export type Plugin = (
9
9
  body: string | Uint8Array
10
10
  }>
11
11
 
12
- export interface Config {
12
+ export declare interface Config {
13
13
  mocksDir?: string
14
14
  ignore?: RegExp
15
15
  watcherEnabled?: boolean
@@ -49,28 +49,28 @@ export interface Config {
49
49
  }
50
50
 
51
51
 
52
- export function Mockaton(options: Partial<Config>): Promise<Server | undefined>
52
+ export declare function Mockaton(options: Partial<Config>): Promise<Server | undefined>
53
53
 
54
- export function defineConfig(options: Partial<Config>): Partial<Config>
54
+ export declare function defineConfig(options: Partial<Config>): Partial<Config>
55
55
 
56
- export const jsToJsonPlugin: Plugin
57
- export const echoFilePlugin: Plugin
56
+ export declare const jsToJsonPlugin: Plugin
57
+ export declare const echoFilePlugin: Plugin
58
58
 
59
59
 
60
60
  // Utils
61
61
 
62
- export function jwtCookie(cookieName: string, payload: any, path?: string): string
62
+ export declare function jwtCookie(cookieName: string, payload: any, path?: string): string
63
63
 
64
- export function parseJSON(request: IncomingMessage): Promise<any>
65
- export function parseSegments(reqUrl: string, filename: string): Record<string, string>
66
- export function parseQueryParams(reqUrl: string): URLSearchParams
64
+ export declare function parseJSON(request: IncomingMessage): Promise<any>
65
+ export declare function parseSegments(reqUrl: string, filename: string): Record<string, string>
66
+ export declare function parseQueryParams(reqUrl: string): URLSearchParams
67
67
 
68
- export type JsonPromise<T> = Promise<Response & { json(): Promise<T> }>
68
+ export declare type JsonPromise<T> = Promise<Response & { json(): Promise<T> }>
69
69
 
70
70
 
71
71
  // API
72
72
 
73
- export type ClientMockBroker = {
73
+ export declare type ClientMockBroker = {
74
74
  mocks: string[]
75
75
  file: string
76
76
  status: number
@@ -79,13 +79,13 @@ export type ClientMockBroker = {
79
79
  delayed: boolean
80
80
  proxied: boolean
81
81
  }
82
- export type ClientBrokersByMethod = {
82
+ export declare type ClientBrokersByMethod = {
83
83
  [method: string]: {
84
84
  [urlMask: string]: ClientMockBroker
85
85
  }
86
86
  }
87
87
 
88
- export interface State {
88
+ export declare interface State {
89
89
  brokersByMethod: ClientBrokersByMethod
90
90
 
91
91
  cookies: [label: string, selected: boolean][]
package/package.json CHANGED
@@ -2,19 +2,21 @@
2
2
  "name": "mockaton",
3
3
  "description": "HTTP Mock Server",
4
4
  "type": "module",
5
- "version": "13.5.0",
5
+ "version": "13.6.2",
6
6
  "exports": {
7
7
  ".": {
8
8
  "import": "./index.js",
9
9
  "types": "./index.d.ts"
10
10
  },
11
- "./openapi.json": "./www/src/assets/openapi.json"
11
+ "./openapi.json": "./www/src/assets/openapi.json",
12
+ "./SKILLS.md": "./www/src/assets/SKILLS.md"
12
13
  },
13
14
  "files": [
14
15
  "src",
15
16
  "index.js",
16
17
  "index.d.ts",
17
- "www/src/assets/openapi.json"
18
+ "www/src/assets/openapi.json",
19
+ "www/src/assets/SKILLS.md"
18
20
  ],
19
21
  "license": "MIT",
20
22
  "homepage": "https://mockaton.com",
@@ -3,8 +3,7 @@ import { API } from './ApiConstants.js'
3
3
 
4
4
  /** Client for controlling Mockaton via its HTTP API */
5
5
  export class Commander {
6
- addr = ''
7
-
6
+ /** @param {string} addr */
8
7
  constructor(addr) {
9
8
  this.addr = addr
10
9
  }
@@ -25,23 +25,22 @@ export function parseFilename(file) {
25
25
  const followsConvention = tokens.length > 3
26
26
  && responseStatusIsValid(Number(tokens.at(-2)))
27
27
  && METHODS.includes(tokens.at(-3))
28
- const isStatic = !followsConvention
29
28
 
30
- return isStatic
29
+ return followsConvention
31
30
  ? {
32
- isStatic,
33
- ext: tokens.pop() || '',
34
- status: 200,
35
- method: 'GET',
36
- urlMask: '/' + file
37
- }
38
- : {
39
- isStatic,
31
+ isStatic: false,
40
32
  ext: tokens.pop(),
41
33
  status: Number(tokens.pop()),
42
34
  method: tokens.pop(),
43
35
  urlMask: '/' + removeTrailingSlash(tokens.join('.'))
44
36
  }
37
+ : {
38
+ isStatic: true,
39
+ ext: tokens.pop() || '',
40
+ status: 200,
41
+ method: 'GET',
42
+ urlMask: '/' + file
43
+ }
45
44
  }
46
45
 
47
46
  export function removeTrailingSlash(url = '') {
@@ -45,6 +45,7 @@ function GlobalDelayField() {
45
45
  r('input', {
46
46
  type: 'number',
47
47
  min: 0,
48
+ max: 120_000,
48
49
  step: 100,
49
50
  autocomplete: 'none',
50
51
  value: store.delay,
package/src/client/app.js CHANGED
@@ -38,15 +38,16 @@ function App() {
38
38
  }
39
39
 
40
40
  function LeftSide() {
41
- return r('div', {
42
- ref: LeftSide.ref,
43
- style: { width: LeftSide.ref.width },
44
- className: CSS.leftSide
45
- },
46
- r('div', { className: CSS.SubToolbar },
47
- GroupByMethod(),
48
- BulkSelector()),
49
- r('div', { className: CSS.Table }, MockList()))
41
+ return (
42
+ r('div', {
43
+ ref: LeftSide.ref,
44
+ style: { width: LeftSide.ref.width },
45
+ className: CSS.leftSide
46
+ },
47
+ r('div', { className: CSS.SubToolbar },
48
+ GroupByMethod(),
49
+ BulkSelector()),
50
+ r('div', { className: CSS.Table }, MockList())))
50
51
  }
51
52
  LeftSide.ref = { width: undefined }
52
53
  LeftSide.$ = selector => LeftSide.ref.elem.querySelector(selector)
@@ -4,13 +4,13 @@
4
4
  * @param {string[]} paths - sorted
5
5
  */
6
6
  export function dittoSplitPaths(paths) {
7
- const pParts = paths.map(p => p.split('/').filter(Boolean))
7
+ const segments = paths.map(p => p.split('/').filter(Boolean))
8
8
  return paths.map((p, i) => {
9
9
  if (i === 0)
10
10
  return ['', p]
11
11
 
12
- const prev = pParts[i - 1]
13
- const curr = pParts[i]
12
+ const prev = segments[i - 1]
13
+ const curr = segments[i]
14
14
  const min = Math.min(curr.length, prev.length)
15
15
  let j = 0
16
16
  while (j < min && curr[j] === prev[j])
@@ -2,80 +2,36 @@ import { test } from 'node:test'
2
2
  import { deepEqual } from 'node:assert/strict'
3
3
  import { groupByFolder } from './groupByFolder.js'
4
4
 
5
+ const PartialBrokerRowModel = (method, urlMask, ...children) =>
6
+ ({ method, urlMask, children })
5
7
 
6
8
  test('groupByFolder', () => {
7
9
  const input = [
8
- { children: [], method: 'GET', urlMask: '/api/user' },
9
- { children: [], method: 'GET', urlMask: '/api/user/avatar' },
10
- { children: [], method: 'GET', urlMask: '/api/video/[id]' },
11
- { children: [], method: 'GET', urlMask: '/index.html' },
12
- { children: [], method: 'GET', urlMask: '/media/file-a.txt' },
13
- { children: [], method: 'GET', urlMask: '/media/file-b.txt' },
14
- { children: [], method: 'GET', urlMask: '/media/sub/file-aa.txt' },
15
- { children: [], method: 'GET', urlMask: '/media/sub/file-bb.txt' },
16
- { children: [], method: 'POST', urlMask: '/api/user' },
17
- { children: [], method: 'POST', urlMask: '/api/user/avatar/foo' },
18
- { children: [], method: 'PATCH', urlMask: '/api/user' }
10
+ PartialBrokerRowModel('GET', '/api/user'),
11
+ PartialBrokerRowModel('GET', '/api/user/avatar'),
12
+ PartialBrokerRowModel('GET', '/api/video/[id]'),
13
+ PartialBrokerRowModel('GET', '/index.html'),
14
+ PartialBrokerRowModel('GET', '/media/file-a.txt'),
15
+ PartialBrokerRowModel('GET', '/media/file-b.txt'),
16
+ PartialBrokerRowModel('GET', '/media/sub/file-aa.txt'),
17
+ PartialBrokerRowModel('GET', '/media/sub/file-bb.txt'),
18
+ PartialBrokerRowModel('POST', '/api/user'),
19
+ PartialBrokerRowModel('POST', '/api/user/avatar/foo'),
20
+ PartialBrokerRowModel('PATCH', '/api/user')
19
21
  ]
20
22
 
21
-
22
23
  const expected = [
23
- {
24
- urlMask: '/api/user',
25
- method: 'GET',
26
- children: [
27
- {
28
- urlMask: '/api/user/avatar',
29
- method: 'GET',
30
- children: [
31
- {
32
- urlMask: '/api/user/avatar/foo',
33
- method: 'POST',
34
- children: []
35
- }
36
- ]
37
- }, {
38
- urlMask: '/api/user',
39
- method: 'POST',
40
- children: []
41
- }, {
42
- urlMask: '/api/user',
43
- method: 'PATCH',
44
- children: []
45
- }
46
- ]
47
- },
48
- {
49
- urlMask: '/api/video/[id]',
50
- method: 'GET',
51
- children: []
52
- },
53
- {
54
- urlMask: '/index.html',
55
- method: 'GET',
56
- children: []
57
- },
58
- {
59
- urlMask: '/media/file-a.txt',
60
- method: 'GET',
61
- children: [
62
- {
63
- urlMask: '/media/file-b.txt',
64
- method: 'GET',
65
- children: []
66
- }, {
67
- urlMask: '/media/sub/file-aa.txt',
68
- method: 'GET',
69
- children: [
70
- {
71
- urlMask: '/media/sub/file-bb.txt',
72
- method: 'GET',
73
- children: []
74
- }
75
- ]
76
- }
77
- ]
78
- }
24
+ PartialBrokerRowModel('GET', '/api/user',
25
+ PartialBrokerRowModel('GET', '/api/user/avatar',
26
+ PartialBrokerRowModel('POST', '/api/user/avatar/foo')),
27
+ PartialBrokerRowModel('POST', '/api/user'),
28
+ PartialBrokerRowModel('PATCH', '/api/user')),
29
+ PartialBrokerRowModel('GET', '/api/video/[id]'),
30
+ PartialBrokerRowModel('GET', '/index.html'),
31
+ PartialBrokerRowModel('GET', '/media/file-a.txt',
32
+ PartialBrokerRowModel('GET', '/media/file-b.txt'),
33
+ PartialBrokerRowModel('GET', '/media/sub/file-aa.txt',
34
+ PartialBrokerRowModel('GET', '/media/sub/file-bb.txt')))
79
35
  ]
80
36
 
81
37
  deepEqual(groupByFolder(input), expected)
package/src/server/Api.js CHANGED
@@ -97,6 +97,7 @@ function onDevWatch(req, response) {
97
97
  else
98
98
  response.notFound()
99
99
  }
100
+
100
101
  /** # PATCH */
101
102
 
102
103
  function reset(_, response) {
@@ -109,9 +110,9 @@ function reset(_, response) {
109
110
 
110
111
  async function setCorsAllowed(req, response) {
111
112
  const corsAllowed = await req.json()
112
-
113
- if (!ConfigValidator.corsAllowed(corsAllowed))
114
- response.unprocessable(`Expected boolean for "corsAllowed"`)
113
+ const err = ConfigValidator.corsAllowed(corsAllowed)
114
+ if (err)
115
+ response.unprocessable(err)
115
116
  else {
116
117
  config.corsAllowed = corsAllowed
117
118
  response.ok()
@@ -122,9 +123,9 @@ async function setCorsAllowed(req, response) {
122
123
 
123
124
  async function setGlobalDelay(req, response) {
124
125
  const delay = await req.json()
125
-
126
- if (!ConfigValidator.delay(delay))
127
- response.unprocessable(`Expected non-negative integer for "delay"`)
126
+ const err = ConfigValidator.delay(delay)
127
+ if (err)
128
+ response.unprocessable(err)
128
129
  else {
129
130
  config.delay = delay
130
131
  response.ok()
@@ -134,9 +135,9 @@ async function setGlobalDelay(req, response) {
134
135
 
135
136
  async function setGlobalDelayJitter(req, response) {
136
137
  const jitter = await req.json()
137
-
138
- if (!ConfigValidator.delayJitter(jitter))
139
- response.unprocessable(`Expected 0 to 3 float for "delayJitter"`)
138
+ const err = ConfigValidator.delayJitter(jitter)
139
+ if (err)
140
+ response.unprocessable(err)
140
141
  else {
141
142
  config.delayJitter = jitter
142
143
  response.ok()
@@ -147,7 +148,6 @@ async function setGlobalDelayJitter(req, response) {
147
148
 
148
149
  async function selectCookie(req, response) {
149
150
  const cookieKey = await req.json()
150
-
151
151
  const error = cookie.setCurrent(cookieKey)
152
152
  if (error)
153
153
  response.unprocessable(error?.message || error)
@@ -160,9 +160,9 @@ async function selectCookie(req, response) {
160
160
 
161
161
  async function setProxyFallback(req, response) {
162
162
  const fallback = await req.json()
163
-
164
- if (!ConfigValidator.proxyFallback(fallback))
165
- response.unprocessable(`Invalid Proxy Fallback URL`)
163
+ const err = ConfigValidator.proxyFallback(fallback)
164
+ if (err)
165
+ response.unprocessable(err)
166
166
  else {
167
167
  config.proxyFallback = fallback
168
168
  response.ok()
@@ -172,9 +172,9 @@ async function setProxyFallback(req, response) {
172
172
 
173
173
  async function setCollectProxied(req, response) {
174
174
  const collectProxied = await req.json()
175
-
176
- if (!ConfigValidator.collectProxied(collectProxied))
177
- response.unprocessable(`Expected a boolean for "collectProxied"`)
175
+ const err = ConfigValidator.collectProxied(collectProxied)
176
+ if (err)
177
+ response.unprocessable(err)
178
178
  else {
179
179
  config.collectProxied = collectProxied
180
180
  response.ok()
@@ -257,13 +257,15 @@ async function setRouteIsProxied(req, response) {
257
257
 
258
258
  async function writeMock(req, response) {
259
259
  if (config.readOnly)
260
- return response.forbidden()
260
+ return response.forbidden('Forbidden: Mockaton is in read-only mode. See config.readOnly, or --no-read-only (CLI)')
261
261
 
262
262
  const [file, content] = await req.json()
263
- const path = await resolveIn(config.mocksDir, file)
263
+ if (typeof file !== 'string')
264
+ return response.unprocessable('Invalid or missing filename. Expected: JSON [filename, content]')
264
265
 
266
+ const path = await resolveIn(config.mocksDir, file)
265
267
  if (!path)
266
- return response.forbidden()
268
+ return response.forbidden('Filename path resolves outside config.mocksDir')
267
269
 
268
270
  await write(path, content)
269
271
 
@@ -277,13 +279,13 @@ async function writeMock(req, response) {
277
279
 
278
280
  async function deleteMock(req, response) {
279
281
  if (config.readOnly)
280
- return response.forbidden()
282
+ return response.forbidden('Forbidden: Mockaton is in read-only mode. See config.readOnly, or --no-read-only (CLI)')
281
283
 
282
284
  const file = await req.json()
283
285
  const path = await resolveIn(config.mocksDir, file)
284
286
 
285
287
  if (!path)
286
- return response.forbidden()
288
+ return response.forbidden('Filename path resolves outside config.mocksDir')
287
289
 
288
290
  if (!isFile(path))
289
291
  return response.unprocessable(`Missing Mock: ${file}`)
@@ -7,26 +7,19 @@ import { parseFilename, includesComment, extractComments, removeQueryStringAndFr
7
7
  * files that can be served for the route, the currently selected file, etc.
8
8
  */
9
9
  export class MockBroker {
10
+ file = '' // selected mock filename
11
+ mocks = [] // filenames
12
+ isStatic = false // doesn’t follow filename convention
13
+ delayed = false
14
+ proxied = false
15
+ status = -1
16
+ autoStatus = 0
17
+
10
18
  constructor(file) {
11
- this.file = '' // selected mock filename
12
- this.mocks = [] // filenames
13
- this.status = -1
14
- this.isStatic = false // doesn’t follow filename convention
15
- this.delayed = false
16
- this.proxied = false
17
- this.autoStatus = 0
18
19
  this.urlMaskMatches = new UrlMatcher(file).urlMaskMatches
19
20
  this.register(file)
20
21
  }
21
22
 
22
- #isStatus = (file, status) => parseFilename(file).status === status
23
-
24
- #sortMocks() {
25
- this.mocks.sort()
26
- const defaults = this.mocks.filter(f => includesComment(f, DEFAULT_MOCK_COMMENT))
27
- this.mocks = Array.from(new Set(defaults).union(new Set(this.mocks)))
28
- }
29
-
30
23
  register(file) {
31
24
  if (this.autoStatus && this.#isStatus(file, this.autoStatus))
32
25
  this.selectFile(file)
@@ -42,6 +35,14 @@ export class MockBroker {
42
35
  return brokerIsEmpty
43
36
  }
44
37
 
38
+ #isStatus = (file, status) => parseFilename(file).status === status
39
+
40
+ #sortMocks() {
41
+ this.mocks.sort()
42
+ const defaults = this.mocks.filter(f => includesComment(f, DEFAULT_MOCK_COMMENT))
43
+ this.mocks = Array.from(new Set(defaults).union(new Set(this.mocks)))
44
+ }
45
+
45
46
  hasMock = file => this.mocks.includes(file)
46
47
 
47
48
  selectFile(filename) {
@@ -6,7 +6,7 @@ export default {
6
6
  userB: jwtCookie('CookieB', { email: 'john@example.test' }),
7
7
  },
8
8
  extraHeaders: ['custom_header_name', 'custom_header_val'],
9
- extraMimes: { ['custom_extension']: 'custom_mime' },
9
+ extraMimes: { 'custom_extension': 'custom_mime' },
10
10
  logLevel: 'verbose',
11
11
  corsOrigins: ['https://example.test'],
12
12
  corsExposedHeaders: ['Content-Encoding'],
@@ -166,7 +166,7 @@ describe('CORS', () => {
166
166
  test('422 for non boolean', async () => {
167
167
  const r = await api.setCorsAllowed('not-a-boolean')
168
168
  equal(r.status, 422)
169
- equal(await r.text(), 'Expected boolean for "corsAllowed"')
169
+ equal(await r.text(), 'Expected Boolean')
170
170
  })
171
171
 
172
172
  test('200', async () => {
@@ -271,7 +271,7 @@ describe('Delay', () => {
271
271
  test('422 for invalid value', async () => {
272
272
  const r = await api.setGlobalDelay('not-a-number')
273
273
  equal(r.status, 422)
274
- equal(await r.text(), 'Expected non-negative integer for "delay"')
274
+ equal(await r.text(), 'Expected an integer between 0 and 120000')
275
275
  })
276
276
  test('200 for valid global delay value', async () => {
277
277
  const r = await api.setGlobalDelay(150)
@@ -284,7 +284,7 @@ describe('Delay', () => {
284
284
  test('422 for invalid value', async () => {
285
285
  const r = await api.setGlobalDelayJitter('not-a-number')
286
286
  equal(r.status, 422)
287
- equal(await r.text(), 'Expected 0 to 3 float for "delayJitter"')
287
+ equal(await r.text(), 'Expected a float between 0 and 3')
288
288
  })
289
289
  test('200 for valid value', async () => {
290
290
  const r = await api.setGlobalDelayJitter(0.1)
@@ -391,7 +391,7 @@ describe('Proxy Fallback', () => {
391
391
  test('422 when value is not a valid URL', async () => {
392
392
  const r = await api.setProxyFallback('bad url')
393
393
  equal(r.status, 422)
394
- equal(await r.text(), 'Invalid Proxy Fallback URL')
394
+ equal(await r.text(), 'Expected an empty String or URL')
395
395
  })
396
396
 
397
397
  test('sets fallback', async () => {
@@ -411,7 +411,7 @@ describe('Proxy Fallback', () => {
411
411
  test('422 for invalid collectProxied value', async () => {
412
412
  const r = await api.setCollectProxied('not-a-boolean')
413
413
  equal(r.status, 422)
414
- equal(await r.text(), 'Expected a boolean for "collectProxied"')
414
+ equal(await r.text(), 'Expected Boolean')
415
415
  })
416
416
 
417
417
  test('200 set and unset', async () => {
@@ -986,12 +986,14 @@ test('head for get. returns the headers without body only for GETs requested as
986
986
 
987
987
 
988
988
  describe('Write and Delete Mock', () => {
989
- test('guards mocksDir', async () => {
989
+ test('rejects filenames resolving outside mocksDir', async () => {
990
990
  const r = await api.writeMock('../outside.txt', '')
991
991
  equal(r.status, 403)
992
+ match(await r.text(), /Filename path resolves outside config.mocksDir/)
992
993
 
993
994
  const r2 = await api.deleteMock('../outside.txt')
994
995
  equal(r2.status, 403)
996
+ match(await r2.text(), /Filename path resolves outside config.mocksDir/)
995
997
  })
996
998
 
997
999
  test('write and delete (with watcher)', async () => {
@@ -2,7 +2,7 @@ import { join } from 'node:path'
2
2
  import { randomUUID } from 'node:crypto'
3
3
 
4
4
  import { extFor } from './utils/mime.js'
5
- import { write, isFile } from './utils/fs.js'
5
+ import { write, isFile, resolveIn } from './utils/fs.js'
6
6
  import { readBody, BodyReaderError } from './utils/HttpIncomingMessage.js'
7
7
 
8
8
  import { config } from './config.js'
@@ -41,6 +41,10 @@ export async function proxy(req, response, delay) {
41
41
  setTimeout(() => response.end(body), delay) // TESTME
42
42
 
43
43
  if (config.collectProxied) {
44
+ if (config.readOnly) {
45
+ logger.info('Write denied: config.readOnly is true')
46
+ return
47
+ }
44
48
  const mime = proxyResponse.headers.get('content-type')
45
49
  const ext = mime
46
50
  ? extFor(mime) || EXT_UNKNOWN_MIME
@@ -59,10 +63,13 @@ async function saveMockToDisk(url, method, status, ext, body) {
59
63
  }
60
64
 
61
65
  try {
62
- await write(makeUniqueMockFilename(url, method, status, ext), body)
66
+ const f = makeUniqueMockFilename(url, method, status, ext)
67
+ if (!resolveIn(config.mocksDir, f))
68
+ throw 'Attempted write outside config.mocksDir'
69
+ await write(f, body)
63
70
  }
64
71
  catch (err) {
65
- logger.warn('Write access denied', err)
72
+ logger.warn('Write denied', err)
66
73
  }
67
74
  }
68
75
 
package/src/server/cli.js CHANGED
@@ -22,6 +22,7 @@ try {
22
22
 
23
23
  quiet: { short: 'q', type: 'boolean' },
24
24
  'no-open': { short: 'n', type: 'boolean' },
25
+ 'no-read-only': { type: 'boolean' },
25
26
 
26
27
  help: { short: 'h', type: 'boolean' },
27
28
  version: { short: 'v', type: 'boolean' }
@@ -48,16 +49,17 @@ else if (args.help)
48
49
  Usage: mockaton [mocks-dir] [options]
49
50
 
50
51
  Options:
51
- -c, --config <file> (default: ./mockaton.config.js)
52
+ -c, --config <file> (default: ./mockaton.config.js)
52
53
 
53
- -H, --host <host> (default: 127.0.0.1)
54
- -p, --port <port> (default: 0) which means auto-assigned
54
+ -H, --host <host> (default: 127.0.0.1)
55
+ -p, --port <port> (default: 0) which means auto-assigned
55
56
 
56
- -q, --quiet Errors only
57
- --no-open Dont open dashboard in a browser
57
+ -q, --quiet Show errors only
58
+ --no-open Don't open dashboard in a browser
59
+ --no-read-only Allow writing and deleting mocks via API
58
60
 
59
- -h, --help Show this help
60
- -v, --version Show version
61
+ -h, --help
62
+ -v, --version
61
63
 
62
64
  Notes:
63
65
  * mockaton.config.js supports more options, see: https://mockaton.com/config
@@ -81,6 +83,7 @@ else {
81
83
 
82
84
  if (args.quiet) opts.logLevel = 'quiet'
83
85
  if (args['no-open']) opts.onReady = () => {}
86
+ if (args['no-read-only']) opts.readOnly = false
84
87
 
85
88
  try {
86
89
  await Mockaton(opts)
@@ -26,7 +26,7 @@ describe('CLI', () => {
26
26
  const { stderr, status } = cli(
27
27
  rel('../../mockaton-mocks'),
28
28
  '--port', 'not-a-number')
29
- equal(stderr.trim(), `port="not-a-number" is invalid`)
29
+ equal(stderr.trim(), `port="not-a-number"\nExpected an integer between 0 and 65535`)
30
30
  equal(status, 1)
31
31
  })
32
32
 
@@ -1,57 +1,56 @@
1
1
  import { resolve } from 'node:path'
2
+ import { lstatSync } from 'node:fs'
2
3
  import { METHODS } from 'node:http'
3
4
 
4
5
  import { logger } from './utils/logger.js'
5
- import { isDirectory } from './utils/fs.js'
6
6
  import { registerMimes } from './utils/mime.js'
7
7
  import { openInBrowser } from './utils/openInBrowser.js'
8
- import { optional, is, validate } from './utils/validate.js'
8
+ import { is, validate, isInt, isFloat, isOneOf, optionalURL } from './utils/validate.js'
9
9
  import { validateCorsAllowedMethods, validateCorsAllowedOrigins } from './utils/http-cors.js'
10
10
 
11
11
  import { jsToJsonPlugin } from './MockDispatcherPlugins.js'
12
12
 
13
-
14
13
  /** @type {{
15
14
  * [K in keyof Config]-?: [
16
15
  * defaultVal: Config[K],
17
- * validator: (val: unknown) => boolean
16
+ * validator: (val: unknown) => err:string
18
17
  * ]
19
18
  * }} */
20
19
  const schema = {
21
- mocksDir: [resolve('mockaton-mocks'), isDirectory],
20
+ mocksDir: [resolve('mockaton-mocks'), p => !lstatSync(p).isDirectory()],
22
21
  ignore: [/(\.DS_Store|~)$/, is(RegExp)],
23
22
  readOnly: [true, is(Boolean)],
24
23
  watcherEnabled: [true, is(Boolean)],
25
- watcherDebounceMs: [80, ms => Number.isInteger(ms) && ms >= 0],
24
+ watcherDebounceMs: [80, isInt(0, 5000)],
26
25
 
27
26
  host: ['127.0.0.1', is(String)],
28
- port: [0, port => Number.isInteger(port) && port >= 0 && port < 2 ** 16], // 0 means auto-assigned
27
+ port: [0, isInt(0, 2 ** 16 - 1)], // 0 means auto-assigned
29
28
 
30
- logLevel: ['normal', val => ['normal', 'quiet', 'verbose'].includes(val)],
29
+ logLevel: ['normal', isOneOf('normal', 'quiet', 'verbose')],
31
30
 
32
- delay: [1200, ms => Number.isInteger(ms) && ms >= 0],
33
- delayJitter: [0, percent => percent >= 0 && percent <= 3],
31
+ delay: [1200, isInt(0, 120_000)],
32
+ delayJitter: [0, isFloat(0, 3)],
34
33
 
35
- proxyFallback: ['', optional(URL.canParse)], // e.g. http://localhost:9999
34
+ proxyFallback: ['', optionalURL], // e.g. http://localhost:9999
36
35
  collectProxied: [false, is(Boolean)],
37
36
  formatCollectedJSON: [true, is(Boolean)],
38
37
 
39
38
  cookies: [{}, is(Object)], // defaults to the first kv
40
- extraHeaders: [[], val => Array.isArray(val) && val.length % 2 === 0],
39
+ extraHeaders: [[], is(Array)],
41
40
  extraMimes: [{}, is(Object)],
42
41
 
43
42
  corsAllowed: [true, is(Boolean)],
44
43
  corsOrigins: [['*'], validateCorsAllowedOrigins],
45
44
  corsMethods: [METHODS, validateCorsAllowedMethods],
46
- corsHeaders: [['content-type', 'authorization'], Array.isArray],
47
- corsExposedHeaders: [[], Array.isArray],
45
+ corsHeaders: [['content-type', 'authorization'], is(Array)],
46
+ corsExposedHeaders: [[], is(Array)],
48
47
  corsCredentials: [true, is(Boolean)],
49
48
  corsMaxAge: [0, is(Number)],
50
49
 
51
50
  plugins: [
52
51
  [
53
52
  [/\.(js|ts)$/, jsToJsonPlugin]
54
- ], Array.isArray],
53
+ ], is(Array)],
55
54
 
56
55
  onReady: [await openInBrowser, is(Function)],
57
56
 
@@ -41,9 +41,9 @@ export class ServerResponse extends http.ServerResponse {
41
41
  this.end()
42
42
  }
43
43
 
44
- forbidden() {
44
+ forbidden(msg) {
45
45
  this.statusCode = 403
46
- this.end()
46
+ this.end(msg)
47
47
  }
48
48
 
49
49
  notFound() {
@@ -4,14 +4,20 @@ import { methodIsSupported } from './HttpIncomingMessage.js'
4
4
 
5
5
  export function validateCorsAllowedOrigins(arr) {
6
6
  if (!Array.isArray(arr))
7
- return false
7
+ return 'Expected Array'
8
8
  if (arr.length === 1 && arr[0] === '*')
9
- return true
9
+ return ''
10
10
  return arr.every(o => URL.canParse(o))
11
+ ? ''
12
+ : 'Expected URLs'
11
13
  }
12
14
 
13
15
  export function validateCorsAllowedMethods(arr) {
14
- return Array.isArray(arr) && arr.every(methodIsSupported)
16
+ if (!Array.isArray(arr))
17
+ return 'Expected Array'
18
+ return arr.every(methodIsSupported)
19
+ ? ''
20
+ : 'Unsupported Method'
15
21
  }
16
22
 
17
23
 
@@ -1,8 +1,41 @@
1
1
  export function validate(obj, shape) {
2
2
  for (const [field, value] of Object.entries(obj))
3
- if (!shape[field](value))
4
- throw new TypeError(`${field}=${JSON.stringify(value)} is invalid`)
3
+ try {
4
+ const err = shape[field](value)
5
+ if (err)
6
+ throw err
7
+ }
8
+ catch (err) {
9
+ throw new TypeError(`${field}=${JSON.stringify(value)}\n${err}`)
10
+ }
5
11
  }
6
12
 
7
- export const is = ctor => val => val.constructor === ctor
8
- export const optional = tester => val => !val || tester(val)
13
+ export function is(ctor) {
14
+ return val => val.constructor === ctor
15
+ ? ''
16
+ : `Expected ${ctor.prototype.constructor.name}`
17
+ }
18
+
19
+ export function isInt(min = Number.MAX_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) {
20
+ return v => Number.isInteger(v) && v >= min && v <= max
21
+ ? ''
22
+ : `Expected an integer between ${min} and ${max}`
23
+ }
24
+
25
+ export function isFloat(min = Number.MAX_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) {
26
+ return v => Number.isFinite(v) && v >= min && v <= max
27
+ ? ''
28
+ : `Expected a float between ${min} and ${max}`
29
+ }
30
+
31
+ export function isOneOf(...vals) {
32
+ return v => vals.includes(v)
33
+ ? ''
34
+ : `Expected one of: ${vals.join(', ')}`
35
+ }
36
+
37
+ export function optionalURL(v) {
38
+ return !v || URL.canParse(v)
39
+ ? ''
40
+ : 'Expected an empty String or URL'
41
+ }
@@ -1,33 +1,14 @@
1
1
  import { describe, test } from 'node:test'
2
2
  import { doesNotThrow, throws } from 'node:assert/strict'
3
- import { validate, is, optional } from './validate.js'
3
+ import { validate, is } from './validate.js'
4
4
 
5
5
 
6
6
  describe('validate', () => {
7
- describe('optional', () => {
8
- test('accepts undefined', () =>
9
- doesNotThrow(() =>
10
- validate({}, { foo: optional(Number.isInteger) })))
11
-
12
- test('accepts falsy value regardless of type', () =>
13
- doesNotThrow(() =>
14
- validate({ foo: 0 }, { foo: optional(Array.isArray) })))
15
-
16
- test('accepts when tester func returns truthy', () =>
17
- doesNotThrow(() =>
18
- validate({ foo: [] }, { foo: optional(Array.isArray) })))
19
-
20
- test('rejects when tester func returns falsy', () =>
21
- throws(() =>
22
- validate({ foo: 1 }, { foo: optional(Array.isArray) }),
23
- /foo=1 is invalid/))
24
- })
25
-
26
7
  describe('is', () => {
27
8
  test('rejects mismatched type', () =>
28
9
  throws(() =>
29
10
  validate({ foo: 1 }, { foo: is(String) }),
30
- /foo=1 is invalid/))
11
+ /foo=1\nExpected String/))
31
12
 
32
13
  test('accepts matched type', () =>
33
14
  doesNotThrow(() =>
@@ -37,16 +18,16 @@ describe('validate', () => {
37
18
  describe('custom tester func', () => {
38
19
  test('rejects mismatched type', () =>
39
20
  throws(() =>
40
- validate({ foo: 'not-a-number' }, { foo: n => n > 1 }),
41
- /foo="not-a-number" is invalid/))
21
+ validate({ foo: 'not-a-number' }, { foo: n => Number.isInteger(n) ? '' : 'Expected Integer' }),
22
+ /foo="not-a-number"\nExpected Integer/))
42
23
 
43
- test('rejects mismatched type', () =>
24
+ test('rejects mismatched value', () =>
44
25
  throws(() =>
45
- validate({ foo: 0 }, { foo: n => n > 1 }),
46
- /foo=0 is invalid/))
26
+ validate({ foo: 0 }, { foo: n => n === 1 ? '' : 'Expected 1' }),
27
+ /foo=0\nExpected 1/))
47
28
 
48
29
  test('accepts matched type', () =>
49
30
  doesNotThrow(() =>
50
- validate({ foo: 1 }, { foo: Number.isInteger })))
31
+ validate({ foo: 1 }, { foo: v => !Number.isInteger(v) })))
51
32
  })
52
33
  })
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: Mockaton
3
+ description: Generates and serves mock HTTP APIs from filesystem conventions. Use when creating, editing, or reasoning about mock endpoints.
4
+ ---
5
+
6
+ ## Basic Usage
7
+ ```sh
8
+ npx mockaton my-mocks-dir/
9
+ ```
10
+
11
+ Mockaton will serve the files on the given directory. It's a file-system
12
+ based router, so filenames can have dynamic parameters and comments.
13
+ Also, each route can have different mock file variants.
14
+
15
+
16
+ | Route | Filename | Description |
17
+ | -----| -----| ---|
18
+ | /api/company/123 | api/company/[id].GET.200.json | `[id]` is a dynamic parameter |
19
+ | /media/avatar.png | media/avatar.png | Statics assets don't need the above extension |
20
+ | /api/login | api/login(invalid attempt).POST.401.json | Anything within parenthesis is a **comment**, they are ignored when routing |
21
+ | /api/login | api/login(default).GET.200.json | `(default)` is a special comment; otherwise, the first mock variant in alphabetical order wins |
22
+ | /api/login | api/login(locked out user).POST.423.ts | TypeScript or JavaScript mocks are sent as JSON by default |
23
+
24
+
25
+ ## Docs
26
+ - How to **configure** Mockaton? See [CLI and mockaton.config.js](https://mockaton.com/config) docs.
27
+ - How to **control** Mockaton? Besides the dashboard, there's a [Programmatic API](https://mockaton.com/api).
28
+ - How to **add plugins**? You can write [Plugins](https://mockaton.com/plugins) for customizing responses.
29
+
30
+
31
+
32
+ ## How to create mocks?
33
+
34
+ Write to your mocks directory. Alternatively, there's an API [PATCH /mockaton/write-mock](https://mockaton.com/api).
35
+ ```sh
36
+ mkdir -p my-mocks-dir/api
37
+ echo '{ "name": "John" }' > my-mocks-dir/api/user.GET.200.json
38
+ sleep 0.1 # Wait for the watcher to register it
39
+ ```
40
+
41
+ ### Example A: JSON
42
+ - **Route:** /api/company/123
43
+ - **Filename:** api/company/[id].GET.200.json
44
+
45
+ ```json
46
+ {
47
+ "name": "Acme, Inc."
48
+ }
49
+ ```
50
+
51
+ ### Example B: TypeScript or JavaScript
52
+ Exporting an Object, Array, or String is sent as JSON.
53
+
54
+ - **Route:** /api/company/abc
55
+ - **Filename:** api/company/[id].GET.200.ts
56
+
57
+ ```ts
58
+ export default {
59
+ name: 'Acme, Inc.'
60
+ }
61
+ ```
62
+
63
+ ### Example C: [Function Mocks](https://mockaton.com/function-mocks)
64
+ With a function mock you can do pretty much anything you could do with a normal backend handler.</p>
65
+ For example, you can handle complex logic, URL parsing, saving toa database, etc.
66
+
67
+ - **Route:** /api/company/abc/user/999
68
+ - **Filename:** api/company/[companyId]/user/[userId].GET.200.ts
69
+
70
+ ```ts
71
+ import { IncomingMessage, OutgoingMessage } from 'node:http'
72
+ import { parseSegments } from 'mockaton'
73
+
74
+ export default async function (req: IncomingMessage, response: OutgoingMessage) {
75
+ const { companyId, userId } = parseSegments(req.url, import.meta.filename)
76
+ const foo = await getFoo()
77
+ return JSON.stringify({
78
+ foo,
79
+ companyId,
80
+ userId,
81
+ name: 'Acme, Inc.'
82
+ })
83
+ }
84
+ ```
@@ -12,7 +12,7 @@
12
12
  "paths": {
13
13
  "/mockaton/openapi": {
14
14
  "get": {
15
- "summary": "Get this OpenAPI spec",
15
+ "summary": "Get OpenAPI spec",
16
16
  "x-js-client-example": "await mockaton.getOpenAPI()",
17
17
  "responses": {
18
18
  "200": {
@@ -51,7 +51,7 @@
51
51
  "/mockaton/write-mock": {
52
52
  "patch": {
53
53
  "summary": "Write a mock file",
54
- "description": "Writes a mock file to the mocks directory. Requires `config.readOnly = false`",
54
+ "description": "Writes a mock file to the mocks directory. Requires `config.readOnly = false` and `config.watcherEnabled = true`.",
55
55
  "x-js-client-example": "await mockaton.writeMock('api/user/friends.GET.200.json', '{ \"friends\": [] }')",
56
56
  "requestBody": {
57
57
  "required": true,
@@ -77,7 +77,7 @@
77
77
  },
78
78
  "responses": {
79
79
  "200": {
80
- "description": "OK"
80
+ "description": "OK. The mock has been written and registered successfully."
81
81
  },
82
82
  "403": {
83
83
  "description": "Forbidden (read-only mode or outside mocksDir)"