mockaton 13.3.4 → 13.4.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.
Files changed (41) hide show
  1. package/README.md +28 -16
  2. package/index.d.ts +1 -1
  3. package/index.js +1 -1
  4. package/package.json +1 -1
  5. package/src/client/ApiCommander.js +1 -1
  6. package/src/client/ApiConstants.js +2 -2
  7. package/src/client/IndexHtml.js +18 -18
  8. package/src/client/app-header.js +3 -2
  9. package/src/client/app-payload-viewer.js +4 -7
  10. package/src/client/app-store.js +20 -78
  11. package/src/client/app-store.test.js +1 -25
  12. package/src/client/app.css +9 -5
  13. package/src/client/app.js +7 -7
  14. package/src/client/dir/dittoSplitPaths.js +25 -0
  15. package/src/client/dir/dittoSplitPaths.test.js +28 -0
  16. package/src/client/{dirStructure.js → dir/groupByFolder.js} +17 -13
  17. package/src/client/dir/groupByFolder.test.js +82 -0
  18. package/src/client/graphics.js +2 -2
  19. package/src/client/utils/LocalStorage.js +69 -0
  20. package/src/client/utils/css.js +16 -0
  21. package/src/client/utils/css.test.js +74 -0
  22. package/src/client/{dom-utils.js → utils/dom.js} +2 -21
  23. package/src/client/utils/watcherDev.js +46 -0
  24. package/src/server/Api.js +16 -3
  25. package/src/server/MockDispatcher.js +11 -8
  26. package/src/server/Mockaton.js +18 -8
  27. package/src/server/Mockaton.test.js +14 -9
  28. package/src/server/ProxyRelay.js +9 -3
  29. package/src/server/{utils/UrlParsers.js → UrlParsers.js} +10 -4
  30. package/src/server/{utils/UrlParsers.test.js → UrlParsers.test.js} +14 -14
  31. package/src/server/config.js +2 -0
  32. package/src/server/utils/HttpServerResponse.js +18 -39
  33. package/src/server/{WatcherDevClient.js → utils/WatcherDevClient.js} +3 -17
  34. package/src/server/utils/fs.js +1 -1
  35. package/src/server/utils/logger.js +4 -4
  36. package/src/server/utils/mime.js +11 -11
  37. package/src/server/utils/mime.test.js +15 -11
  38. package/www/src/assets/openapi.json +147 -147
  39. package/src/client/dirStructure.test.js +0 -81
  40. package/src/client/dom-utils.test.js +0 -76
  41. package/src/client/watcherDev.js +0 -39
package/README.md CHANGED
@@ -10,6 +10,26 @@ for testing difficult to reproduce backend states.
10
10
 
11
11
  ## [Documentation ↗](https://mockaton.com) | [Changelog ↗](https://mockaton.com/changelog)
12
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.
32
+
13
33
  <picture>
14
34
  <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">
15
35
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.dark.gold.png">
@@ -36,12 +56,8 @@ curl localhost:2020/api/user
36
56
  ```
37
57
 
38
58
 
39
- ## Overview
40
- With Mockaton, you don’t need to write code for wiring up your
41
- mocks. Instead, a given directory is scanned for filenames
42
- following a convention similar to the URLs.
43
-
44
- For example, for [/api/company/123](#), the file could be:
59
+ ## Examples
60
+ [/api/company/123](#)
45
61
 
46
62
  <code>my_mocks_dir/<b>api/company/[id]</b>.GET.200.json</code>
47
63
  ```json
@@ -50,6 +66,8 @@ For example, for [/api/company/123](#), the file could be:
50
66
  }
51
67
  ```
52
68
 
69
+ <br/>
70
+
53
71
  Or, you can write it in TypeScript (it will be sent as JSON).
54
72
 
55
73
  <code>my_mocks_dir/<b>api/company/[id]</b>.GET.200.ts</code>
@@ -59,15 +77,17 @@ export default {
59
77
  }
60
78
  ```
61
79
 
80
+ <br/>
81
+
62
82
  Similarly, you can handle logic with [Functional Mocks](https://mockaton.com/functional-mocks):
63
83
 
64
84
  <code>my_mocks_dir/<b>api/company/[companyId]/user/[userId]</b>.GET.200.ts</code>
65
85
  ```ts
66
86
  import { IncomingMessage, OutgoingMessage } from 'node:http'
67
- import { parseSplats } from 'mockaton'
87
+ import { parseSegments } from 'mockaton'
68
88
 
69
89
  export default async function (req: IncomingMessage, response: OutgoingMessage) {
70
- const { companyId, userId } = parseSplats(req.url, import.meta.filename)
90
+ const { companyId, userId } = parseSegments(req.url, import.meta.filename)
71
91
  const foo = await getFoo()
72
92
  return JSON.stringify({
73
93
  foo,
@@ -77,11 +97,3 @@ export default async function (req: IncomingMessage, response: OutgoingMessage)
77
97
  })
78
98
  }
79
99
  ```
80
-
81
- ## Browser Extension
82
- [Browser Extension](https://mockaton.com/scraping) for scraping responses from your backend.
83
-
84
-
85
- ## API
86
- Programmatic [Control API](https://mockaton.com/api).
87
-
package/index.d.ts CHANGED
@@ -62,7 +62,7 @@ export const echoFilePlugin: Plugin
62
62
  export function jwtCookie(cookieName: string, payload: any, path?: string): string
63
63
 
64
64
  export function parseJSON(request: IncomingMessage): Promise<any>
65
- export function parseSplats(reqUrl: string, filename: string): Record<string, string>
65
+ export function parseSegments(reqUrl: string, filename: string): Record<string, string>
66
66
  export function parseQueryParams(reqUrl: string): URLSearchParams
67
67
 
68
68
  export type JsonPromise<T> = Promise<Response & { json(): Promise<T> }>
package/index.js CHANGED
@@ -5,6 +5,6 @@ export { Mockaton } from './src/server/Mockaton.js'
5
5
  export { jwtCookie } from './src/server/utils/jwt.js'
6
6
  export { jsToJsonPlugin, echoFilePlugin } from './src/server/MockDispatcherPlugins.js'
7
7
  export { parseJSON, BodyReaderError } from './src/server/utils/HttpIncomingMessage.js'
8
- export { parseSplats, parseQueryParams } from './src/server/utils/UrlParsers.js'
8
+ export { parseSegments, parseQueryParams, parseSplats } from './src/server/UrlParsers.js'
9
9
 
10
10
  export const defineConfig = opts => opts
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.3.4",
5
+ "version": "13.4.0",
6
6
  "exports": {
7
7
  ".": {
8
8
  "import": "./index.js",
@@ -10,7 +10,7 @@ export class Commander {
10
10
  }
11
11
 
12
12
  /** @returns {Promise<Response>} */
13
- #patch = (api, body) => fetch(this.addr + api, {
13
+ #patch = (api, body = undefined) => fetch(this.addr + api, {
14
14
  method: 'PATCH',
15
15
  body: JSON.stringify(body)
16
16
  })
@@ -25,9 +25,9 @@ export const API = {
25
25
  deleteMock: MOUNT + '/delete-mock',
26
26
  }
27
27
 
28
- export const HEADER_502 = 'Mockaton502'
29
-
30
28
  export const DEFAULT_MOCK_COMMENT = '(default)'
31
29
 
32
30
  export const EXT_UNKNOWN_MIME = 'unknown'
33
31
  export const EXT_EMPTY = 'empty'
32
+
33
+ export const FILENAME_HEADER = 'Mockaton-File'
@@ -7,24 +7,24 @@ export const CSP = [
7
7
 
8
8
  // language=html
9
9
  export const IndexHtml = (hotReloadEnabled, version) => `
10
- <!DOCTYPE html>
11
- <html lang="en-US">
12
- <head>
13
- <meta charset="UTF-8">
14
- <base href="${API.dashboard}/">
10
+ <!DOCTYPE html>
11
+ <html lang="en-US">
12
+ <head>
13
+ <meta charset="UTF-8">
14
+ <base href="${API.dashboard}/">
15
15
 
16
- <script type="module" src="app.js"></script>
17
- <link rel="preload" href="${API.state}" as="fetch" crossorigin>
16
+ <script type="module" src="app.js"></script>
17
+ <link rel="preload" href="${API.state}" as="fetch" crossorigin>
18
18
 
19
- <link rel="icon" href="data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m235 33.7v202c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-151c-0.115-4.49-6.72-5.88-8.46-0.87l-48.3 155c-2.22 7.01-7.72 10.1-16 9.9-3.63-0.191-7.01-1.14-9.66-2.89-2.89-1.72-4.83-4.34-5.57-7.72-11.1-37-22.6-74.3-34.1-111-4.34-14-8.95-31.4-14-48.3-1.82-4.83-8.16-5.32-8.46 1.16v156c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-207c0-5.74 2.62-13.2 9.39-16.3 7.5-3.14 15-4.05 21.8-3.8 3.14 0 6.03 0.686 8.95 1.46 3.14 0.797 6.03 1.98 8.7 3.63 2.65 1.38 5.32 3.14 7.5 5.57 2.22 2.22 3.87 4.83 5.07 7.72l45.8 157c4.63-15.9 32.4-117 33.3-121 4.12-13.8 7.72-26.5 10.9-38.7 1.16-2.65 2.89-5.32 5.07-7.5 2.15-2.15 4.58-4.12 7.5-5.32 2.65-1.57 5.57-2.89 8.46-3.63 3.14-0.797 9.44-0.988 12.1-0.988 11.6 1.07 29.4 9.14 29.4 27z' fill='%23808080'/%3E%3C/svg%3E">
20
- <meta name="viewport" content="width=device-width, initial-scale=1">
21
- <meta name="description" content="HTTP Mock Server">
22
- <title>Mockaton v${version}</title>
23
- </head>
24
- <body>
25
- ${hotReloadEnabled
26
- ? '<script type="module" src="watcherDev.js"></script>'
27
- : ''}
28
- </body>
29
- </html>
19
+ <link rel="icon" href="data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m235 33.7v202c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-151c-0.115-4.49-6.72-5.88-8.46-0.87l-48.3 155c-2.22 7.01-7.72 10.1-16 9.9-3.63-0.191-7.01-1.14-9.66-2.89-2.89-1.72-4.83-4.34-5.57-7.72-11.1-37-22.6-74.3-34.1-111-4.34-14-8.95-31.4-14-48.3-1.82-4.83-8.16-5.32-8.46 1.16v156c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-207c0-5.74 2.62-13.2 9.39-16.3 7.5-3.14 15-4.05 21.8-3.8 3.14 0 6.03 0.686 8.95 1.46 3.14 0.797 6.03 1.98 8.7 3.63 2.65 1.38 5.32 3.14 7.5 5.57 2.22 2.22 3.87 4.83 5.07 7.72l45.8 157c4.63-15.9 32.4-117 33.3-121 4.12-13.8 7.72-26.5 10.9-38.7 1.16-2.65 2.89-5.32 5.07-7.5 2.15-2.15 4.58-4.12 7.5-5.32 2.65-1.57 5.57-2.89 8.46-3.63 3.14-0.797 9.44-0.988 12.1-0.988 11.6 1.07 29.4 9.14 29.4 27z' fill='%23808080'/%3E%3C/svg%3E">
20
+ <meta name="viewport" content="width=device-width, initial-scale=1">
21
+ <meta name="description" content="HTTP Mock Server">
22
+ <title>Mockaton v${version}</title>
23
+ </head>
24
+ <body>
25
+ ${hotReloadEnabled
26
+ ? `<script type="module" src="utils/watcherDev.js?url=${API.watchHotReload}"></script>`
27
+ : ''}
28
+ </body>
29
+ </html>
30
30
  `
@@ -1,9 +1,10 @@
1
- import { createElement as r, t, defineClassNames } from './dom-utils.js'
1
+ import { createElement as r, t } from './utils/dom.js'
2
2
  import { Logo, HelpIcon } from './graphics.js'
3
3
  import { store } from './app-store.js'
4
4
 
5
5
  import CSS from './app.css' with { type: 'css' }
6
- defineClassNames(CSS)
6
+ import { extractClassNames } from './utils/css.js'
7
+ Object.assign(CSS, extractClassNames(CSS))
7
8
 
8
9
 
9
10
  export function Header() {
@@ -1,10 +1,10 @@
1
- import { createElement as r, t, defineClassNames } from './dom-utils.js'
2
- import { HEADER_502 } from './ApiConstants.js'
1
+ import { createElement as r, t } from './utils/dom.js'
3
2
  import { parseFilename } from './Filename.js'
4
3
  import { store } from './app-store.js'
5
4
 
6
5
  import CSS from './app.css' with { type: 'css' }
7
- defineClassNames(CSS)
6
+ import { extractClassNames } from './utils/css.js'
7
+ Object.assign(CSS, extractClassNames(CSS))
8
8
 
9
9
 
10
10
  const titleRef = {}
@@ -41,12 +41,9 @@ function PayloadViewerTitle(file, statusText) {
41
41
 
42
42
  function PayloadViewerTitleWhenProxied(response) {
43
43
  const mime = response.headers.get('content-type') || ''
44
- const badGateway = response.headers.get(HEADER_502)
45
44
  return (
46
45
  r('span', null,
47
- badGateway
48
- ? r('span', null, t`⛔ Fallback Backend Error` + ' ')
49
- : r('span', null, t`Got` + ' '),
46
+ r('span', null, t`Got` + ' '),
50
47
  r('abbr', { title: response.statusText }, response.status),
51
48
  ' ' + mime))
52
49
  }
@@ -1,5 +1,8 @@
1
1
  import { Commander } from './ApiCommander.js'
2
+ import { groupByFolder } from './dir/groupByFolder.js'
3
+ import { dittoSplitPaths } from './dir/dittoSplitPaths.js'
2
4
  import { parseFilename, extractComments } from './Filename.js'
5
+ import { LocalStorageSet, QueryParamBool } from './utils/LocalStorage.js'
3
6
  import { EXT_UNKNOWN_MIME, EXT_EMPTY } from './ApiConstants.js'
4
7
 
5
8
 
@@ -22,25 +25,21 @@ export const store = {
22
25
  collectProxied: false,
23
26
  proxyFallback: '',
24
27
  showProxyField: null,
25
- get canProxy() {
26
- return Boolean(store.proxyFallback)
27
- },
28
+ get canProxy() { return Boolean(store.proxyFallback) },
28
29
 
29
- groupByMethod: initPreference('groupByMethod'),
30
+ _groupByMethod: new QueryParamBool('groupByMethod'),
31
+ get groupByMethod() { return store._groupByMethod.value },
30
32
  toggleGroupByMethod() {
31
- store.groupByMethod = !store.groupByMethod
32
- togglePreference('groupByMethod', store.groupByMethod)
33
+ store._groupByMethod.toggle()
33
34
  store.render()
34
35
  },
35
36
 
36
- collapsedFolders: new Set(JSON.parse(globalThis.localStorage?.getItem('collapsedFolders') || '[]')),
37
+ collapsedFolders: new LocalStorageSet('collapsedFolders'),
37
38
  setFolderCollapsed(folder, collapsed) {
38
39
  if (collapsed)
39
40
  store.collapsedFolders.add(folder)
40
41
  else
41
42
  store.collapsedFolders.delete(folder)
42
-
43
- globalThis.localStorage?.setItem('collapsedFolders', JSON.stringify([...store.collapsedFolders]))
44
43
  },
45
44
 
46
45
  chosenLink: { method: '', urlMask: '' },
@@ -123,10 +122,6 @@ export const store = {
123
122
 
124
123
  _dittoCache: new Map(),
125
124
 
126
- brokerFor(method, urlMask) {
127
- return store.brokersByMethod[method]?.[urlMask]
128
- },
129
-
130
125
  brokerAsRow(method, urlMask) {
131
126
  const b = store.brokerFor(method, urlMask)
132
127
  const r = new BrokerRowModel(b, store.canProxy)
@@ -134,13 +129,15 @@ export const store = {
134
129
  return r
135
130
  },
136
131
 
137
- _setBroker(broker) {
138
- const { method, urlMask } = parseFilename(broker.file)
139
- store.brokersByMethod[method] ??= {}
140
- store.brokersByMethod[method][urlMask] = broker
132
+ brokerFor(method, urlMask) {
133
+ return store.brokersByMethod[method]?.[urlMask]
134
+ },
135
+
136
+ folderGroupsByMethod(method) {
137
+ return groupByFolder(store._brokersAsRowsByMethod(method))
141
138
  },
142
139
 
143
- brokersAsRowsByMethod(method) {
140
+ _brokersAsRowsByMethod(method) {
144
141
  const rows = store._brokersAsArray(method)
145
142
  .map(b => new BrokerRowModel(b, store.canProxy))
146
143
  .sort((a, b) => a.urlMask.localeCompare(b.urlMask))
@@ -200,70 +197,15 @@ export const store = {
200
197
  store._request(() => api.setRouteIsDelayed(method, urlMask, checked), async response => {
201
198
  store._setBroker(await response.json())
202
199
  })
203
- }
204
- }
205
-
206
-
200
+ },
207
201
 
208
- // When false, the URL will be updated with param=false
209
- function initPreference(param) {
210
- const qs = new URLSearchParams(globalThis.location?.search)
211
- if (!qs.has(param)) {
212
- const group = globalThis.localStorage?.getItem(param) !== '0'
213
- if (!group) {
214
- const url = new URL(globalThis.location?.href)
215
- url.searchParams.set(param, '0')
216
- history.replaceState(null, '', url)
217
- }
218
- return group
202
+ _setBroker(broker) {
203
+ const { method, urlMask } = parseFilename(broker.file)
204
+ store.brokersByMethod[method] ??= {}
205
+ store.brokersByMethod[method][urlMask] = broker
219
206
  }
220
- return qs.get(param) !== '0'
221
- }
222
-
223
- // When false, the URL and localStorage will have param='0'
224
- function togglePreference(param, nextVal) {
225
- if (nextVal)
226
- globalThis.localStorage?.removeItem(param)
227
- else
228
- globalThis.localStorage?.setItem(param, nextVal)
229
-
230
- const url = new URL(location.href)
231
- if (nextVal)
232
- url.searchParams.delete(param)
233
- else
234
- url.searchParams.set(param, '0')
235
- history.replaceState(null, '', url)
236
207
  }
237
208
 
238
-
239
-
240
- /**
241
- * Think of this as a way of printing a directory tree in which
242
- * the repeated folder paths are kept but styled differently.
243
- * @param {string[]} paths - sorted
244
- */
245
- export function dittoSplitPaths(paths) {
246
- const pParts = paths.map(p => p.split('/').filter(Boolean))
247
- return paths.map((p, i) => {
248
- if (i === 0)
249
- return ['', p]
250
-
251
- const prev = pParts[i - 1]
252
- const curr = pParts[i]
253
- const min = Math.min(curr.length, prev.length)
254
- let j = 0
255
- while (j < min && curr[j] === prev[j])
256
- j++
257
-
258
- if (!j) // no common dirs
259
- return ['', p]
260
-
261
- const ditto = '/' + curr.slice(0, j).join('/') + '/'
262
- return [ditto, p.slice(ditto.length)]
263
- })
264
- }
265
-
266
-
267
209
  export class BrokerRowModel {
268
210
  opts = /** @type {[key:string, label:string, selected:boolean][]} */ []
269
211
  isNew = false
@@ -1,31 +1,7 @@
1
1
  import { test } from 'node:test'
2
2
  import { deepEqual } from 'node:assert/strict'
3
3
 
4
- import { dittoSplitPaths, BrokerRowModel, t } from './app-store.js'
5
-
6
-
7
- test('dittoSplitPaths', () => {
8
- const input = [
9
- '/api/user',
10
- '/api/user/avatar',
11
- '/api/user/friends',
12
- '/api/vid',
13
- '/api/video/id',
14
- '/api/video/stats',
15
- '/v2/foo',
16
- '/v2/foo/bar'
17
- ]
18
- deepEqual(dittoSplitPaths(input), [
19
- ['', '/api/user'],
20
- ['/api/user/', 'avatar'],
21
- ['/api/user/', 'friends'],
22
- ['/api/', 'vid'],
23
- ['/api/', 'video/id'],
24
- ['/api/video/', 'stats'],
25
- ['', '/v2/foo'],
26
- ['/v2/foo/', 'bar']
27
- ])
28
- })
4
+ import { BrokerRowModel, t } from './app-store.js'
29
5
 
30
6
 
31
7
  test('BrokerRowModel', () => {
@@ -185,15 +185,19 @@ header {
185
185
  }
186
186
 
187
187
  .HelpLink {
188
- width: 24px;
189
- height: 24px;
188
+ width: 22px;
189
+ height: 22px;
190
190
  flex-shrink: 0;
191
191
  align-self: end;
192
- margin-bottom: 2px;
192
+ margin-bottom: 3px;
193
193
  margin-left: auto;
194
194
  border-radius: 50%;
195
- fill: white;
196
- background: var(--colorAccent);
195
+ fill: var(--colorBgHeader);
196
+ background: var(--colorLabel);
197
+
198
+ &:hover {
199
+ background: var(--colorAccent);
200
+ }
197
201
 
198
202
  svg {
199
203
  transform: scale(.7);
package/src/client/app.js CHANGED
@@ -1,15 +1,15 @@
1
- import { createElement as r, t, classNames, restoreFocus, Fragment, defineClassNames } from './dom-utils.js'
1
+ import { createElement as r, t, restoreFocus, Fragment } from './utils/dom.js'
2
2
 
3
3
  import { store } from './app-store.js'
4
4
  import { API } from './ApiConstants.js'
5
5
  import { Header } from './app-header.js'
6
- import { dirStructure } from './dirStructure.js'
7
6
  import { PayloadViewer, previewMock } from './app-payload-viewer.js'
8
7
  import { TimerIcon, CloudIcon, ChevronDownIcon } from './graphics.js'
9
8
 
10
9
  import CSS from './app.css' with { type: 'css' }
10
+ import { extractClassNames, classNames } from './utils/css.js'
11
11
  document.adoptedStyleSheets.push(CSS)
12
- defineClassNames(CSS)
12
+ Object.assign(CSS, extractClassNames(CSS))
13
13
 
14
14
 
15
15
  store.onError = onError
@@ -100,14 +100,14 @@ function MockList() {
100
100
  r('div', {
101
101
  className: classNames(CSS.TableHeading, store.canProxy && CSS.canProxy)
102
102
  }, method),
103
- FolderGroups(store.brokersAsRowsByMethod(method))))
103
+ FolderGroups(store.folderGroupsByMethod(method))))
104
104
 
105
- return FolderGroups(store.brokersAsRowsByMethod('*'))
105
+ return FolderGroups(store.folderGroupsByMethod('*'))
106
106
  }
107
107
 
108
- function FolderGroups(bRows) {
108
+ function FolderGroups(brokersTree) {
109
109
  const res = []
110
- for (const b of dirStructure(bRows)) {
110
+ for (const b of brokersTree) {
111
111
  if (!b.children.length)
112
112
  res.push(Row(b))
113
113
  else
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Think of this as a way of printing a directory tree in which
3
+ * the repeated folder paths are kept but styled differently.
4
+ * @param {string[]} paths - sorted
5
+ */
6
+ export function dittoSplitPaths(paths) {
7
+ const pParts = paths.map(p => p.split('/').filter(Boolean))
8
+ return paths.map((p, i) => {
9
+ if (i === 0)
10
+ return ['', p]
11
+
12
+ const prev = pParts[i - 1]
13
+ const curr = pParts[i]
14
+ const min = Math.min(curr.length, prev.length)
15
+ let j = 0
16
+ while (j < min && curr[j] === prev[j])
17
+ j++
18
+
19
+ if (!j) // no common dirs
20
+ return ['', p]
21
+
22
+ const ditto = '/' + curr.slice(0, j).join('/') + '/'
23
+ return [ditto, p.slice(ditto.length)]
24
+ })
25
+ }
@@ -0,0 +1,28 @@
1
+ import { test } from 'node:test'
2
+ import { deepEqual } from 'node:assert/strict'
3
+ import { dittoSplitPaths } from './dittoSplitPaths.js'
4
+
5
+
6
+ test('dittoSplitPaths', () => {
7
+ const input = [
8
+ '/api/user',
9
+ '/api/user/avatar',
10
+ '/api/user/friends',
11
+ '/api/vid',
12
+ '/api/video/id',
13
+ '/api/video/stats',
14
+ '/v2/foo',
15
+ '/v2/foo/bar'
16
+ ]
17
+ deepEqual(dittoSplitPaths(input), [
18
+ ['', '/api/user'],
19
+ ['/api/user/', 'avatar'],
20
+ ['/api/user/', 'friends'],
21
+ ['/api/', 'vid'],
22
+ ['/api/', 'video/id'],
23
+ ['/api/video/', 'stats'],
24
+ ['', '/v2/foo'],
25
+ ['/v2/foo/', 'bar']
26
+ ])
27
+ })
28
+
@@ -1,13 +1,8 @@
1
- function TrieNode() {
2
- this.brokers = []
3
- this.tnChildren = new Map()
4
- }
5
-
6
1
  /**
7
2
  * @param {Partial<BrokerRowModel>[]} brokers
8
3
  * @returns {Partial<BrokerRowModel>[]}
9
4
  */
10
- export function dirStructure(brokers) {
5
+ export function groupByFolder(brokers) {
11
6
  return dfs(trie(brokers))
12
7
  }
13
8
 
@@ -16,8 +11,8 @@ function trie(brokers) {
16
11
  for (const b of brokers) {
17
12
  let node = root
18
13
  for (const seg of b.urlMask.split('/')) { // TODO it should ignore query string
19
- const segNode = node.tnChildren.get(seg) || new TrieNode()
20
- node.tnChildren.set(seg, segNode)
14
+ const segNode = node.getChild(seg) || new TrieNode()
15
+ node.addChild(seg, segNode)
21
16
  node = segNode
22
17
  }
23
18
  node.brokers.push(b)
@@ -25,10 +20,18 @@ function trie(brokers) {
25
20
  return root
26
21
  }
27
22
 
23
+ class TrieNode {
24
+ #children = new Map()
25
+ brokers = []
26
+ addChild(k, v) { this.#children.set(k, v) }
27
+ getChild(k) { return this.#children.get(k) }
28
+ getChildren() { return this.#children.values() }
29
+ }
30
+
28
31
  /** @param {TrieNode} node */
29
32
  function dfs(node) {
30
33
  const childBrokers = []
31
- for (const tnc of node.tnChildren.values())
34
+ for (const tnc of node.getChildren())
32
35
  childBrokers.push(...dfs(tnc))
33
36
 
34
37
  const brokers = node.brokers.length
@@ -38,10 +41,11 @@ function dfs(node) {
38
41
  if (!brokers.length)
39
42
  return []
40
43
 
41
- const [head, ...rest] = brokers
42
- if (node.brokers.length || !head.children.length) {
43
- head.children.push(...rest)
44
- return [head]
44
+ const [b0, ...rest] = brokers
45
+ if (node.brokers.length || !b0.children.length) {
46
+ b0.children.push(...rest)
47
+ return [b0]
45
48
  }
46
49
  return brokers
47
50
  }
51
+
@@ -0,0 +1,82 @@
1
+ import { test } from 'node:test'
2
+ import { deepEqual } from 'node:assert/strict'
3
+ import { groupByFolder } from './groupByFolder.js'
4
+
5
+
6
+ test('groupByFolder', () => {
7
+ 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' }
19
+ ]
20
+
21
+
22
+ 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
+ }
79
+ ]
80
+
81
+ deepEqual(groupByFolder(input), expected)
82
+ })