primate 0.16.3 → 0.18.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/package.json +2 -2
- package/src/Logger.js +79 -51
- package/src/app.js +64 -53
- package/src/commands/exports.js +2 -5
- package/src/defaults/primate.config.js +1 -0
- package/src/errors.js +112 -0
- package/src/handlers/error.js +9 -0
- package/src/handlers/exports.js +1 -0
- package/src/handlers/redirect.js +3 -1
- package/src/handlers/view.js +4 -4
- package/src/hooks/bundle.js +1 -1
- package/src/hooks/compile.js +1 -1
- package/src/hooks/exports.js +1 -0
- package/src/hooks/handle/exports.js +0 -1
- package/src/hooks/handle.js +23 -100
- package/src/hooks/parse.js +59 -0
- package/src/hooks/parse.spec.js +72 -0
- package/src/hooks/publish.js +1 -1
- package/src/hooks/register.js +1 -1
- package/src/hooks/route.js +86 -48
- package/src/hooks/route.spec.js +181 -0
- package/src/http-statuses.js +4 -0
- package/src/run.js +52 -1
- package/src/start.js +14 -3
- package/src/commands/build.js +0 -1
- package/src/commands/create.js +0 -42
- package/src/commands/help.js +0 -3
- package/src/handlers/http.js +0 -1
- package/src/handlers/http404.js +0 -6
- package/src/hooks/handle/http-statuses.js +0 -5
package/src/hooks/handle.js
CHANGED
|
@@ -1,61 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
1
|
+
import {Response} from "runtime-compat/http";
|
|
2
|
+
import {error as clientError} from "../handlers/exports.js";
|
|
3
|
+
import {mime, isResponse, respond} from "./handle/exports.js";
|
|
4
|
+
import {invalid} from "./route.js";
|
|
5
|
+
import errors from "../errors.js";
|
|
6
|
+
import {OK} from "../http-statuses.js";
|
|
6
7
|
|
|
7
8
|
const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
fromNull(Object.fromEntries(body.split("&").map(part => part.split("=")
|
|
12
|
-
.map(subpart => decodeURIComponent(subpart).replaceAll("+", " "))))),
|
|
13
|
-
"application/json": body => JSON.parse(body),
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export default async app => {
|
|
17
|
-
const {config} = app;
|
|
18
|
-
const {http} = config;
|
|
10
|
+
export default app => {
|
|
11
|
+
const {http} = app.config;
|
|
19
12
|
|
|
20
|
-
const _respond = async request => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
.map(resource => `'${resource.integrity}'`).join(" ");
|
|
25
|
-
const _csp = scripts === "" ? csp : `${csp}script-src 'self' ${scripts};`;
|
|
26
|
-
// remove inline resources
|
|
27
|
-
for (let i = app.resources.length - 1; i >= 0; i--) {
|
|
28
|
-
const resource = app.resources[i];
|
|
29
|
-
if (resource.inline) {
|
|
30
|
-
app.resources.splice(i, 1);
|
|
31
|
-
}
|
|
13
|
+
const _respond = async (request, headers) => {
|
|
14
|
+
if (invalid(request.url.pathname)) {
|
|
15
|
+
errors.NoFileForPath.throw({pathname: request.url.pathname, config: app.config});
|
|
16
|
+
return;
|
|
32
17
|
}
|
|
18
|
+
return respond(await app.route(request))(app, headers);
|
|
19
|
+
};
|
|
33
20
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"Referrer-Policy": "same-origin",
|
|
37
|
-
};
|
|
21
|
+
const route = async request => {
|
|
22
|
+
const headers = app.generateHeaders();
|
|
38
23
|
|
|
39
24
|
try {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
// handle is the last module to be executed
|
|
43
|
-
const handlers = [...modules, router.route].reduceRight((acc, handler) =>
|
|
44
|
-
input => handler(input, acc));
|
|
45
|
-
return await respond(await handlers(request))(app, headers);
|
|
25
|
+
const response = await _respond(request, headers);
|
|
26
|
+
return isResponse(response) ? response : new Response(...response);
|
|
46
27
|
} catch (error) {
|
|
47
28
|
app.log.auto(error);
|
|
48
|
-
return
|
|
29
|
+
return new Response(...await clientError()(app, {}));
|
|
49
30
|
}
|
|
50
31
|
};
|
|
51
32
|
|
|
52
|
-
const route = async request => {
|
|
53
|
-
const response = await _respond(request);
|
|
54
|
-
return isResponse(response) ? response : new Response(...response);
|
|
55
|
-
};
|
|
56
|
-
|
|
57
33
|
const staticResource = async file => new Response(file.readable, {
|
|
58
|
-
status:
|
|
34
|
+
status: OK,
|
|
59
35
|
headers: {
|
|
60
36
|
"Content-Type": mime(file.name),
|
|
61
37
|
Etag: await file.modified,
|
|
@@ -67,7 +43,7 @@ export default async app => {
|
|
|
67
43
|
!inline && src === request.url.pathname);
|
|
68
44
|
if (published !== undefined) {
|
|
69
45
|
return new Response(published.code, {
|
|
70
|
-
status:
|
|
46
|
+
status: OK,
|
|
71
47
|
headers: {
|
|
72
48
|
"Content-Type": mime(published.src),
|
|
73
49
|
Etag: published.integrity,
|
|
@@ -91,62 +67,9 @@ export default async app => {
|
|
|
91
67
|
};
|
|
92
68
|
|
|
93
69
|
const handle = async request => {
|
|
94
|
-
|
|
95
|
-
return await resource(request);
|
|
96
|
-
} catch (error) {
|
|
97
|
-
app.log.auto(error);
|
|
98
|
-
return new Response(null, {status: statuses.InternalServerError});
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
const parseContentType = (contentType, body) => {
|
|
103
|
-
const type = contents[contentType];
|
|
104
|
-
return type === undefined ? body : type(body);
|
|
70
|
+
return await resource(request);
|
|
105
71
|
};
|
|
106
72
|
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
return parseContentType(request.headers.get("content-type"), body);
|
|
110
|
-
} catch (error) {
|
|
111
|
-
app.log.warn(error);
|
|
112
|
-
return body;
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
const decoder = new TextDecoder();
|
|
117
|
-
|
|
118
|
-
const parseBody = async request => {
|
|
119
|
-
const reader = request.body.getReader();
|
|
120
|
-
const chunks = [];
|
|
121
|
-
let result;
|
|
122
|
-
do {
|
|
123
|
-
result = await reader.read();
|
|
124
|
-
if (result.value !== undefined) {
|
|
125
|
-
chunks.push(decoder.decode(result.value));
|
|
126
|
-
}
|
|
127
|
-
} while (!result.done);
|
|
128
|
-
|
|
129
|
-
return chunks.length === 0 ? null : parseContent(request, chunks.join());
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const parseRequest = async request => {
|
|
133
|
-
const cookies = request.headers.get("cookie");
|
|
134
|
-
const {url} = request;
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
request,
|
|
138
|
-
url: new URL(url.endsWith("/") ? url.slice(0, -1) : url),
|
|
139
|
-
body: await parseBody(request),
|
|
140
|
-
cookies: fromNull(cookies === null
|
|
141
|
-
? {}
|
|
142
|
-
: Object.fromEntries(cookies.split(";").map(c => c.trim().split("=")))),
|
|
143
|
-
headers: fromNull(Object.fromEntries(request.headers)),
|
|
144
|
-
};
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
// handle is the last module to be executed
|
|
148
|
-
const handlers = [...filter("handle", app.modules), handle]
|
|
73
|
+
return [...filter("handle", app.modules), handle]
|
|
149
74
|
.reduceRight((acc, handler) => input => handler(input, acc));
|
|
150
|
-
|
|
151
|
-
serve(async request => handlers(await parseRequest(request)), config.http);
|
|
152
75
|
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {URL} from "runtime-compat/http";
|
|
2
|
+
import fromNull from "../fromNull.js";
|
|
3
|
+
import errors from "../errors.js";
|
|
4
|
+
|
|
5
|
+
const contents = {
|
|
6
|
+
"application/x-www-form-urlencoded": body =>
|
|
7
|
+
fromNull(Object.fromEntries(body.split("&").map(part => part.split("=")
|
|
8
|
+
.map(subpart => decodeURIComponent(subpart).replaceAll("+", " "))))),
|
|
9
|
+
"application/json": body => JSON.parse(body),
|
|
10
|
+
};
|
|
11
|
+
const decoder = new TextDecoder();
|
|
12
|
+
|
|
13
|
+
export default async request => {
|
|
14
|
+
const parseContentType = (contentType, body) => {
|
|
15
|
+
const type = contents[contentType];
|
|
16
|
+
return type === undefined ? body : type(body);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const parseContent = async (request, body) => {
|
|
20
|
+
const contentType = request.headers.get("content-type");
|
|
21
|
+
try {
|
|
22
|
+
return parseContentType(contentType, body);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return errors.CannotParseBody.throw({body, contentType});
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const parseBody = async request => {
|
|
29
|
+
if (request.body === null) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const reader = request.body.getReader();
|
|
33
|
+
const chunks = [];
|
|
34
|
+
let result;
|
|
35
|
+
do {
|
|
36
|
+
result = await reader.read();
|
|
37
|
+
if (result.value !== undefined) {
|
|
38
|
+
chunks.push(decoder.decode(result.value));
|
|
39
|
+
}
|
|
40
|
+
} while (!result.done);
|
|
41
|
+
|
|
42
|
+
return parseContent(request, chunks.join());
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const cookies = request.headers.get("cookie");
|
|
46
|
+
const _url = request.url;
|
|
47
|
+
const url = new URL(_url.endsWith("/") ? _url.slice(0, -1) : _url);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
original: request,
|
|
51
|
+
url,
|
|
52
|
+
body: await parseBody(request),
|
|
53
|
+
cookies: fromNull(cookies === null
|
|
54
|
+
? {}
|
|
55
|
+
: Object.fromEntries(cookies.split(";").map(c => c.trim().split("=")))),
|
|
56
|
+
headers: fromNull(Object.fromEntries(request.headers)),
|
|
57
|
+
query: fromNull(Object.fromEntries(url.searchParams)),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import parse from "./parse.js";
|
|
2
|
+
import Logger from "../Logger.js";
|
|
3
|
+
|
|
4
|
+
const {mark} = Logger;
|
|
5
|
+
|
|
6
|
+
const r = await (async () => {
|
|
7
|
+
const p = "https://p.com";
|
|
8
|
+
const request = (method, path = "/", options = {}) =>
|
|
9
|
+
new Request(`${p}${path}`, {method, ...options});
|
|
10
|
+
return Object.fromEntries(["get", "post", "put", "delete"].map(verb =>
|
|
11
|
+
[verb, (...args) => parse(request(verb.toUpperCase(), ...args))]));
|
|
12
|
+
})();
|
|
13
|
+
|
|
14
|
+
export default test => {
|
|
15
|
+
test.case("no body => null", async assert => {
|
|
16
|
+
assert((await r.get("/")).body).null();
|
|
17
|
+
assert((await r.post("/")).body).null();
|
|
18
|
+
});
|
|
19
|
+
test.case("body is application/json", async assert => {
|
|
20
|
+
const body = JSON.stringify({foo: "bar"});
|
|
21
|
+
const contentType = "application/json";
|
|
22
|
+
const headers = {"Content-Type": contentType};
|
|
23
|
+
assert((await r.post("/", {body, headers})).body).equals({foo: "bar"});
|
|
24
|
+
|
|
25
|
+
const faulty = `${body}%`;
|
|
26
|
+
assert(() => r.post("/", {body: faulty, headers}))
|
|
27
|
+
.throws(mark("cannot parse body % as %", faulty, contentType));
|
|
28
|
+
});
|
|
29
|
+
test.case("body is application/x-www-form-urlencoded", async assert => {
|
|
30
|
+
assert((await r.post("/", {
|
|
31
|
+
body: encodeURI("foo=bar &bar=baz"),
|
|
32
|
+
headers: {
|
|
33
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
34
|
+
},
|
|
35
|
+
})).body).equals({foo: "bar ", bar: "baz"});
|
|
36
|
+
});
|
|
37
|
+
test.case("no query => {}", async assert => {
|
|
38
|
+
assert((await r.get("/")).query).equals({});
|
|
39
|
+
});
|
|
40
|
+
test.case("query", async assert => {
|
|
41
|
+
assert((await r.get("/?key=value")).query).equals({key: "value"});
|
|
42
|
+
});
|
|
43
|
+
test.case("no cookies => {}", async assert => {
|
|
44
|
+
assert((await r.get("/")).cookies).equals({});
|
|
45
|
+
});
|
|
46
|
+
test.case("cookies", async assert => {
|
|
47
|
+
assert((await r.get("/?key=value", {
|
|
48
|
+
headers: {
|
|
49
|
+
Cookie: "key=value;key2=value2",
|
|
50
|
+
},
|
|
51
|
+
})).cookies).equals({key: "value", key2: "value2"});
|
|
52
|
+
});
|
|
53
|
+
test.case("no headers => {}", async assert => {
|
|
54
|
+
assert((await r.get("/")).headers).equals({});
|
|
55
|
+
});
|
|
56
|
+
test.case("headers", async assert => {
|
|
57
|
+
assert((await r.get("/?key=value", {
|
|
58
|
+
headers: {
|
|
59
|
+
"X-User": "Donald",
|
|
60
|
+
},
|
|
61
|
+
})).headers).equals({"x-user": "Donald"});
|
|
62
|
+
});
|
|
63
|
+
test.case("cookies double as headers", async assert => {
|
|
64
|
+
const response = await r.get("/?key=value", {
|
|
65
|
+
headers: {
|
|
66
|
+
Cookie: "key=value",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
assert(response.headers).equals({cookie: "key=value"});
|
|
70
|
+
assert(response.cookies).equals({key: "value"});
|
|
71
|
+
});
|
|
72
|
+
};
|
package/src/hooks/publish.js
CHANGED
|
@@ -29,7 +29,7 @@ const post = async app => {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
export default async app => {
|
|
32
|
-
app.log.info("running publish hooks");
|
|
32
|
+
app.log.info("running publish hooks", {module: "primate"});
|
|
33
33
|
await [...filter("publish", app.modules), _ => _]
|
|
34
34
|
.reduceRight((acc, handler) => input => handler(input, acc))(app);
|
|
35
35
|
await post(app);
|
package/src/hooks/register.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
|
|
2
2
|
|
|
3
3
|
export default async app => {
|
|
4
|
-
app.log.info("running register hooks");
|
|
4
|
+
app.log.info("running register hooks", {module: "primate"});
|
|
5
5
|
await [...filter("register", app.modules), _ => _]
|
|
6
6
|
.reduceRight((acc, handler) => input => handler(input, acc))(app);
|
|
7
7
|
};
|
package/src/hooks/route.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import errors from "../errors.js";
|
|
2
|
+
|
|
3
|
+
const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
|
|
4
4
|
|
|
5
5
|
// insensitive-case equal
|
|
6
6
|
const ieq = (left, right) => left.toLowerCase() === right.toLowerCase();
|
|
@@ -9,61 +9,99 @@ const verbs = [
|
|
|
9
9
|
// CRUD
|
|
10
10
|
"post", "get", "put", "delete",
|
|
11
11
|
// extended
|
|
12
|
-
"
|
|
12
|
+
"connect", "options", "trace", "patch", "head",
|
|
13
13
|
];
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
.
|
|
15
|
+
/* routes may not contain dots */
|
|
16
|
+
export const invalid = route => /\./u.test(route);
|
|
17
|
+
const toRoute = path => {
|
|
18
|
+
const double = path.split("/")
|
|
19
|
+
.filter(part => part.startsWith("{") && part.endsWith("}"))
|
|
20
|
+
.map(part => part.slice(1, part.indexOf(":")))
|
|
21
|
+
.find((part, i, array) =>
|
|
22
|
+
array.filter((_, j) => i !== j).includes(part));
|
|
23
|
+
double && errors.DoublePathParameter.throw({path, double});
|
|
24
|
+
|
|
25
|
+
const route = path
|
|
20
26
|
// transform /index -> ""
|
|
21
27
|
.replace("/index", "")
|
|
22
28
|
// transform index -> ""
|
|
23
29
|
.replace("index", "")
|
|
24
30
|
// prepare for regex
|
|
25
|
-
.replaceAll(/\{(?<named
|
|
26
|
-
|
|
31
|
+
.replaceAll(/\{(?<named>.*?)\}/gu, (_, named) => {
|
|
32
|
+
try {
|
|
33
|
+
const {name, type} = /^(?<name>\w*)(?<type>:\w+)?$/u.exec(named).groups;
|
|
34
|
+
const param = type === undefined ? name : `${name}$${type.slice(1)}`;
|
|
35
|
+
return `(?<${param}>[^/]{1,}?)`;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return errors.InvalidPathParameter.throw({named, path});
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
invalid(route) && errors.InvalidRouteName.throw({path});
|
|
42
|
+
|
|
27
43
|
return new RegExp(`^/${route}$`, "u");
|
|
28
44
|
};
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
const reentry = (object, mapper) =>
|
|
47
|
+
Object.fromEntries(mapper(Object.entries(object ?? {})));
|
|
48
|
+
|
|
49
|
+
export default app => {
|
|
50
|
+
const double = app.routes
|
|
51
|
+
.map(([route]) => route
|
|
52
|
+
.replaceAll("/index", "")
|
|
53
|
+
.replaceAll(/\{(?<name>\w*)(?<_>:\w+)?\}?/gu, (_, name) => `{${name}}`))
|
|
54
|
+
.find((part, i, array) =>
|
|
55
|
+
array.filter((_, j) => i !== j).includes(part));
|
|
56
|
+
|
|
57
|
+
double && errors.DoubleRoute.throw({double});
|
|
58
|
+
|
|
59
|
+
const routes = app.routes
|
|
60
|
+
.map(([route, imported]) => {
|
|
61
|
+
if (imported === undefined || Object.keys(imported).length === 0) {
|
|
62
|
+
errors.EmptyRouteFile.warn(app.log, {config: app.config, route});
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const path = toRoute(route);
|
|
67
|
+
return Object.entries(imported)
|
|
68
|
+
.filter(([verb]) => verbs.includes(verb))
|
|
69
|
+
.map(([method, handler]) => ({method, handler, path}));
|
|
70
|
+
}).flat();
|
|
71
|
+
|
|
72
|
+
const {types = {}} = app;
|
|
73
|
+
Object.entries(types).every(([name]) => /^(?:\w*)$/u.test(name) ||
|
|
74
|
+
errors.InvalidType.throw({name}));
|
|
75
|
+
|
|
76
|
+
const isType = groups => Object
|
|
77
|
+
.entries(groups ?? {})
|
|
78
|
+
.map(([name, value]) =>
|
|
79
|
+
[types[name] === undefined ? name : `${name}$${name}`, value])
|
|
80
|
+
.filter(([name]) => name.includes("$"))
|
|
81
|
+
.map(([name, value]) => [name.split("$")[1], value])
|
|
82
|
+
.every(([name, value]) => types?.[name](value) === true)
|
|
83
|
+
;
|
|
84
|
+
const isPath = ({route, path}) => {
|
|
85
|
+
const result = route.path.exec(path);
|
|
86
|
+
return result === null ? false : isType(result.groups);
|
|
87
|
+
};
|
|
88
|
+
const isMethod = ({route, method, path}) => ieq(route.method, method)
|
|
89
|
+
&& isPath({route, path});
|
|
46
90
|
const find = (method, path) => routes.find(route =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
...rest,
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
return verb.handler(data);
|
|
66
|
-
},
|
|
91
|
+
isMethod({route, method, path}));
|
|
92
|
+
const modules = filter("route", app.modules);
|
|
93
|
+
|
|
94
|
+
return request => {
|
|
95
|
+
const {original: {method}, url: {pathname}} = request;
|
|
96
|
+
const verb = find(method, pathname) ??
|
|
97
|
+
errors.NoRouteToPath.throw({method, pathname, config: app.config});
|
|
98
|
+
const path = reentry(verb.path?.exec(pathname).groups,
|
|
99
|
+
object => object.map(([key, value]) => [key.split("$")[0], value]));
|
|
100
|
+
|
|
101
|
+
// verb.handler is the last module to be executed
|
|
102
|
+
const handlers = [...modules, verb.handler].reduceRight((acc, handler) =>
|
|
103
|
+
input => handler(input, acc));
|
|
104
|
+
|
|
105
|
+
return handlers({...request, path});
|
|
67
106
|
};
|
|
68
|
-
return router;
|
|
69
107
|
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import Logger from "../Logger.js";
|
|
2
|
+
import route from "./route.js";
|
|
3
|
+
|
|
4
|
+
const {mark} = Logger;
|
|
5
|
+
|
|
6
|
+
const app = {
|
|
7
|
+
config: {
|
|
8
|
+
paths: {
|
|
9
|
+
routes: "/routes",
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
routes: [
|
|
13
|
+
"index",
|
|
14
|
+
"user",
|
|
15
|
+
"users/{userId}a",
|
|
16
|
+
"comments/{commentId:comment}",
|
|
17
|
+
"users/{userId}/comments/{commentId}",
|
|
18
|
+
"users/{userId:user}/comments/{commentId}/a",
|
|
19
|
+
"users/{userId:user}/comments/{commentId:comment}/b",
|
|
20
|
+
"users/{_userId}/comments/{commentId}/d",
|
|
21
|
+
"users/{_userId}/comments/{_commentId}/e",
|
|
22
|
+
"comments2/{_commentId}",
|
|
23
|
+
"users2/{_userId}/{commentId}",
|
|
24
|
+
"users3/{_userId}/{_commentId:_commentId}",
|
|
25
|
+
"users4/{_userId}/{_commentId}",
|
|
26
|
+
"users5/{truthy}",
|
|
27
|
+
"{uuid}/{Uuid}/{UUID}",
|
|
28
|
+
].map(pathname => [pathname, {get: request => request}]),
|
|
29
|
+
types: {
|
|
30
|
+
user: id => /^\d*$/u.test(id),
|
|
31
|
+
comment: id => /^\d*$/u.test(id),
|
|
32
|
+
_userId: id => /^\d*$/u.test(id),
|
|
33
|
+
_commentId: id => /^\d*$/u.test(id),
|
|
34
|
+
truthy: () => 1,
|
|
35
|
+
uuid: _ => _ === "uuid",
|
|
36
|
+
Uuid: _ => _ === "Uuid",
|
|
37
|
+
UUID: _ => _ === "UUID",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default test => {
|
|
42
|
+
const router = route(app);
|
|
43
|
+
const p = "https://p.com";
|
|
44
|
+
const r = pathname => {
|
|
45
|
+
const original = new Request(`${p}${pathname}`, {method: "GET"});
|
|
46
|
+
const {url} = original;
|
|
47
|
+
const end = -1;
|
|
48
|
+
return router({
|
|
49
|
+
original,
|
|
50
|
+
url: new URL(url.endsWith("/") ? url.slice(0, end) : url),
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
test.reassert(assert => ({
|
|
55
|
+
match: (url, result) => {
|
|
56
|
+
assert(r(url).url.pathname).equals(result ?? url);
|
|
57
|
+
},
|
|
58
|
+
fail: (url, result) => {
|
|
59
|
+
const throws = mark("no % route to %", "GET", result ?? url);
|
|
60
|
+
assert(() => r(url)).throws(throws);
|
|
61
|
+
},
|
|
62
|
+
path: (url, result) => assert(r(url).path).equals(result),
|
|
63
|
+
assert,
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
const get = () => null;
|
|
67
|
+
/* errors {{{ */
|
|
68
|
+
test.case("error DoubleRouted", ({assert}) => {
|
|
69
|
+
const post = ["post", {get}];
|
|
70
|
+
const throws = mark("double route %", "post");
|
|
71
|
+
assert(() => route({routes: [post, post]})).throws(throws);
|
|
72
|
+
});
|
|
73
|
+
test.case("error DoublePathParameter", ({assert}) => {
|
|
74
|
+
const path = "{user}/{user}";
|
|
75
|
+
const throws = mark("double path parameter % in route %", "user", path);
|
|
76
|
+
assert(() => route({routes: [[path, {get}]]})).throws(throws);
|
|
77
|
+
});
|
|
78
|
+
test.case("error EmptyRoutefile", ({assert}) => {
|
|
79
|
+
const path = "user";
|
|
80
|
+
const throws = mark("empty route file at %", `/routes/${path}.js`);
|
|
81
|
+
const base = {
|
|
82
|
+
log: {
|
|
83
|
+
auto(error) {
|
|
84
|
+
throw error;
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
config: {
|
|
88
|
+
paths: {
|
|
89
|
+
routes: "/routes",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
assert(() => route({...base, routes: [[path, undefined]]})).throws(throws);
|
|
94
|
+
assert(() => route({...base, routes: [[path, {}]]})).throws(throws);
|
|
95
|
+
});
|
|
96
|
+
test.case("error InvalidRouteName", ({assert}) => {
|
|
97
|
+
const post = ["po.st", {get}];
|
|
98
|
+
const throws = mark("invalid route name %", "po.st");
|
|
99
|
+
assert(() => route({routes: [post], types: {}})).throws(throws);
|
|
100
|
+
});
|
|
101
|
+
test.case("error InvalidParameter", ({assert}) => {
|
|
102
|
+
const path = "{us$er}";
|
|
103
|
+
const throws = mark("invalid path parameter % in route %", "us$er", path);
|
|
104
|
+
assert(() => route({routes: [[path, {get}]]})).throws(throws);
|
|
105
|
+
});
|
|
106
|
+
test.case("error InvalidType", ({assert}) => {
|
|
107
|
+
const throws = mark("invalid type %", "us$er");
|
|
108
|
+
const types = {us$er: () => false};
|
|
109
|
+
assert(() => route({routes: [], types})).throws(throws);
|
|
110
|
+
});
|
|
111
|
+
/* }}} */
|
|
112
|
+
|
|
113
|
+
test.case("index route", ({match}) => {
|
|
114
|
+
match("/");
|
|
115
|
+
});
|
|
116
|
+
test.case("simple route", ({match}) => {
|
|
117
|
+
match("/user");
|
|
118
|
+
});
|
|
119
|
+
test.case("param match/fail", ({match, fail}) => {
|
|
120
|
+
match("/users/1a");
|
|
121
|
+
match("/users/aa");
|
|
122
|
+
match("/users/ba?key=value", "/users/ba");
|
|
123
|
+
fail("/user/1a");
|
|
124
|
+
fail("/users/a");
|
|
125
|
+
fail("/users/aA");
|
|
126
|
+
fail("/users//a");
|
|
127
|
+
fail("/users/?a", "/users/");
|
|
128
|
+
});
|
|
129
|
+
test.case("no params", ({path}) => {
|
|
130
|
+
path("/", {});
|
|
131
|
+
});
|
|
132
|
+
test.case("single param", ({path}) => {
|
|
133
|
+
path("/users/1a", {userId: "1"});
|
|
134
|
+
});
|
|
135
|
+
test.case("params", ({path, fail}) => {
|
|
136
|
+
path("/users/1/comments/2", {userId: "1", commentId: "2"});
|
|
137
|
+
path("/users/1/comments/2/b", {userId: "1", commentId: "2"});
|
|
138
|
+
fail("/users/d/comments/2/b");
|
|
139
|
+
fail("/users/1/comments/d/b");
|
|
140
|
+
fail("/users/d/comments/d/b");
|
|
141
|
+
});
|
|
142
|
+
test.case("single typed param", ({path, fail}) => {
|
|
143
|
+
path("/comments/1", {commentId: "1"});
|
|
144
|
+
fail("/comments/ ", "/comments");
|
|
145
|
+
fail("/comments/1d");
|
|
146
|
+
});
|
|
147
|
+
test.case("mixed untyped and typed params", ({path, fail}) => {
|
|
148
|
+
path("/users/1/comments/2/a", {userId: "1", commentId: "2"});
|
|
149
|
+
fail("/users/d/comments/2/a");
|
|
150
|
+
});
|
|
151
|
+
test.case("single implicit typed param", ({path, fail}) => {
|
|
152
|
+
path("/comments2/1", {_commentId: "1"});
|
|
153
|
+
fail("/comments2/d");
|
|
154
|
+
});
|
|
155
|
+
test.case("mixed implicit and untyped params", ({path, fail}) => {
|
|
156
|
+
path("/users2/1/2", {_userId: "1", commentId: "2"});
|
|
157
|
+
fail("/users2/d/2");
|
|
158
|
+
fail("/users2/d");
|
|
159
|
+
});
|
|
160
|
+
test.case("mixed implicit and explicit params", ({path, fail}) => {
|
|
161
|
+
path("/users3/1/2", {_userId: "1", _commentId: "2"});
|
|
162
|
+
fail("/users3/d/2");
|
|
163
|
+
fail("/users3/1/d");
|
|
164
|
+
fail("/users3");
|
|
165
|
+
});
|
|
166
|
+
test.case("implicit params", ({path, fail}) => {
|
|
167
|
+
path("/users4/1/2", {_userId: "1", _commentId: "2"});
|
|
168
|
+
fail("/users4/d/2");
|
|
169
|
+
fail("/users4/1/d");
|
|
170
|
+
fail("/users4");
|
|
171
|
+
});
|
|
172
|
+
test.case("fail not strictly true implicit params", ({fail}) => {
|
|
173
|
+
fail("/users5/any");
|
|
174
|
+
});
|
|
175
|
+
test.case("different case params", ({path, fail}) => {
|
|
176
|
+
path("/uuid/Uuid/UUID", {uuid: "uuid", Uuid: "Uuid", UUID: "UUID"});
|
|
177
|
+
fail("/uuid/uuid/uuid");
|
|
178
|
+
fail("/Uuid/UUID/uuid");
|
|
179
|
+
fail("/UUID/uuid/Uuid");
|
|
180
|
+
});
|
|
181
|
+
};
|