mockaton 13.4.2 → 13.6.0

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,12 @@
1
- # Mockaton
2
-
3
- An HTTP mock server for simulating APIs with minimal setup — ideal
4
- for testing difficult to reproduce backend states.
5
-
6
1
  ![NPM Version](https://img.shields.io/npm/v/mockaton)
7
2
  [![Test](https://github.com/ericfortis/mockaton/actions/workflows/test.yml/badge.svg)](https://github.com/ericfortis/mockaton/actions/workflows/test.yml)
8
3
  [![codecov](https://codecov.io/github/ericfortis/mockaton/graph/badge.svg?token=90NYLMMG1J)](https://codecov.io/github/ericfortis/mockaton)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
5
 
6
+ ## [Docs ↗](https://mockaton.com) | [Changelog ↗](https://mockaton.com/changelog)
10
7
 
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.
8
+ Mockaton is an HTTP mock server for simulating APIs, designed
9
+ for testing difficult to reproduce backend states with minimal setup.
32
10
 
33
11
  <picture>
34
12
  <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 +14,91 @@ Also, there’s a [Browser Extension](https://mockaton.com/scraping) for scrapin
36
14
  <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
15
  </picture>
38
16
 
39
- <br/>
40
17
 
41
-
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).
18
+ ## Demo (Docker)
19
+ This will spin up Mockaton with the [sample directory](./mockaton-mocks)
20
+ included in this repo mounted on the container.
45
21
 
46
22
  ```sh
47
23
  git clone https://github.com/ericfortis/mockaton.git --depth 1
48
24
  cd mockaton
49
25
  make docker
50
26
  ```
51
- Dashboard: [localhost:2020/mockaton](http://localhost:2020/mockaton)
52
-
53
27
  Test it:
54
- ```shell
28
+ ```sh
55
29
  curl localhost:2020/api/user
56
30
  ```
31
+ Dashboard: [localhost:2020/mockaton](http://localhost:2020/mockaton)
32
+
33
+
34
+ ## Basic Usage
35
+ ```sh
36
+ npx mockaton my-mocks-dir/
37
+ ```
38
+
39
+ Mockaton will serve the files on the given directory. It’s a file-system
40
+ based router, so filenames can have dynamic parameters and comments.
41
+ Also, each route can have different mock file variants.
42
+
43
+
44
+ | Route | Filename | Description |
45
+ | -----| -----| ---|
46
+ | /api/company/123 | api/company/[id].GET.200.json | `[id]` is a dynamic parameter |
47
+ | /media/avatar.png | media/avatar.png | Statics assets don’t need the above extension |
48
+ | /api/login | api/login(invalid attempt).POST.401.json | Anything within parenthesis is a **comment**, they are ignored when routing |
49
+ | /api/login | api/login(default).GET.200.json | `(default)` is a special comment; otherwise, the first mock variant in alphabetical order wins |
50
+ | /api/login | api/login(locked out user).POST.423.ts | TypeScript or JavaScript mocks are sent as JSON by default |
51
+
57
52
 
53
+ ## Docs
54
+ - How to **configure** Mockaton? See [CLI and mockaton.config.js](https://mockaton.com/config) docs.
55
+ - How to **control** Mockaton? Besides the dashboard, there’s a [Programmatic API](https://mockaton.com/api).
56
+ - How to **add plugins**? You can write [Plugins](https://mockaton.com/plugins) for customizing responses.
57
+
58
+
59
+ ## How to scrape your backend APIs?
60
+ Mockaton has a [Browser Extension](https://mockaton.com/scraping) that lets
61
+ you download in bulk all your API responses following Mockaton’s filename convention.
62
+
63
+
64
+ ## How to create mocks?
65
+
66
+ Write to your mocks directory. Alternatively, there’s an API [PATCH /mockaton/write-mock](https://mockaton.com/api).
67
+ ```sh
68
+ mkdir -p my-mocks-dir/api
69
+ echo '{ "name": "John" }' > my-mocks-dir/api/user.GET.200.json
70
+ sleep 0.1 # Wait for the watcher to register it
71
+ ```
58
72
 
59
- ## Examples
60
- [/api/company/123](#)
73
+ ### Example A: JSON
74
+ - **Route:** /api/company/123
75
+ - **Filename:** api/company/[id].GET.200.json
61
76
 
62
- <code>my_mocks_dir/<b>api/company/[id]</b>.GET.200.json</code>
63
77
  ```json
64
78
  {
65
79
  "name": "Acme, Inc."
66
80
  }
67
81
  ```
68
82
 
69
- <br/>
83
+ ### Example B: TypeScript or JavaScript
84
+ Exporting an Object, Array, or String is sent as JSON.
70
85
 
71
- Or, you can write it in TypeScript (it will be sent as JSON).
86
+ - **Route:** /api/company/abc
87
+ - **Filename:** api/company/[id].GET.200.ts
72
88
 
73
- <code>my_mocks_dir/<b>api/company/[id]</b>.GET.200.ts</code>
74
89
  ```ts
75
90
  export default {
76
91
  name: 'Acme, Inc.'
77
92
  }
78
93
  ```
79
94
 
80
- <br/>
95
+ ### Example C: [Function Mocks](https://mockaton.com/function-mocks)
96
+ With a function mock you can do pretty much anything you could do with a normal backend handler.</p>
97
+ For example, you can handle complex logic, URL parsing, saving toa database, etc.
81
98
 
82
- Similarly, you can handle logic with [Functional Mocks](https://mockaton.com/functional-mocks):
99
+ - **Route:** /api/company/abc/user/999
100
+ - **Filename:** api/company/[companyId]/user/[userId].GET.200.ts
83
101
 
84
- <code>my_mocks_dir/<b>api/company/[companyId]/user/[userId]</b>.GET.200.ts</code>
85
102
  ```ts
86
103
  import { IncomingMessage, OutgoingMessage } from 'node:http'
87
104
  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,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "HTTP Mock Server",
4
4
  "type": "module",
5
- "version": "13.4.2",
5
+ "version": "13.6.0",
6
6
  "exports": {
7
7
  ".": {
8
8
  "import": "./index.js",
@@ -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
  }
@@ -65,4 +64,6 @@ export class Commander {
65
64
  * @returns {Promise<Response>}
66
65
  */
67
66
  getSyncVersion = () => fetch(this.addr + API.syncVersion)
67
+
68
+ getOpenAPI = () => fetch(this.addr + API.openAPI)
68
69
  }
@@ -4,25 +4,33 @@ const MOUNT = '/mockaton'
4
4
 
5
5
  export const API = {
6
6
  dashboard: MOUNT,
7
+
8
+ reset: MOUNT + '/reset',
9
+
10
+ select: MOUNT + '/select',
7
11
  bulkSelect: MOUNT + '/bulk-select-by-comment',
8
- collectProxied: MOUNT + '/collect-proxied',
9
- cookies: MOUNT + '/cookies',
10
- cors: MOUNT + '/cors',
12
+
11
13
  delay: MOUNT + '/delay',
14
+ proxied: MOUNT + '/proxied',
15
+ toggleStatus: MOUNT + '/toggle-status',
16
+
17
+ cors: MOUNT + '/cors',
18
+ cookies: MOUNT + '/cookies',
12
19
  fallback: MOUNT + '/fallback',
20
+ collectProxied: MOUNT + '/collect-proxied',
13
21
  globalDelay: MOUNT + '/global-delay',
14
22
  globalDelayJitter: MOUNT + '/global-delay-jitter',
15
- proxied: MOUNT + '/proxied',
16
- reset: MOUNT + '/reset',
17
- select: MOUNT + '/select',
23
+
24
+ writeMock: MOUNT + '/write-mock',
25
+ deleteMock: MOUNT + '/delete-mock',
26
+ watchMocks: MOUNT + '/watch-mocks',
27
+
18
28
  state: MOUNT + '/state',
19
- syncVersion: MOUNT + '/sync-version',
20
29
  throws: MOUNT + '/throws',
21
- toggleStatus: MOUNT + '/toggle-status',
30
+ syncVersion: MOUNT + '/sync-version',
22
31
  watchHotReload: MOUNT + '/watch-hot-reload',
23
- watchMocks: MOUNT + '/watch-mocks',
24
- writeMock: MOUNT + '/write-mock',
25
- deleteMock: MOUNT + '/delete-mock',
32
+
33
+ openAPI: MOUNT + '/openapi'
26
34
  }
27
35
 
28
36
  export const DEFAULT_MOCK_COMMENT = '(default)'
@@ -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 = '') {
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
@@ -7,6 +7,7 @@ import { join } from 'node:path'
7
7
  import { readdirSync } from 'node:fs'
8
8
  import { write, rm, isFile, resolveIn } from './utils/fs.js'
9
9
 
10
+ import openapi from '../../www/src/assets/openapi.json' with { type: 'json' }
10
11
  import pkgJSON from '../../package.json' with { type: 'json' }
11
12
 
12
13
  import { sseClientHotReload } from './utils/WatcherDevClient.js'
@@ -24,7 +25,6 @@ export const CLIENT_DIR = join(import.meta.dirname, '../client')
24
25
  const DASHBOARD_ASSETS = readdirSync(CLIENT_DIR, { recursive: true })
25
26
 
26
27
 
27
-
28
28
  export const apiGetReqs = new Map([
29
29
  [API.dashboard, serveDashboard],
30
30
  ...DASHBOARD_ASSETS.map(f => [API.dashboard + '/' + f, serveStatic(f)]),
@@ -33,7 +33,8 @@ export const apiGetReqs = new Map([
33
33
  [API.syncVersion, sseClientSyncVersion],
34
34
 
35
35
  [API.watchHotReload, onDevWatch],
36
- [API.throws, () => { throw new Error('Test500') }]
36
+ [API.throws, () => { throw new Error('Test500') }],
37
+ [API.openAPI, (_, response) => response.json(openapi)]
37
38
  ])
38
39
 
39
40
 
@@ -257,13 +258,15 @@ async function setRouteIsProxied(req, response) {
257
258
 
258
259
  async function writeMock(req, response) {
259
260
  if (config.readOnly)
260
- return response.forbidden()
261
+ return response.forbidden('Forbidden: Mockaton is in read-only mode. See config.readOnly, or --no-read-only (CLI)')
261
262
 
262
263
  const [file, content] = await req.json()
264
+ if (typeof file !== 'string')
265
+ return response.unprocessable('Invalid or missing filename. Expected: JSON [filename, content]')
266
+
263
267
  const path = await resolveIn(config.mocksDir, file)
264
-
265
268
  if (!path)
266
- return response.forbidden()
269
+ return response.forbidden('Filename path resolves outside config.mocksDir')
267
270
 
268
271
  await write(path, content)
269
272
 
@@ -277,13 +280,13 @@ async function writeMock(req, response) {
277
280
 
278
281
  async function deleteMock(req, response) {
279
282
  if (config.readOnly)
280
- return response.forbidden()
283
+ return response.forbidden('Forbidden: Mockaton is in read-only mode. See config.readOnly, or --no-read-only (CLI)')
281
284
 
282
285
  const file = await req.json()
283
286
  const path = await resolveIn(config.mocksDir, file)
284
287
 
285
288
  if (!path)
286
- return response.forbidden()
289
+ return response.forbidden('Filename path resolves outside config.mocksDir')
287
290
 
288
291
  if (!isFile(path))
289
292
  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) {
@@ -227,6 +227,14 @@ describe('Dashboard', () => {
227
227
  })
228
228
 
229
229
 
230
+ describe('OpenAPI', () => {
231
+ test('serves the json spec', async () => {
232
+ const r = await request(API.openAPI)
233
+ match(await r.text(), new RegExp('"openapi":'))
234
+ })
235
+ })
236
+
237
+
230
238
  describe('Cookie', () => {
231
239
  test('422 when trying to select non-existing cookie', async () => {
232
240
  const r = await api.selectCookie('non-existing-cookie-key')
@@ -360,7 +368,7 @@ describe('Proxy Fallback', () => {
360
368
 
361
369
  equal(r1.headers.get('set-cookie'), CUSTOM_COOKIES.join(', '))
362
370
  equal(r2.headers.get('set-cookie'), CUSTOM_COOKIES.join(', '))
363
-
371
+
364
372
  equal(r1.headers.get('cache-control'), 'no-cache') // unsets cache
365
373
  equal(r2.headers.get('cache-control'), 'no-cache')
366
374
 
@@ -978,12 +986,14 @@ test('head for get. returns the headers without body only for GETs requested as
978
986
 
979
987
 
980
988
  describe('Write and Delete Mock', () => {
981
- test('guards mocksDir', async () => {
989
+ test('rejects filenames resolving outside mocksDir', async () => {
982
990
  const r = await api.writeMock('../outside.txt', '')
983
991
  equal(r.status, 403)
992
+ match(await r.text(), /Filename path resolves outside config.mocksDir/)
984
993
 
985
994
  const r2 = await api.deleteMock('../outside.txt')
986
995
  equal(r2.status, 403)
996
+ match(await r2.text(), /Filename path resolves outside config.mocksDir/)
987
997
  })
988
998
 
989
999
  test('write and delete (with watcher)', async () => {
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)
@@ -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() {
@@ -1,8 +1,8 @@
1
1
  {
2
- "openapi": "3.1.2",
2
+ "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Mockaton Control API",
5
- "version": "1.0.0"
5
+ "version": "1.0.1"
6
6
  },
7
7
  "servers": [
8
8
  {
@@ -10,6 +10,26 @@
10
10
  }
11
11
  ],
12
12
  "paths": {
13
+ "/mockaton/openapi": {
14
+ "get": {
15
+ "summary": "Get OpenAPI spec",
16
+ "x-js-client-example": "await mockaton.getOpenAPI()",
17
+ "responses": {
18
+ "200": {
19
+ "description": "OpenAPI spec",
20
+ "content": {
21
+ "application/json": {
22
+ "schema": {
23
+ "type": "object",
24
+ "additionalProperties": true
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ },
32
+
13
33
  "/mockaton/state": {
14
34
  "get": {
15
35
  "summary": "Get complete Mockaton state",
@@ -31,7 +51,7 @@
31
51
  "/mockaton/write-mock": {
32
52
  "patch": {
33
53
  "summary": "Write a mock file",
34
- "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`.",
35
55
  "x-js-client-example": "await mockaton.writeMock('api/user/friends.GET.200.json', '{ \"friends\": [] }')",
36
56
  "requestBody": {
37
57
  "required": true,
@@ -57,7 +77,7 @@
57
77
  },
58
78
  "responses": {
59
79
  "200": {
60
- "description": "OK"
80
+ "description": "OK. The mock has been written and registered successfully."
61
81
  },
62
82
  "403": {
63
83
  "description": "Forbidden (read-only mode or outside mocksDir)"
@@ -488,29 +508,17 @@
488
508
  },
489
509
  "/mockaton/sync-version": {
490
510
  "get": {
491
- "summary": "Get sync version for long‑polling updates",
511
+ "summary": "Get sync version as SSE",
492
512
  "description": "A counter that’s incremented when a new mock is added, removed, or renamed. Also, when the internal state changes, e.g., when changing the mock file for a route.",
493
513
  "x-js-client-example": "await mockaton.getSyncVersion()",
494
- "parameters": [
495
- {
496
- "name": "sync_version",
497
- "in": "header",
498
- "required": false,
499
- "example": -1,
500
- "schema": {
501
- "type": "number"
502
- },
503
- "description": "When not present, or when the version mismatches it responds right away. Otherwise, it long polls. Times out in 8s."
504
- }
505
- ],
506
514
  "responses": {
507
515
  "200": {
508
- "description": "Sync version value",
516
+ "description": "Stream of sync version updates (SSE)",
509
517
  "content": {
510
- "application/json": {
518
+ "text/event-stream": {
511
519
  "schema": {
512
- "type": "number",
513
- "description": "Incremental integer"
520
+ "type": "integer",
521
+ "description": "Incremental sync version emitted per event"
514
522
  }
515
523
  }
516
524
  }