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 +61 -43
- package/index.d.ts +14 -14
- package/package.json +5 -3
- package/src/client/ApiCommander.js +1 -2
- package/src/client/Filename.js +9 -10
- package/src/client/app-header.js +1 -0
- package/src/client/app.js +10 -9
- package/src/client/dir/dittoSplitPaths.js +3 -3
- package/src/client/dir/groupByFolder.test.js +24 -68
- package/src/server/Api.js +23 -21
- package/src/server/MockBroker.js +16 -15
- package/src/server/Mockaton.test.config.js +1 -1
- package/src/server/Mockaton.test.js +8 -6
- package/src/server/ProxyRelay.js +10 -3
- package/src/server/cli.js +10 -7
- package/src/server/cli.test.js +1 -1
- package/src/server/config.js +14 -15
- package/src/server/utils/HttpServerResponse.js +2 -2
- package/src/server/utils/http-cors.js +9 -3
- package/src/server/utils/validate.js +37 -4
- package/src/server/utils/validate.test.js +8 -27
- package/www/src/assets/SKILLS.md +84 -0
- package/www/src/assets/openapi.json +3 -3
package/README.md
CHANGED
|
@@ -1,34 +1,13 @@
|
|
|
1
|
-
|
|
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
|

|
|
7
3
|
[](https://github.com/ericfortis/mockaton/actions/workflows/test.yml)
|
|
8
4
|
[](https://codecov.io/github/ericfortis/mockaton)
|
|
5
|
+
[](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
|
-
|
|
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
|
-
##
|
|
43
|
-
This will spin up Mockaton with the sample directory
|
|
44
|
-
included in this repo mounted on the container.
|
|
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
|
-
```
|
|
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
|
-
##
|
|
60
|
-
[
|
|
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
|
-
|
|
84
|
+
### Example B: TypeScript or JavaScript
|
|
85
|
+
Exporting an Object, Array, or String is sent as JSON.
|
|
70
86
|
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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",
|
package/src/client/Filename.js
CHANGED
|
@@ -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
|
|
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 = '') {
|
package/src/client/app-header.js
CHANGED
package/src/client/app.js
CHANGED
|
@@ -38,15 +38,16 @@ function App() {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function LeftSide() {
|
|
41
|
-
return
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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 =
|
|
13
|
-
const curr =
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 (
|
|
114
|
-
response.unprocessable(
|
|
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 (
|
|
127
|
-
response.unprocessable(
|
|
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 (
|
|
139
|
-
response.unprocessable(
|
|
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 (
|
|
165
|
-
response.unprocessable(
|
|
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 (
|
|
177
|
-
response.unprocessable(
|
|
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
|
-
|
|
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}`)
|
package/src/server/MockBroker.js
CHANGED
|
@@ -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: {
|
|
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
|
|
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
|
|
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
|
|
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(), '
|
|
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
|
|
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('
|
|
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 () => {
|
package/src/server/ProxyRelay.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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>
|
|
52
|
+
-c, --config <file> (default: ./mockaton.config.js)
|
|
52
53
|
|
|
53
|
-
-H, --host <host>
|
|
54
|
-
-p, --port <port>
|
|
54
|
+
-H, --host <host> (default: 127.0.0.1)
|
|
55
|
+
-p, --port <port> (default: 0) which means auto-assigned
|
|
55
56
|
|
|
56
|
-
-q, --quiet
|
|
57
|
-
--no-open
|
|
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
|
|
60
|
-
-v, --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)
|
package/src/server/cli.test.js
CHANGED
|
@@ -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"
|
|
29
|
+
equal(stderr.trim(), `port="not-a-number"\nExpected an integer between 0 and 65535`)
|
|
30
30
|
equal(status, 1)
|
|
31
31
|
})
|
|
32
32
|
|
package/src/server/config.js
CHANGED
|
@@ -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 {
|
|
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) =>
|
|
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,
|
|
24
|
+
watcherDebounceMs: [80, isInt(0, 5000)],
|
|
26
25
|
|
|
27
26
|
host: ['127.0.0.1', is(String)],
|
|
28
|
-
port: [0,
|
|
27
|
+
port: [0, isInt(0, 2 ** 16 - 1)], // 0 means auto-assigned
|
|
29
28
|
|
|
30
|
-
logLevel: ['normal',
|
|
29
|
+
logLevel: ['normal', isOneOf('normal', 'quiet', 'verbose')],
|
|
31
30
|
|
|
32
|
-
delay: [1200,
|
|
33
|
-
delayJitter: [0,
|
|
31
|
+
delay: [1200, isInt(0, 120_000)],
|
|
32
|
+
delayJitter: [0, isFloat(0, 3)],
|
|
34
33
|
|
|
35
|
-
proxyFallback: ['',
|
|
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: [[],
|
|
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
|
|
47
|
-
corsExposedHeaders: [[], Array
|
|
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
|
|
53
|
+
], is(Array)],
|
|
55
54
|
|
|
56
55
|
onReady: [await openInBrowser, is(Function)],
|
|
57
56
|
|
|
@@ -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
|
|
7
|
+
return 'Expected Array'
|
|
8
8
|
if (arr.length === 1 && arr[0] === '*')
|
|
9
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
|
41
|
-
/foo="not-a-number"
|
|
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
|
|
24
|
+
test('rejects mismatched value', () =>
|
|
44
25
|
throws(() =>
|
|
45
|
-
validate({ foo: 0 }, { foo: n => n
|
|
46
|
-
/foo=0
|
|
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
|
|
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)"
|