mockaton 0.0.1

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 (58) hide show
  1. package/Api.js +108 -0
  2. package/ApiConstants.js +22 -0
  3. package/Config.js +41 -0
  4. package/Dashboard.css +206 -0
  5. package/Dashboard.html +12 -0
  6. package/Dashboard.js +355 -0
  7. package/LICENSE +21 -0
  8. package/MockBroker.js +107 -0
  9. package/MockDispatcher.js +72 -0
  10. package/Mockaton.js +39 -0
  11. package/README-dashboard-dropdown.png +0 -0
  12. package/README-dashboard.png +0 -0
  13. package/README-mocks-with-comments.png +0 -0
  14. package/README.md +211 -0
  15. package/Route.js +90 -0
  16. package/StaticDispatcher.js +29 -0
  17. package/Tests.js +367 -0
  18. package/_usage_example.js +14 -0
  19. package/cookie.js +29 -0
  20. package/index.d.ts +17 -0
  21. package/index.js +2 -0
  22. package/mockBrokersCollection.js +84 -0
  23. package/package.json +12 -0
  24. package/sample-mocks/api/user/.GET.200.json +1 -0
  25. package/sample-mocks/api/user/.GET.501.txt +7 -0
  26. package/sample-mocks/api/user/edit-name.PATCH.200.json +1 -0
  27. package/sample-mocks/api/user/edit-name.PATCH.200.md +12 -0
  28. package/sample-mocks/api/user/edit-name.PATCH.501.txt +0 -0
  29. package/sample-mocks/api/user/friends.GET.200.json +1 -0
  30. package/sample-mocks/api/user/friends.GET.204.json +4 -0
  31. package/sample-mocks/api/user/friends.GET.501.txt +0 -0
  32. package/sample-mocks/api/user/logout.POST.200.json +1 -0
  33. package/sample-mocks/api/user/logout.POST.501.txt +0 -0
  34. package/sample-mocks/api/user/videos(assorted).GET.200.json +13 -0
  35. package/sample-mocks/api/user/videos(entirely unverified).GET.200.json +13 -0
  36. package/sample-mocks/api/user/videos(entirely verified)(another comment).GET.200.json +13 -0
  37. package/sample-mocks/api/user/videos.GET.501.txt +0 -0
  38. package/sample-mocks/api/video/[id].GET.200.json +4 -0
  39. package/sample-mocks/api/video/[id].GET.501.txt +0 -0
  40. package/sample-mocks/api/video/list(concat newly uploaded).GET.200.mjs +8 -0
  41. package/sample-mocks/api/video/list.GET.200.json +11 -0
  42. package/sample-mocks/api/video/list.GET.501.txt +0 -0
  43. package/sample-mocks/api/video/stat/[stat-id]/[video-id].GET.200.json +1 -0
  44. package/sample-mocks/api/video/stat/[stat-id]/[video-id].GET.501.txt +0 -0
  45. package/sample-mocks/api/video/stat/[stat-id]/all-videos?limit=[limit].GET.200.json +4 -0
  46. package/sample-mocks/api/video/stat/[stat-id]/all-videos?limit=[limit].GET.501.txt +0 -0
  47. package/sample-mocks/api/video/upload(insert newly uploaded).POST.201.mjs +10 -0
  48. package/sample-mocks/api/video/upload.POST.201.json +3 -0
  49. package/sample-mocks/api/video/upload.POST.501.txt +0 -0
  50. package/sample-static/another-entry/index.html +12 -0
  51. package/sample-static/assets/app.js +1 -0
  52. package/sample-static/assets/video.mp4 +0 -0
  53. package/sample-static/index.html +13 -0
  54. package/utils/http-request.js +36 -0
  55. package/utils/http-response.js +60 -0
  56. package/utils/jwt.js +21 -0
  57. package/utils/mime.js +47 -0
  58. package/utils/validate.js +17 -0
package/cookie.js ADDED
@@ -0,0 +1,29 @@
1
+ export const cookie = new class {
2
+ #cookies = {}
3
+ #currentKey = ''
4
+
5
+ init(cookies = {}) {
6
+ this.#cookies = cookies
7
+ const keys = Object.keys(cookies)
8
+ if (keys.length)
9
+ this.#currentKey = keys[0]
10
+ }
11
+
12
+ getCurrent() {
13
+ return this.#cookies[this.#currentKey]
14
+ }
15
+
16
+ setCurrent(key) {
17
+ if (key in this.#cookies)
18
+ this.#currentKey = key
19
+ else
20
+ throw 'Cookie key not found' // TESTME
21
+ }
22
+
23
+ list() {
24
+ return Object.keys(this.#cookies).map(key => [
25
+ key,
26
+ key === this.#currentKey // selected
27
+ ])
28
+ }
29
+ }
package/index.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { Server } from 'node:http';
2
+
3
+ interface Config {
4
+ mocksDir: string
5
+ staticDir?: string
6
+ host?: string,
7
+ port?: number
8
+ delay?: number
9
+ cookies?(): object
10
+ database?: object
11
+ skipOpen?: boolean
12
+ allowedExt?: RegExp
13
+ }
14
+
15
+ export function Mockaton(options: Config): Server
16
+
17
+ export function jwtCookie(cookieName: string, payload: any): string
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { Mockaton } from './Mockaton.js'
2
+ export { jwtCookie } from './utils/jwt.js'
@@ -0,0 +1,84 @@
1
+ import { join } from 'node:path'
2
+ import { readdirSync, lstatSync } from 'node:fs'
3
+
4
+ import { Route } from './Route.js'
5
+ import { Config } from './Config.js'
6
+ import { cookie } from './cookie.js'
7
+ import { MockBroker } from './MockBroker.js'
8
+
9
+
10
+ /**
11
+ * @example
12
+ * {
13
+ * GET: {
14
+ * /api/route-a: <MockBroker>
15
+ * /api/route-b: <MockBroker>
16
+ * },
17
+ * POST: {…}
18
+ * }
19
+ */
20
+ let collection = {}
21
+
22
+ export function init() {
23
+ cookie.init(Config.cookies)
24
+
25
+ collection = {}
26
+ for (const file of listMocksDirRecursively()) {
27
+ const { error, method, urlMask } = Route.parseFilename(file)
28
+ if (error) // skip
29
+ console.error(error, file)
30
+ else {
31
+ collection[method] ??= {}
32
+ if (!(urlMask in collection[method]))
33
+ collection[method][urlMask] = new MockBroker(file)
34
+ else
35
+ collection[method][urlMask].register(file)
36
+ }
37
+ }
38
+ forEachBroker(broker => broker.ensureItHas501())
39
+ }
40
+
41
+ export const getAll = () => collection
42
+ export const getBroker = (method, urlMask) => collection[method][urlMask]
43
+ export const getBrokerByFilename = file => {
44
+ const { method, urlMask } = Route.parseFilename(file)
45
+ return getBroker(method, urlMask)
46
+ }
47
+
48
+
49
+ // Searching the routes in reverse order so dynamic params (e.g.
50
+ // /user/<id>) don’t take precedence over exact paths (e.g.
51
+ // /user/name). That’s because "<" chars are lower than alphanumeric ones.
52
+ // BTW, `urlMasks` always start with "/", so there’s no need to
53
+ // worry about the primacy of array-like keys when iterating.
54
+ export function findMatchingBroker(method, url) {
55
+ const brokers = Object.values(collection[method])
56
+ for (let i = brokers.length - 1; i >= 0; i--)
57
+ if (brokers[i].urlMaskMatches(url))
58
+ return brokers[i]
59
+ }
60
+
61
+ export function extractAllComments() {
62
+ const comments = new Set()
63
+ forEachBroker(broker => {
64
+ for (const comment of broker.extractComments())
65
+ comments.add(comment)
66
+ })
67
+ return Array.from(comments)
68
+ }
69
+
70
+ export function setMocksMatchingComment(comment) {
71
+ forEachBroker(broker => broker.setByMatchingComment(comment))
72
+ }
73
+
74
+ function listMocksDirRecursively() {
75
+ return readdirSync(Config.mocksDir, { recursive: true })
76
+ .filter(f => Config.allowedExt.test(f) && lstatSync(join(Config.mocksDir, f)).isFile())
77
+ .sort()
78
+ }
79
+
80
+ function forEachBroker(fn) {
81
+ for (const brokers of Object.values(collection))
82
+ Object.values(brokers).forEach(fn)
83
+ }
84
+
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "mockaton",
3
+ "description": "A deterministic server-side for developing frontend clients",
4
+ "type": "module",
5
+ "version": "0.0.1",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "scripts": {
9
+ "test": "./Tests.js",
10
+ "demo": "_usage_example.js"
11
+ }
12
+ }
@@ -0,0 +1 @@
1
+ "This is an index-like route (i.e. /api/user). This file has no name, only the extension convention is needed for the index path."
@@ -0,0 +1,7 @@
1
+ This is a plain text response for (/api/user).
2
+
3
+ In this case, it’s for mocking up a 501 - Internal Server Error.
4
+
5
+ This file could have been empty, or some JSON if it had a `.json` extension.
6
+
7
+ By the way, on initialization an 501 is auto-generated for routes that don’t have a 501.
@@ -0,0 +1 @@
1
+ { "renamed": "OK" }
@@ -0,0 +1,12 @@
1
+ # PATCH /api/user/edit
2
+
3
+ ## Expected Request Body
4
+ ```json
5
+ {
6
+ "new_name": "John"
7
+ }
8
+ ```
9
+
10
+ Example of a markdown file for documenting the request contract.
11
+
12
+ Markdown files can be seen in the mock server dashboard as well.
File without changes
@@ -0,0 +1 @@
1
+ "This is an exact path (i.e. /api/user/friends)"
@@ -0,0 +1,4 @@
1
+ {
2
+ "_": "An example response of a user without friends. Uses HTTP Status: 204 - No Content",
3
+ "friends": []
4
+ }
File without changes
File without changes
@@ -0,0 +1,13 @@
1
+ {
2
+ "_": "This file has a comment: `(assorted)`. i.e. the route is /api/user/videos",
3
+ "videos": [
4
+ {
5
+ "url": "https://example.com/1",
6
+ "verified": true
7
+ },
8
+ {
9
+ "url": "https://example.com/2",
10
+ "verified": false
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "_": "This file has a comment: `(entirely unverified)`. i.e. the route is /api/user/videos",
3
+ "videos": [
4
+ {
5
+ "url": "https://example.com/1",
6
+ "verified": false
7
+ },
8
+ {
9
+ "url": "https://example.com/2",
10
+ "verified": false
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "_": "This file has two comments: `(entirely verified)` and `(another comment)`. i.e. the route is /api/user/videos",
3
+ "videos": [
4
+ {
5
+ "url": "https://example.com/1",
6
+ "verified": true
7
+ },
8
+ {
9
+ "url": "https://example.com/2",
10
+ "verified": true
11
+ }
12
+ ]
13
+ }
File without changes
@@ -0,0 +1,4 @@
1
+ {
2
+ "_": "This route has a dynamic param `<id>`. i.e. /api/video/123",
3
+ "url": "https://example.com/123"
4
+ }
File without changes
@@ -0,0 +1,8 @@
1
+ // This is an example "transform". It takes the mock for the same route as
2
+ // input, so you can modify it. In this case, it uses the `database` field.
3
+
4
+ export default function concatNewlyUploadedVideos(mockAsText, _, config) {
5
+ const mockList = JSON.parse(mockAsText)
6
+ mockList.videos = mockList.videos.concat(config.database.videos || [])
7
+ return JSON.stringify(mockList, null, 2)
8
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "_": "This file has query string params, but they are fully ignored. i.e. /api/video/list and /api/video/list?page_num=1 would match as well",
3
+ "videos": [
4
+ {
5
+ "url": "https://example.com/1"
6
+ },
7
+ {
8
+ "url": "https://example.com/2"
9
+ }
10
+ ]
11
+ }
File without changes
@@ -0,0 +1 @@
1
+ "Two Dynamic Params. i.e. /api/video/stat/VIEWS/123"
@@ -0,0 +1,4 @@
1
+ {
2
+ "_": "Example filename with a query string. BTW, it doesn't work on Windows, because '?' is an illegal character. At any rate, the full query string is ignored.",
3
+ "data": []
4
+ }
@@ -0,0 +1,10 @@
1
+ // An example "transform" for saving a POST request payload into the `config.database`
2
+
3
+ export default function concatNewlyUploadedVideos(mockAsText, requestBody, config) {
4
+ config.database.videos ??= []
5
+ config.database.videos.push({
6
+ createdAt: Date.now(),
7
+ ...requestBody
8
+ })
9
+ return JSON.stringify({})
10
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "created": "OK"
3
+ }
File without changes
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta content="width=device-width" name="viewport">
6
+ <link rel="icon" href="favicon.svg" type="image/svg+xml">
7
+ <title>Another Entry Point</title>
8
+ </head>
9
+ <body>
10
+ <h1>Another Entry Point</h1>
11
+ </body>
12
+ </html>
@@ -0,0 +1 @@
1
+ console.log('app')
Binary file
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta content="width=device-width" name="viewport">
6
+ <title>Title</title>
7
+ </head>
8
+ <body>
9
+ <h1>Sample</h1>
10
+ <video src="assets/video.mp4" width="720" controls></video>
11
+ <script src="assets/app.js"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,36 @@
1
+ export class JsonBodyParserError extends Error {}
2
+
3
+ export function parseJSON(req) {
4
+ return new Promise((resolve, reject) => {
5
+ const MAX_BODY_SIZE = 200 * 1024
6
+ const expectedLength = req.headers['content-length'] | 0
7
+ let lengthSoFar = 0
8
+ const body = []
9
+ req.on('data', onData)
10
+ req.on('end', onEnd)
11
+ req.on('error', onEnd)
12
+
13
+ function onData(chunk) {
14
+ lengthSoFar += chunk.length
15
+ if (lengthSoFar > MAX_BODY_SIZE)
16
+ onEnd()
17
+ else
18
+ body.push(chunk)
19
+ }
20
+
21
+ function onEnd() {
22
+ req.removeListener('data', onData)
23
+ req.removeListener('end', onEnd)
24
+ req.removeListener('error', onEnd)
25
+ if (lengthSoFar !== expectedLength)
26
+ reject(new JsonBodyParserError())
27
+ else
28
+ try {
29
+ resolve(JSON.parse(Buffer.concat(body).toString()))
30
+ }
31
+ catch (_) {
32
+ reject(new JsonBodyParserError())
33
+ }
34
+ }
35
+ })
36
+ }
@@ -0,0 +1,60 @@
1
+ import fs, { readFileSync } from 'node:fs'
2
+ import { mimeFor } from './mime.js'
3
+
4
+
5
+ export function sendOK(response) {
6
+ response.end()
7
+ }
8
+
9
+ export function sendJSON(response, payload) {
10
+ response.setHeader('content-type', 'application/json')
11
+ response.end(JSON.stringify(payload))
12
+ }
13
+
14
+ export function sendFile(response, file) {
15
+ response.setHeader('content-type', mimeFor(file))
16
+ response.end(readFileSync(file))
17
+ }
18
+
19
+ export async function sendPartialContent(response, range, file) {
20
+ const { size } = await fs.promises.lstat(file)
21
+ let [start, end] = range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
22
+ if (isNaN(end)) end = size - 1
23
+ if (isNaN(start)) start = size - end
24
+
25
+ if (start < 0 || start > end || start >= size || end >= size) {
26
+ response.statusCode = 416 // Range Not Satisfiable
27
+ response.setHeader('content-range', `bytes */${size}`)
28
+ response.end()
29
+ }
30
+ else {
31
+ response.statusCode = 206 // Partial Content
32
+ response.setHeader('accept-ranges', 'bytes')
33
+ response.setHeader('content-range', `bytes ${start}-${end}/${size}`)
34
+ response.setHeader('content-type', mimeFor(file))
35
+ const reader = fs.createReadStream(file, { start, end })
36
+ reader.on('open', function () {
37
+ this.pipe(response)
38
+ })
39
+ reader.on('error', function (error) {
40
+ console.error(error)
41
+ sendInternalServerError(response)
42
+ })
43
+ }
44
+ }
45
+
46
+
47
+ export function sendBadRequest(response) {
48
+ response.statusCode = 400
49
+ response.end()
50
+ }
51
+
52
+ export function sendNotFound(response) {
53
+ response.statusCode = 404
54
+ response.end()
55
+ }
56
+
57
+ export function sendInternalServerError(response) {
58
+ response.statusCode = 500
59
+ response.end()
60
+ }
package/utils/jwt.js ADDED
@@ -0,0 +1,21 @@
1
+ export function jwtCookie(cookieName, payload) {
2
+ return [
3
+ `${cookieName}=${jwt(payload)}`,
4
+ 'Path=/',
5
+ 'SameSite=strict'
6
+ ].join(';')
7
+ }
8
+
9
+ function jwt(payload) {
10
+ return [
11
+ 'Header_Not_In_Use',
12
+ toBase64Url(payload),
13
+ 'Signature_Not_In_Use'
14
+ ].join('.')
15
+ }
16
+
17
+ function toBase64Url(obj) {
18
+ return btoa(JSON.stringify(obj))
19
+ .replace('+', '-')
20
+ .replace('/', '_')
21
+ }
package/utils/mime.js ADDED
@@ -0,0 +1,47 @@
1
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
2
+ const mimes = {
3
+ aac: 'audio/acc',
4
+ apng: 'image/apng',
5
+ avif: 'image/avif',
6
+ css: 'text/css',
7
+ doc: 'application/msword',
8
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
9
+ eot: 'application/vnd.ms-fontobject',
10
+ epub: 'application/epub+zip',
11
+ gif: 'image/gif',
12
+ gz: 'application/gzip',
13
+ htm: 'text/html',
14
+ html: 'text/html',
15
+ ico: 'image/vnd.microsoft.icon',
16
+ ics: 'text/calendar',
17
+ jpeg: 'image/jpeg',
18
+ jpg: 'image/jpeg',
19
+ js: 'application/javascript',
20
+ json: 'application/json',
21
+ jsonld: 'application/ld+json',
22
+ md: 'text/markdown',
23
+ mjs: 'text/javascript',
24
+ mp3: 'audio/mpeg',
25
+ mp4: 'video/mp4',
26
+ oft: 'font/otf',
27
+ pdf: 'application/pdf',
28
+ png: 'image/png',
29
+ ttf: 'font/ttf',
30
+ txt: 'plain/text',
31
+ wav: 'audio/wav',
32
+ weba: 'audio/webm',
33
+ webm: 'video/webm',
34
+ webp: 'image/webp',
35
+ woff2: 'font/woff2',
36
+ woff: 'font/woff',
37
+ xml: 'application/xml',
38
+ zip: 'application/zip'
39
+ }
40
+
41
+ export function mimeFor(filename) {
42
+ const ext = filename.replace(/.*\./, '')
43
+ const mime = mimes[ext] || ''
44
+ if (!mime)
45
+ console.error(`Missing MIME for ${filename}`)
46
+ return mime
47
+ }
@@ -0,0 +1,17 @@
1
+ export function validate(obj, shape) {
2
+ for (const [field, value] of Object.entries(obj)) {
3
+ const validator = shape[field]
4
+ if (isTypeOf(validator, Function) && validator !== Function) {
5
+ if (!validator(value))
6
+ throw new TypeError(`${field} ${value}`)
7
+ }
8
+ else if (!isTypeOf(validator, value))
9
+ throw new TypeError(`${field} ${value}`)
10
+ }
11
+ }
12
+
13
+ function isTypeOf(example, value) {
14
+ return (
15
+ Object.prototype.toString.call(value) ===
16
+ Object.prototype.toString.call(example))
17
+ }