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.
- package/Api.js +108 -0
- package/ApiConstants.js +22 -0
- package/Config.js +41 -0
- package/Dashboard.css +206 -0
- package/Dashboard.html +12 -0
- package/Dashboard.js +355 -0
- package/LICENSE +21 -0
- package/MockBroker.js +107 -0
- package/MockDispatcher.js +72 -0
- package/Mockaton.js +39 -0
- package/README-dashboard-dropdown.png +0 -0
- package/README-dashboard.png +0 -0
- package/README-mocks-with-comments.png +0 -0
- package/README.md +211 -0
- package/Route.js +90 -0
- package/StaticDispatcher.js +29 -0
- package/Tests.js +367 -0
- package/_usage_example.js +14 -0
- package/cookie.js +29 -0
- package/index.d.ts +17 -0
- package/index.js +2 -0
- package/mockBrokersCollection.js +84 -0
- package/package.json +12 -0
- package/sample-mocks/api/user/.GET.200.json +1 -0
- package/sample-mocks/api/user/.GET.501.txt +7 -0
- package/sample-mocks/api/user/edit-name.PATCH.200.json +1 -0
- package/sample-mocks/api/user/edit-name.PATCH.200.md +12 -0
- package/sample-mocks/api/user/edit-name.PATCH.501.txt +0 -0
- package/sample-mocks/api/user/friends.GET.200.json +1 -0
- package/sample-mocks/api/user/friends.GET.204.json +4 -0
- package/sample-mocks/api/user/friends.GET.501.txt +0 -0
- package/sample-mocks/api/user/logout.POST.200.json +1 -0
- package/sample-mocks/api/user/logout.POST.501.txt +0 -0
- package/sample-mocks/api/user/videos(assorted).GET.200.json +13 -0
- package/sample-mocks/api/user/videos(entirely unverified).GET.200.json +13 -0
- package/sample-mocks/api/user/videos(entirely verified)(another comment).GET.200.json +13 -0
- package/sample-mocks/api/user/videos.GET.501.txt +0 -0
- package/sample-mocks/api/video/[id].GET.200.json +4 -0
- package/sample-mocks/api/video/[id].GET.501.txt +0 -0
- package/sample-mocks/api/video/list(concat newly uploaded).GET.200.mjs +8 -0
- package/sample-mocks/api/video/list.GET.200.json +11 -0
- package/sample-mocks/api/video/list.GET.501.txt +0 -0
- package/sample-mocks/api/video/stat/[stat-id]/[video-id].GET.200.json +1 -0
- package/sample-mocks/api/video/stat/[stat-id]/[video-id].GET.501.txt +0 -0
- package/sample-mocks/api/video/stat/[stat-id]/all-videos?limit=[limit].GET.200.json +4 -0
- package/sample-mocks/api/video/stat/[stat-id]/all-videos?limit=[limit].GET.501.txt +0 -0
- package/sample-mocks/api/video/upload(insert newly uploaded).POST.201.mjs +10 -0
- package/sample-mocks/api/video/upload.POST.201.json +3 -0
- package/sample-mocks/api/video/upload.POST.501.txt +0 -0
- package/sample-static/another-entry/index.html +12 -0
- package/sample-static/assets/app.js +1 -0
- package/sample-static/assets/video.mp4 +0 -0
- package/sample-static/index.html +13 -0
- package/utils/http-request.js +36 -0
- package/utils/http-response.js +60 -0
- package/utils/jwt.js +21 -0
- package/utils/mime.js +47 -0
- 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,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" }
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"This is an exact path (i.e. /api/user/friends)"
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
""
|
|
File without changes
|
|
@@ -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
|
|
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
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"Two Dynamic Params. i.e. /api/video/stat/VIEWS/123"
|
|
File without changes
|
|
File without changes
|
|
@@ -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
|
+
}
|
|
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
|
+
}
|