@riddl/core 0.1.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/LICENSE.md +21 -0
- package/dist/adapters.d.ts +7 -0
- package/dist/adapters.js +73 -0
- package/dist/api.d.ts +2 -0
- package/dist/api.js +128 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/endpoint.d.ts +4 -0
- package/dist/endpoint.js +3 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/log.d.ts +2 -0
- package/dist/log.js +2 -0
- package/dist/middlewares.d.ts +0 -0
- package/dist/middlewares.js +1 -0
- package/dist/openapi.d.ts +4 -0
- package/dist/openapi.js +113 -0
- package/dist/parser.d.ts +29 -0
- package/dist/parser.js +143 -0
- package/dist/routing.d.ts +14 -0
- package/dist/routing.js +78 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +135 -0
- package/dist/validation.d.ts +5 -0
- package/dist/validation.js +130 -0
- package/dist/zod.d.ts +2 -0
- package/dist/zod.js +4 -0
- package/package.json +46 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 All Peep Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Handler } from "./types";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import express, { type Router, Application } from "express";
|
|
4
|
+
export declare const mountApi: (app: Application, prefix: string, apiHandler: Handler) => express.Application;
|
|
5
|
+
export declare const setResponse: (res: ServerResponse, response: Response) => Promise<void>;
|
|
6
|
+
export declare const rawAdapter: (apiHandler: Handler) => (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
7
|
+
export declare const expressAdapter: (apiHandler: Handler) => Router;
|
package/dist/adapters.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { toFetchRequest } from "./utils.js";
|
|
3
|
+
import * as set_cookie_parser from "set-cookie-parser";
|
|
4
|
+
export const mountApi = (app, prefix, apiHandler) => app.use(prefix, expressAdapter(apiHandler));
|
|
5
|
+
export const setResponse = async (res, response) => {
|
|
6
|
+
for (const [key, value] of response.headers) {
|
|
7
|
+
try {
|
|
8
|
+
res.setHeader(key, key === 'set-cookie'
|
|
9
|
+
? set_cookie_parser.splitCookiesString(response.headers.get(key))
|
|
10
|
+
: value);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
res.getHeaderNames().forEach((name) => res.removeHeader(name));
|
|
14
|
+
res.writeHead(500).end(String(error));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
res.writeHead(response.status);
|
|
19
|
+
if (!response.body) {
|
|
20
|
+
res.end();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (response.body.locked) {
|
|
24
|
+
res.end('Fatal error: Response body is locked. ' +
|
|
25
|
+
"This can happen when the response was already read (for example through 'response.json()' or 'response.text()').");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const reader = response.body.getReader();
|
|
29
|
+
if (res.destroyed) {
|
|
30
|
+
void reader.cancel();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const cancel = (error) => {
|
|
34
|
+
res.off('close', cancel);
|
|
35
|
+
res.off('error', cancel);
|
|
36
|
+
// If the reader has already been interrupted with an error earlier,
|
|
37
|
+
// then it will appear here, it is useless, but it needs to be catch.
|
|
38
|
+
reader.cancel(error).catch(() => { });
|
|
39
|
+
if (error)
|
|
40
|
+
res.destroy(error);
|
|
41
|
+
};
|
|
42
|
+
res.on('close', cancel);
|
|
43
|
+
res.on('error', cancel);
|
|
44
|
+
void next();
|
|
45
|
+
async function next() {
|
|
46
|
+
try {
|
|
47
|
+
for (;;) {
|
|
48
|
+
const { done, value } = await reader.read();
|
|
49
|
+
if (done)
|
|
50
|
+
break;
|
|
51
|
+
if (!res.write(value)) {
|
|
52
|
+
res.once('drain', next);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
res.end();
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
cancel(error instanceof Error ? error : new Error(String(error)));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
export const rawAdapter = (apiHandler) => (async (req, res) => {
|
|
64
|
+
const response = await apiHandler(toFetchRequest(req));
|
|
65
|
+
res.statusCode = response.status;
|
|
66
|
+
res.setHeader("Content-Type", "application/json");
|
|
67
|
+
res.setHeader("Content-Length", "0");
|
|
68
|
+
response.headers.forEach((value, key) => {
|
|
69
|
+
res.setHeader(key, value);
|
|
70
|
+
});
|
|
71
|
+
await setResponse(res, response);
|
|
72
|
+
});
|
|
73
|
+
export const expressAdapter = (apiHandler) => express.Router().use(rawAdapter(apiHandler));
|
package/dist/api.d.ts
ADDED
package/dist/api.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
|
2
|
+
import { json, recursiveAwait } from "./utils.js";
|
|
3
|
+
import { CORS_DEFAULT, METHOD } from "./constants.js";
|
|
4
|
+
import { log } from "./log.js";
|
|
5
|
+
import { parseBody, parseOutput, parseParam, parseQuery } from "./validation.js";
|
|
6
|
+
import { parseRouteId } from "./parser.js";
|
|
7
|
+
import { buildRouter } from "./routing.js";
|
|
8
|
+
import { openapiHandler, registerRoutes } from "./openapi.js";
|
|
9
|
+
import { error } from "./errors.js";
|
|
10
|
+
export const api = async ({ routeModuleMap, config, base = "/api", cors = true, registry = new OpenAPIRegistry(), }) => {
|
|
11
|
+
const { routes, middlewares } = await Object.entries(routeModuleMap)
|
|
12
|
+
.reduce(async (accPromise, [path, module]) => {
|
|
13
|
+
var _a, _b, _c;
|
|
14
|
+
const acc = await accPromise;
|
|
15
|
+
const parts = path.split("/");
|
|
16
|
+
const last = parts[parts.length - 1].split(".")[0];
|
|
17
|
+
parts[parts.length - 1] = last;
|
|
18
|
+
const id = parts.slice(0, -1).join("/");
|
|
19
|
+
if (METHOD.test(last)) {
|
|
20
|
+
const method = last.toUpperCase();
|
|
21
|
+
const endpoint = (await module()).apiEndpoint;
|
|
22
|
+
acc.routes[id] = {
|
|
23
|
+
matcher: parseRouteId(id),
|
|
24
|
+
handlers: {
|
|
25
|
+
...((_c = (_b = (_a = acc.routes) === null || _a === void 0 ? void 0 : _a[id]) === null || _b === void 0 ? void 0 : _b.handlers) !== null && _c !== void 0 ? _c : {}),
|
|
26
|
+
[method]: endpoint,
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (last === "middleware") {
|
|
31
|
+
acc.middlewares[id] = (await module());
|
|
32
|
+
}
|
|
33
|
+
return acc;
|
|
34
|
+
}, Promise.resolve({ routes: {}, middlewares: {} }));
|
|
35
|
+
log("routes: %O", routes);
|
|
36
|
+
log("middlewares: %O", middlewares);
|
|
37
|
+
log("config: %O", config);
|
|
38
|
+
log("base: %s", base);
|
|
39
|
+
registerRoutes({ routes, registry, base });
|
|
40
|
+
const router = buildRouter(routes, middlewares);
|
|
41
|
+
const effectiveCorsSettings = cors ? typeof cors === "object" ? cors : CORS_DEFAULT : undefined;
|
|
42
|
+
const handler = async (request) => {
|
|
43
|
+
var _a, _b, _c;
|
|
44
|
+
try {
|
|
45
|
+
log("request: %O", request);
|
|
46
|
+
const event = {
|
|
47
|
+
request,
|
|
48
|
+
url: new URL(request.url),
|
|
49
|
+
context: {},
|
|
50
|
+
};
|
|
51
|
+
const { id, params, routeWithMiddlewares } = (_a = router(event.url.pathname)) !== null && _a !== void 0 ? _a : {};
|
|
52
|
+
if (!id || !params || !routeWithMiddlewares) {
|
|
53
|
+
throw error(404, "Route not found");
|
|
54
|
+
}
|
|
55
|
+
log("route id: %s", id);
|
|
56
|
+
// handle OPTIONS
|
|
57
|
+
if (request.method.toUpperCase() === "OPTIONS") {
|
|
58
|
+
return new Response(null, {
|
|
59
|
+
headers: (_b = effectiveCorsSettings) !== null && _b !== void 0 ? _b : {}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const endpoint = routeWithMiddlewares.handlers[request.method.toUpperCase()];
|
|
63
|
+
if (!(endpoint === null || endpoint === void 0 ? void 0 : endpoint.handle)) {
|
|
64
|
+
throw error(405, "Method not allowed");
|
|
65
|
+
}
|
|
66
|
+
const param = await parseParam(params !== null && params !== void 0 ? params : {}, endpoint);
|
|
67
|
+
const query = await parseQuery(request, endpoint);
|
|
68
|
+
const body = await parseBody(request, endpoint);
|
|
69
|
+
log("handler: %O", endpoint.handle);
|
|
70
|
+
const output = await endpoint.handle({ body, query, param, event });
|
|
71
|
+
log("output: %O", output);
|
|
72
|
+
if (output instanceof Response) {
|
|
73
|
+
if (effectiveCorsSettings) {
|
|
74
|
+
for (const [key, value] of Object.entries(effectiveCorsSettings)) {
|
|
75
|
+
output.headers.set(key, value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return output;
|
|
79
|
+
}
|
|
80
|
+
// ReadableStream for Server-Sent Events
|
|
81
|
+
if (output instanceof ReadableStream) {
|
|
82
|
+
const res = new Response(output, {
|
|
83
|
+
headers: {
|
|
84
|
+
"Content-Type": "text/event-stream",
|
|
85
|
+
"Cache-Control": "no-cache",
|
|
86
|
+
Connection: "keep-alive",
|
|
87
|
+
...(effectiveCorsSettings !== null && effectiveCorsSettings !== void 0 ? effectiveCorsSettings : {}),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
return res;
|
|
91
|
+
}
|
|
92
|
+
if (output instanceof ArrayBuffer) {
|
|
93
|
+
const res = new Response(output, {
|
|
94
|
+
headers: {
|
|
95
|
+
"Content-Type": "application/octet-stream",
|
|
96
|
+
...(effectiveCorsSettings !== null && effectiveCorsSettings !== void 0 ? effectiveCorsSettings : {}),
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
return res;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const out = await recursiveAwait(await parseOutput(output, endpoint));
|
|
103
|
+
const res = json(out, {
|
|
104
|
+
headers: (_c = effectiveCorsSettings) !== null && _c !== void 0 ? _c : {},
|
|
105
|
+
});
|
|
106
|
+
return res;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
if ('__HTTP_ERROR__' in e) {
|
|
111
|
+
return json(e, {
|
|
112
|
+
status: e.status,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
log("internal error: %O", e);
|
|
116
|
+
return json({ message: 'Internal server error', status: 500 }, {
|
|
117
|
+
status: 500,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
handler,
|
|
123
|
+
openapi: {
|
|
124
|
+
registry,
|
|
125
|
+
handler: openapiHandler(registry, config),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const METHOD: RegExp;
|
|
2
|
+
export declare const CORS_DEFAULT: {
|
|
3
|
+
readonly "Access-Control-Allow-Origin": "*";
|
|
4
|
+
readonly "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
5
|
+
readonly "Access-Control-Allow-Headers": "Content-Type, Authorization";
|
|
6
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const METHOD = /^(GET|POST|PUT|DELETE|PATCH|OPTIONS)$/i;
|
|
2
|
+
export const CORS_DEFAULT = {
|
|
3
|
+
"Access-Control-Allow-Origin": "*",
|
|
4
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
5
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
6
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { EndpointHandler, EndpointSpecification, APIEndpoint, ParamType, QueryType, InputType, OutputType, StreamType, ErrorType } from "./types";
|
|
2
|
+
export declare const endpoint: <P extends ParamType, Q extends QueryType, I extends InputType, O extends OutputType, S extends StreamType, E extends ErrorType>(data?: EndpointSpecification<P, Q, I, O, S, E>) => {
|
|
3
|
+
handler: (handle: EndpointHandler<P, Q, I, O>) => APIEndpoint<P, Q, I, O, S, E>;
|
|
4
|
+
};
|
package/dist/endpoint.js
ADDED
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/log.d.ts
ADDED
package/dist/log.js
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
|
2
|
+
import type { OpenAPIOptions, OpenAPIObjectConfig } from "./types";
|
|
3
|
+
export declare const registerRoutes: ({ routes, registry, base, }: OpenAPIOptions) => void;
|
|
4
|
+
export declare const openapiHandler: (registry: OpenAPIRegistry, config: OpenAPIObjectConfig) => (request: Request) => Promise<Response>;
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { OpenApiGeneratorV3, } from "@asteasolutions/zod-to-openapi";
|
|
2
|
+
import { z } from "./zod.js";
|
|
3
|
+
const openApiPath = (id, base) => id.split("/")
|
|
4
|
+
.slice(0, -1)
|
|
5
|
+
.join("/")
|
|
6
|
+
.replace(/^\./, base)
|
|
7
|
+
.replace(/\[(\.{3})?(.+?)\]/g, "{$2}")
|
|
8
|
+
.replace(/\(.+\)\//g, "");
|
|
9
|
+
export const registerRoutes = ({ routes, registry, base = "/api", }) => {
|
|
10
|
+
for (const [id, { handlers }] of Object.entries(routes)) {
|
|
11
|
+
Object.entries(handlers).forEach(([methodString, module]) => {
|
|
12
|
+
var _a;
|
|
13
|
+
const modifier = module.Modifier || ((r) => r);
|
|
14
|
+
const query = module.Query || z.object({});
|
|
15
|
+
const params = module.Param || z.object({});
|
|
16
|
+
const body = module.Input || undefined;
|
|
17
|
+
const path = openApiPath(id, base);
|
|
18
|
+
const method = methodString.toLowerCase();
|
|
19
|
+
registry.registerPath(modifier({
|
|
20
|
+
method,
|
|
21
|
+
path,
|
|
22
|
+
request: {
|
|
23
|
+
params,
|
|
24
|
+
query,
|
|
25
|
+
body: body
|
|
26
|
+
? {
|
|
27
|
+
description: "",
|
|
28
|
+
content: {
|
|
29
|
+
"application/json": {
|
|
30
|
+
schema: body,
|
|
31
|
+
},
|
|
32
|
+
"application/x-www-form-urlencoded": {
|
|
33
|
+
schema: body,
|
|
34
|
+
},
|
|
35
|
+
"multipart/form-data": {
|
|
36
|
+
schema: body,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
: undefined,
|
|
41
|
+
},
|
|
42
|
+
responses: {
|
|
43
|
+
...(module.Output
|
|
44
|
+
? {
|
|
45
|
+
"200": {
|
|
46
|
+
description: "",
|
|
47
|
+
content: {
|
|
48
|
+
"application/json": {
|
|
49
|
+
schema: module.Output,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
: undefined),
|
|
55
|
+
...(module.Stream
|
|
56
|
+
? {
|
|
57
|
+
"200": {
|
|
58
|
+
description: "",
|
|
59
|
+
content: {
|
|
60
|
+
"text/event-stream": {
|
|
61
|
+
schema: module.Stream,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
: undefined),
|
|
67
|
+
...(query || module.Param || body
|
|
68
|
+
? {
|
|
69
|
+
"400": {
|
|
70
|
+
description: "Invalid input (path parameters, query string, or body)",
|
|
71
|
+
content: {
|
|
72
|
+
"application/json": {
|
|
73
|
+
schema: z.object({
|
|
74
|
+
message: z.string(),
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
: undefined),
|
|
81
|
+
...((_a = Object.fromEntries((module.Error ? Object.entries(module.Error) : []).map(([status, error]) => [
|
|
82
|
+
status,
|
|
83
|
+
{
|
|
84
|
+
description: error.message,
|
|
85
|
+
},
|
|
86
|
+
]))) !== null && _a !== void 0 ? _a : {}),
|
|
87
|
+
},
|
|
88
|
+
}));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
export const openapiHandler = (registry, config) => {
|
|
93
|
+
let document = undefined;
|
|
94
|
+
return async (request) => {
|
|
95
|
+
if (!document) {
|
|
96
|
+
const url = new URL(request.url).origin;
|
|
97
|
+
const generator = new OpenApiGeneratorV3(registry.definitions);
|
|
98
|
+
document = generator.generateDocument({
|
|
99
|
+
servers: [
|
|
100
|
+
{
|
|
101
|
+
url,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
...config,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return new Response(JSON.stringify(document), {
|
|
108
|
+
headers: {
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
};
|
package/dist/parser.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { RouteParam } from "./types";
|
|
2
|
+
export declare const parseRouteId: (id: string) => {
|
|
3
|
+
pattern: RegExp;
|
|
4
|
+
params: RouteParam[];
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Removes optional params from a route ID.
|
|
8
|
+
*/
|
|
9
|
+
export declare const removeOptionalParams: (id: string) => string;
|
|
10
|
+
/**
|
|
11
|
+
* Splits a route id into its segments, removing segments that
|
|
12
|
+
* don't affect the path (i.e. groups). The root route is represented by `/`
|
|
13
|
+
* and will be returned as `['']`.
|
|
14
|
+
*/
|
|
15
|
+
export declare const getRouteSegments: (route: string) => string[];
|
|
16
|
+
/**
|
|
17
|
+
* Populate a route ID with params to resolve a pathname.
|
|
18
|
+
* @example
|
|
19
|
+
* ```js
|
|
20
|
+
* resolveRoute(
|
|
21
|
+
* `/blog/[slug]/[...somethingElse]`,
|
|
22
|
+
* {
|
|
23
|
+
* slug: 'hello-world',
|
|
24
|
+
* somethingElse: 'something/else'
|
|
25
|
+
* }
|
|
26
|
+
* ); // `/blog/hello-world/something/else`
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export declare const resolveRoute: (id: string, params: Record<string, string | undefined>) => string;
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const paramPattern = /^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/;
|
|
2
|
+
export const parseRouteId = (id) => {
|
|
3
|
+
const params = [];
|
|
4
|
+
const pattern = id === '/'
|
|
5
|
+
? /^\/$/
|
|
6
|
+
: new RegExp(`^${getRouteSegments(id)
|
|
7
|
+
.map((segment) => {
|
|
8
|
+
// special case — /[...rest]/ could contain zero segments
|
|
9
|
+
const restMatch = /^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(segment);
|
|
10
|
+
if (restMatch) {
|
|
11
|
+
params.push({
|
|
12
|
+
name: restMatch[1],
|
|
13
|
+
matcher: restMatch[2],
|
|
14
|
+
optional: false,
|
|
15
|
+
rest: true,
|
|
16
|
+
chained: true
|
|
17
|
+
});
|
|
18
|
+
return '(?:/([^]*))?';
|
|
19
|
+
}
|
|
20
|
+
// special case — /[[optional]]/ could contain zero segments
|
|
21
|
+
const optionalMatch = /^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(segment);
|
|
22
|
+
if (optionalMatch) {
|
|
23
|
+
params.push({
|
|
24
|
+
name: optionalMatch[1],
|
|
25
|
+
matcher: optionalMatch[2],
|
|
26
|
+
optional: true,
|
|
27
|
+
rest: false,
|
|
28
|
+
chained: true
|
|
29
|
+
});
|
|
30
|
+
return '(?:/([^/]+))?';
|
|
31
|
+
}
|
|
32
|
+
if (!segment) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const parts = segment.split(/\[(.+?)\](?!\])/);
|
|
36
|
+
const result = parts
|
|
37
|
+
.map((content, i) => {
|
|
38
|
+
if (i % 2) {
|
|
39
|
+
if (content.startsWith('x+')) {
|
|
40
|
+
return escape(String.fromCharCode(parseInt(content.slice(2), 16)));
|
|
41
|
+
}
|
|
42
|
+
if (content.startsWith('u+')) {
|
|
43
|
+
return escape(String.fromCharCode(...content
|
|
44
|
+
.slice(2)
|
|
45
|
+
.split('-')
|
|
46
|
+
.map((code) => parseInt(code, 16))));
|
|
47
|
+
}
|
|
48
|
+
const match = (paramPattern.exec(content));
|
|
49
|
+
if (!match) {
|
|
50
|
+
throw new Error(`Invalid param: ${content}. Params and matcher names can only have underscores and alphanumeric characters.`);
|
|
51
|
+
}
|
|
52
|
+
const [, isOptional, isRest, name, matcher] = match;
|
|
53
|
+
// It's assumed that the following invalid route id cases are already checked
|
|
54
|
+
// - unbalanced brackets
|
|
55
|
+
// - optional param following rest param
|
|
56
|
+
params.push({
|
|
57
|
+
name,
|
|
58
|
+
matcher,
|
|
59
|
+
optional: !!isOptional,
|
|
60
|
+
rest: !!isRest,
|
|
61
|
+
chained: isRest ? i === 1 && parts[0] === '' : false
|
|
62
|
+
});
|
|
63
|
+
return isRest ? '([^]*?)' : isOptional ? '([^/]*)?' : '([^/]+?)';
|
|
64
|
+
}
|
|
65
|
+
return escape(content);
|
|
66
|
+
})
|
|
67
|
+
.join('');
|
|
68
|
+
return '/' + result;
|
|
69
|
+
})
|
|
70
|
+
.join('')}/?$`);
|
|
71
|
+
return { pattern, params };
|
|
72
|
+
};
|
|
73
|
+
const optionalParamRegex = /\/\[\[\w+?(?:=\w+)?\]\]/;
|
|
74
|
+
/**
|
|
75
|
+
* Removes optional params from a route ID.
|
|
76
|
+
*/
|
|
77
|
+
export const removeOptionalParams = (id) => {
|
|
78
|
+
return id.replace(optionalParamRegex, '');
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Returns `false` for `(group)` segments
|
|
82
|
+
*/
|
|
83
|
+
function affectsPath(segment) {
|
|
84
|
+
return segment !== '' && !/^\([^)]+\)$/.test(segment);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Splits a route id into its segments, removing segments that
|
|
88
|
+
* don't affect the path (i.e. groups). The root route is represented by `/`
|
|
89
|
+
* and will be returned as `['']`.
|
|
90
|
+
*/
|
|
91
|
+
export const getRouteSegments = (route) => {
|
|
92
|
+
return route.slice(1).split('/').filter(affectsPath);
|
|
93
|
+
};
|
|
94
|
+
const escape = (str) => {
|
|
95
|
+
return (str
|
|
96
|
+
.normalize()
|
|
97
|
+
// escape [ and ] before escaping other characters, since they are used in the replacements
|
|
98
|
+
.replace(/[[\]]/g, '\\$&')
|
|
99
|
+
// replace %, /, ? and # with their encoded versions because decode_pathname leaves them untouched
|
|
100
|
+
.replace(/%/g, '%25')
|
|
101
|
+
.replace(/\//g, '%2[Ff]')
|
|
102
|
+
.replace(/\?/g, '%3[Ff]')
|
|
103
|
+
.replace(/#/g, '%23')
|
|
104
|
+
// escape characters that have special meaning in regex
|
|
105
|
+
.replace(/[.*+?^${}()|\\]/g, '\\$&'));
|
|
106
|
+
};
|
|
107
|
+
const basicParamPattern = /\[(\[)?(\.\.\.)?(\w+?)(?:=(\w+))?\]\]?/g;
|
|
108
|
+
/**
|
|
109
|
+
* Populate a route ID with params to resolve a pathname.
|
|
110
|
+
* @example
|
|
111
|
+
* ```js
|
|
112
|
+
* resolveRoute(
|
|
113
|
+
* `/blog/[slug]/[...somethingElse]`,
|
|
114
|
+
* {
|
|
115
|
+
* slug: 'hello-world',
|
|
116
|
+
* somethingElse: 'something/else'
|
|
117
|
+
* }
|
|
118
|
+
* ); // `/blog/hello-world/something/else`
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export const resolveRoute = (id, params) => {
|
|
122
|
+
const segments = getRouteSegments(id);
|
|
123
|
+
const hasIdTrailingSlash = id != '/' && id.endsWith('/');
|
|
124
|
+
return ('/' +
|
|
125
|
+
segments
|
|
126
|
+
.map((segment) => segment.replace(basicParamPattern, (_, optional, rest, name) => {
|
|
127
|
+
const paramValue = params[name];
|
|
128
|
+
// This is nested so TS correctly narrows the type
|
|
129
|
+
if (!paramValue) {
|
|
130
|
+
if (optional)
|
|
131
|
+
return '';
|
|
132
|
+
if (rest && paramValue !== undefined)
|
|
133
|
+
return '';
|
|
134
|
+
throw new Error(`Missing parameter '${name}' in route ${id}`);
|
|
135
|
+
}
|
|
136
|
+
if (paramValue.startsWith('/') || paramValue.endsWith('/'))
|
|
137
|
+
throw new Error(`Parameter '${name}' in route ${id} cannot start or end with a slash -- this would cause an invalid route like foo//bar`);
|
|
138
|
+
return paramValue;
|
|
139
|
+
}))
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.join('/') +
|
|
142
|
+
(hasIdTrailingSlash ? '/' : ''));
|
|
143
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MiddlewareMap, RouteModuleMap, RouteParam } from "./types";
|
|
2
|
+
export declare const buildRouter: (routesMap: RouteModuleMap, middlewaresMap: MiddlewareMap) => (path: string) => {
|
|
3
|
+
id: string;
|
|
4
|
+
params: Record<string, string> | undefined;
|
|
5
|
+
routeWithMiddlewares: {
|
|
6
|
+
middlewares: import("./types").Middleware[];
|
|
7
|
+
matcher: {
|
|
8
|
+
pattern: RegExp;
|
|
9
|
+
params: RouteParam[];
|
|
10
|
+
};
|
|
11
|
+
handlers: Record<import("./types").Method, import("./types").GenericApiEndpoint>;
|
|
12
|
+
id: string;
|
|
13
|
+
};
|
|
14
|
+
} | undefined;
|
package/dist/routing.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const exec = (match, params, matchers) => {
|
|
2
|
+
const result = {};
|
|
3
|
+
const values = match.slice(1);
|
|
4
|
+
const valuesNeedingMatch = values.filter((value) => value !== undefined);
|
|
5
|
+
let buffered = 0;
|
|
6
|
+
for (let i = 0; i < params.length; i += 1) {
|
|
7
|
+
const param = params[i];
|
|
8
|
+
let value = values[i - buffered];
|
|
9
|
+
// in the `[[a=b]]/.../[...rest]` case, if one or more optional parameters
|
|
10
|
+
// weren't matched, roll the skipped values into the rest
|
|
11
|
+
if (param.chained && param.rest && buffered) {
|
|
12
|
+
value = values
|
|
13
|
+
.slice(i - buffered, i + 1)
|
|
14
|
+
.filter((s) => s)
|
|
15
|
+
.join('/');
|
|
16
|
+
buffered = 0;
|
|
17
|
+
}
|
|
18
|
+
// if `value` is undefined, it means this is an optional or rest parameter
|
|
19
|
+
if (value === undefined) {
|
|
20
|
+
if (param.rest) {
|
|
21
|
+
// We need to allow the matcher to run so that it can decide if this optional rest param should be allowed to match
|
|
22
|
+
value = '';
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (!param.matcher || matchers[param.matcher](value)) {
|
|
29
|
+
result[param.name] = value;
|
|
30
|
+
// Now that the params match, reset the buffer if the next param isn't the [...rest]
|
|
31
|
+
// and the next value is defined, otherwise the buffer will cause us to skip values
|
|
32
|
+
const nextParam = params[i + 1];
|
|
33
|
+
const nextValue = values[i + 1];
|
|
34
|
+
if (nextParam && !nextParam.rest && nextParam.optional && nextValue && param.chained) {
|
|
35
|
+
buffered = 0;
|
|
36
|
+
}
|
|
37
|
+
// There are no more params and no more values, but all non-empty values have been matched
|
|
38
|
+
if (!nextParam &&
|
|
39
|
+
!nextValue &&
|
|
40
|
+
Object.keys(result).length === valuesNeedingMatch.length) {
|
|
41
|
+
buffered = 0;
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// in the `/[[a=b]]/...` case, if the value didn't satisfy the matcher,
|
|
46
|
+
// keep track of the number of skipped optional parameters and continue
|
|
47
|
+
if (param.optional && param.chained) {
|
|
48
|
+
buffered++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// otherwise, if the matcher returns `false`, the route did not match
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (buffered)
|
|
55
|
+
return;
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
const routeSorter = (a, b) => {
|
|
59
|
+
return a.id.length - b.id.length;
|
|
60
|
+
};
|
|
61
|
+
export const buildRouter = (routesMap, middlewaresMap) => {
|
|
62
|
+
const middlewareEntries = Object.entries(middlewaresMap).sort((a, b) => a[0].length - b[0].length);
|
|
63
|
+
const routesWithMiddlewaresList = Object.entries(routesMap).map(([id, route]) => ({
|
|
64
|
+
id,
|
|
65
|
+
...route,
|
|
66
|
+
middlewares: middlewareEntries.filter(([middlewareId]) => id.startsWith(middlewareId)).map(([_, middleware]) => middleware),
|
|
67
|
+
})).sort(routeSorter);
|
|
68
|
+
return (path) => {
|
|
69
|
+
for (const route of routesWithMiddlewaresList) {
|
|
70
|
+
const { pattern } = route.matcher;
|
|
71
|
+
const matches = pattern.exec(path);
|
|
72
|
+
if (matches) {
|
|
73
|
+
return { id: route.id, params: exec(matches, route.matcher.params, {}), routeWithMiddlewares: route };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
};
|
|
78
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { RouteConfig, OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
|
2
|
+
import type { OpenAPIObjectConfig } from "@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator";
|
|
3
|
+
import type { z } from "./zod";
|
|
4
|
+
export type { OpenAPIObject } from "openapi3-ts/oas30";
|
|
5
|
+
export type { OpenAPIObjectConfig } from "@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator";
|
|
6
|
+
export type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
|
|
7
|
+
export interface HttpError {
|
|
8
|
+
status: number;
|
|
9
|
+
key?: string;
|
|
10
|
+
message?: string;
|
|
11
|
+
properties?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
export interface RouteParam {
|
|
14
|
+
name: string;
|
|
15
|
+
matcher: string;
|
|
16
|
+
optional: boolean;
|
|
17
|
+
rest: boolean;
|
|
18
|
+
chained: boolean;
|
|
19
|
+
}
|
|
20
|
+
export type Middleware = {
|
|
21
|
+
pre: (event: RequestEvent) => Promise<RequestEvent>;
|
|
22
|
+
post: (event: RequestEvent) => Promise<RequestEvent>;
|
|
23
|
+
};
|
|
24
|
+
export interface ApiRoute {
|
|
25
|
+
matcher: {
|
|
26
|
+
pattern: RegExp;
|
|
27
|
+
params: RouteParam[];
|
|
28
|
+
};
|
|
29
|
+
handlers: Record<Method, GenericApiEndpoint>;
|
|
30
|
+
}
|
|
31
|
+
export interface ApiRouteWithMiddlewares extends ApiRoute {
|
|
32
|
+
middlewares: Middleware[];
|
|
33
|
+
}
|
|
34
|
+
export type RouteModuleMap = Record<string, {
|
|
35
|
+
matcher: {
|
|
36
|
+
pattern: RegExp;
|
|
37
|
+
params: RouteParam[];
|
|
38
|
+
};
|
|
39
|
+
handlers: Record<Method, GenericApiEndpoint>;
|
|
40
|
+
}>;
|
|
41
|
+
export type RouteWithMiddlewaresMap = Record<string, ApiRouteWithMiddlewares>;
|
|
42
|
+
export type MiddlewareMap = Record<string, Middleware>;
|
|
43
|
+
export type MatcherMap = Record<string, {
|
|
44
|
+
pattern: RegExp;
|
|
45
|
+
params: RouteParam[];
|
|
46
|
+
}>;
|
|
47
|
+
export interface APIContext {
|
|
48
|
+
routes: RouteWithMiddlewaresMap;
|
|
49
|
+
matchers: MatcherMap;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Modify route config after parsing input-output shapes.
|
|
53
|
+
* Useful for adding custom tags, etc.
|
|
54
|
+
*
|
|
55
|
+
* @param r Route config
|
|
56
|
+
* @returns Modified route config
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* export const Modifier: RouteModifier = (r) => {
|
|
60
|
+
* r.tags = ["Tag"];
|
|
61
|
+
* r.operationId = "customOperationId";
|
|
62
|
+
* r.security = [{ bearerAuth: [] }];
|
|
63
|
+
* return r;
|
|
64
|
+
* };
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export type EndpointModifier = (r: RouteConfig) => RouteConfig;
|
|
68
|
+
export interface CORS {
|
|
69
|
+
"Access-Control-Allow-Origin": string;
|
|
70
|
+
"Access-Control-Allow-Methods": string;
|
|
71
|
+
"Access-Control-Allow-Headers": string;
|
|
72
|
+
}
|
|
73
|
+
export interface APIOptions {
|
|
74
|
+
routeModuleMap: Record<string, () => Promise<unknown>>;
|
|
75
|
+
config: OpenAPIObjectConfig;
|
|
76
|
+
base?: string;
|
|
77
|
+
cors?: boolean | CORS;
|
|
78
|
+
registry?: OpenAPIRegistry;
|
|
79
|
+
}
|
|
80
|
+
export interface OpenAPIOptions {
|
|
81
|
+
base?: string;
|
|
82
|
+
routes: RouteModuleMap;
|
|
83
|
+
registry: OpenAPIRegistry;
|
|
84
|
+
}
|
|
85
|
+
export interface EndpointHandler<P, Q, I, O> {
|
|
86
|
+
({ body, query, param, event }: {
|
|
87
|
+
body: z.infer<I>;
|
|
88
|
+
query: z.infer<Q>;
|
|
89
|
+
param: z.infer<P>;
|
|
90
|
+
event: RequestEvent;
|
|
91
|
+
}): Promise<z.input<O>>;
|
|
92
|
+
}
|
|
93
|
+
export type ParamType = z.ZodObject;
|
|
94
|
+
export type QueryType = z.ZodObject;
|
|
95
|
+
export type InputType = z.ZodType;
|
|
96
|
+
export type OutputType = z.ZodType;
|
|
97
|
+
export type StreamType = z.ZodType;
|
|
98
|
+
export type ErrorType = Record<string, HttpError>;
|
|
99
|
+
export interface EndpointSpecification<P extends ParamType, Q extends QueryType, I extends InputType, O extends OutputType, S extends StreamType, E extends ErrorType> {
|
|
100
|
+
Param?: P;
|
|
101
|
+
Query?: Q;
|
|
102
|
+
Input?: I;
|
|
103
|
+
Output?: O;
|
|
104
|
+
Stream?: S;
|
|
105
|
+
Error?: E;
|
|
106
|
+
Modifier?: EndpointModifier;
|
|
107
|
+
}
|
|
108
|
+
export interface APIEndpoint<P extends ParamType = z.ZodObject<Record<never, never>>, Q extends QueryType = z.ZodObject<Record<never, never>>, I extends InputType = z.ZodObject<Record<never, never>>, O extends OutputType = z.ZodObject<Record<never, never>>, S extends StreamType = z.ZodObject<Record<never, never>>, E extends ErrorType = Record<string, HttpError>> extends EndpointSpecification<P, Q, I, O, S, E> {
|
|
109
|
+
/**
|
|
110
|
+
* Handler
|
|
111
|
+
*/
|
|
112
|
+
handle?: EndpointHandler<P, Q, I, O>;
|
|
113
|
+
}
|
|
114
|
+
export interface EndpointBuilder<P extends ParamType, Q extends QueryType, I extends InputType, O extends OutputType, S extends StreamType, E extends ErrorType> {
|
|
115
|
+
(spec: EndpointSpecification<P, Q, I, O, S, E>): ({
|
|
116
|
+
handler: () => APIEndpoint<P, Q, I, O, S, E>;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
export interface EndpointStreamSetter<P extends ParamType, Q extends QueryType, I extends InputType, O extends OutputType, S extends StreamType, E extends ErrorType> {
|
|
120
|
+
(data: APIEndpoint<P, Q, I, O, S, E>): EndpointBuilder<P, Q, I, O, S, E>;
|
|
121
|
+
}
|
|
122
|
+
export type GenericApiEndpoint = APIEndpoint<ParamType, QueryType, InputType, OutputType, StreamType, ErrorType>;
|
|
123
|
+
export interface GenericApiEndpointModule {
|
|
124
|
+
apiEndpoint: GenericApiEndpoint;
|
|
125
|
+
}
|
|
126
|
+
declare global {
|
|
127
|
+
namespace Riddl {
|
|
128
|
+
interface RequestContext {
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export interface RequestEvent {
|
|
133
|
+
request: Request;
|
|
134
|
+
url: URL;
|
|
135
|
+
context: Riddl.RequestContext;
|
|
136
|
+
}
|
|
137
|
+
export interface Handler {
|
|
138
|
+
(request: Request): Promise<Response>;
|
|
139
|
+
}
|
|
140
|
+
export interface API {
|
|
141
|
+
handler: Handler;
|
|
142
|
+
openapi: {
|
|
143
|
+
registry: OpenAPIRegistry;
|
|
144
|
+
handler: Handler;
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export type ParamMatcher = (param: string) => boolean;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { IncomingMessage } from "http";
|
|
2
|
+
import type { Request as ExpressRequest } from "express";
|
|
3
|
+
export declare const recursiveAwait: (obj: unknown) => unknown;
|
|
4
|
+
export declare const validateRouteId: (id: string) => void;
|
|
5
|
+
export declare const toFetchRequest: (req: IncomingMessage | ExpressRequest) => Request;
|
|
6
|
+
export declare function json(data: unknown, init?: ResponseInit): Response;
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const textEncoder = new TextEncoder();
|
|
2
|
+
export const recursiveAwait = (obj) => {
|
|
3
|
+
if (obj instanceof Promise) {
|
|
4
|
+
return obj.then(recursiveAwait);
|
|
5
|
+
}
|
|
6
|
+
else if (obj instanceof Array) {
|
|
7
|
+
return Promise.all(obj.map(recursiveAwait));
|
|
8
|
+
}
|
|
9
|
+
else if (obj instanceof Object) {
|
|
10
|
+
const keys = Object.keys(obj);
|
|
11
|
+
const values = Object.values(obj);
|
|
12
|
+
return Promise.all(values.map(recursiveAwait)).then((values) => {
|
|
13
|
+
const returns = {};
|
|
14
|
+
for (let i = 0; i < keys.length; i++) {
|
|
15
|
+
returns[keys[i]] = values[i];
|
|
16
|
+
}
|
|
17
|
+
return returns;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
return obj;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const countOccurrences = (needle, haystack) => {
|
|
25
|
+
let count = 0;
|
|
26
|
+
for (let i = 0; i < haystack.length; i += 1) {
|
|
27
|
+
if (haystack[i] === needle)
|
|
28
|
+
count += 1;
|
|
29
|
+
}
|
|
30
|
+
return count;
|
|
31
|
+
};
|
|
32
|
+
export const validateRouteId = (id) => {
|
|
33
|
+
if (/\]\[/.test(id)) {
|
|
34
|
+
throw new Error(`Invalid route ${id} — parameters must be separated`);
|
|
35
|
+
}
|
|
36
|
+
if (countOccurrences('[', id) !== countOccurrences(']', id)) {
|
|
37
|
+
throw new Error(`Invalid route ${id} — brackets are unbalanced`);
|
|
38
|
+
}
|
|
39
|
+
if (/\[\.\.\.\w+\]\/\[\[/.test(id)) {
|
|
40
|
+
throw new Error(`Invalid route ${id} — an [[optional]] route segment cannot follow a [...rest] route segment`);
|
|
41
|
+
}
|
|
42
|
+
if (/\[\[\.\.\./.test(id)) {
|
|
43
|
+
throw new Error(`Invalid route ${id} — a rest route segment is always optional, remove the outer square brackets`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const isNodeReadable = (value) => !!value && typeof value.pipe === "function";
|
|
47
|
+
const isBodyInit = (value) => {
|
|
48
|
+
if (typeof value === "string")
|
|
49
|
+
return true;
|
|
50
|
+
if (value instanceof ArrayBuffer)
|
|
51
|
+
return true;
|
|
52
|
+
if (ArrayBuffer.isView(value))
|
|
53
|
+
return true;
|
|
54
|
+
if (typeof Blob !== "undefined" && value instanceof Blob)
|
|
55
|
+
return true;
|
|
56
|
+
if (typeof FormData !== "undefined" && value instanceof FormData)
|
|
57
|
+
return true;
|
|
58
|
+
if (value instanceof URLSearchParams)
|
|
59
|
+
return true;
|
|
60
|
+
if (typeof ReadableStream !== "undefined" && value instanceof ReadableStream)
|
|
61
|
+
return true;
|
|
62
|
+
if (isNodeReadable(value))
|
|
63
|
+
return true;
|
|
64
|
+
return false;
|
|
65
|
+
};
|
|
66
|
+
const coerceBody = (body, headers) => {
|
|
67
|
+
if (body == null)
|
|
68
|
+
return undefined;
|
|
69
|
+
if (isBodyInit(body))
|
|
70
|
+
return body;
|
|
71
|
+
const json = JSON.stringify(body);
|
|
72
|
+
if (!headers.has("content-type")) {
|
|
73
|
+
headers.set("content-type", "application/json; charset=utf-8");
|
|
74
|
+
}
|
|
75
|
+
return json;
|
|
76
|
+
};
|
|
77
|
+
const getRequestUrl = (req) => {
|
|
78
|
+
var _a;
|
|
79
|
+
const rawUrl = "originalUrl" in req ? req.originalUrl || req.url || "/" : req.url || "/";
|
|
80
|
+
if (/^https?:\/\//i.test(rawUrl))
|
|
81
|
+
return new URL(rawUrl);
|
|
82
|
+
if ("protocol" in req) {
|
|
83
|
+
return new URL(rawUrl, `${req.protocol}://${req.get("host")}`);
|
|
84
|
+
}
|
|
85
|
+
const host = (_a = req.headers.host) !== null && _a !== void 0 ? _a : "localhost";
|
|
86
|
+
const forwardedProto = req.headers["x-forwarded-proto"];
|
|
87
|
+
const proto = Array.isArray(forwardedProto)
|
|
88
|
+
? forwardedProto[0]
|
|
89
|
+
: forwardedProto;
|
|
90
|
+
const protocol = (proto === null || proto === void 0 ? void 0 : proto.split(",")[0].trim()) || "http";
|
|
91
|
+
return new URL(rawUrl, `${protocol}://${host}`);
|
|
92
|
+
};
|
|
93
|
+
export const toFetchRequest = (req) => {
|
|
94
|
+
const headers = new Headers();
|
|
95
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
96
|
+
if (Array.isArray(value))
|
|
97
|
+
headers.append(key, value.join(","));
|
|
98
|
+
else if (value != null)
|
|
99
|
+
headers.set(key, value);
|
|
100
|
+
}
|
|
101
|
+
const method = req.method || "GET";
|
|
102
|
+
const expressReq = req;
|
|
103
|
+
const parsedBody = "body" in expressReq ? expressReq.body : undefined;
|
|
104
|
+
const rawBody = "rawBody" in expressReq ? expressReq.rawBody : undefined;
|
|
105
|
+
let body;
|
|
106
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
107
|
+
body = coerceBody(rawBody !== null && rawBody !== void 0 ? rawBody : parsedBody, headers);
|
|
108
|
+
if (body === undefined && isNodeReadable(req) && !req.readableEnded) {
|
|
109
|
+
body = req;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const init = {
|
|
113
|
+
method,
|
|
114
|
+
headers,
|
|
115
|
+
body: body,
|
|
116
|
+
};
|
|
117
|
+
if (isNodeReadable(body)) {
|
|
118
|
+
init.duplex = "half"; // required when body is a stream
|
|
119
|
+
}
|
|
120
|
+
return new Request(getRequestUrl(req).toString(), init);
|
|
121
|
+
};
|
|
122
|
+
export function json(data, init) {
|
|
123
|
+
const body = JSON.stringify(data);
|
|
124
|
+
const headers = new Headers(init === null || init === void 0 ? void 0 : init.headers);
|
|
125
|
+
if (!headers.has('content-length')) {
|
|
126
|
+
headers.set('content-length', textEncoder.encode(body).byteLength.toString());
|
|
127
|
+
}
|
|
128
|
+
if (!headers.has('content-type')) {
|
|
129
|
+
headers.set('content-type', 'application/json');
|
|
130
|
+
}
|
|
131
|
+
return new Response(body, {
|
|
132
|
+
...init,
|
|
133
|
+
headers,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { GenericApiEndpoint } from "./types";
|
|
2
|
+
export declare const parseBody: (req: Request, module: GenericApiEndpoint) => Promise<unknown>;
|
|
3
|
+
export declare const parseQuery: (request: Request, module: GenericApiEndpoint) => Promise<Record<string, string | string[]>>;
|
|
4
|
+
export declare const parseParam: (params: Record<string, string>, module: GenericApiEndpoint) => Promise<Record<string, string | never>>;
|
|
5
|
+
export declare const parseOutput: (out: unknown, module: GenericApiEndpoint) => Promise<Record<string, unknown> | unknown[]>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { fromZodError } from "zod-validation-error";
|
|
2
|
+
import { log } from "./log.js";
|
|
3
|
+
import { z } from "./zod.js";
|
|
4
|
+
import { error } from "./errors.js";
|
|
5
|
+
const parseJsonBody = async (req) => {
|
|
6
|
+
var _a;
|
|
7
|
+
const clonedRequest = req.clone();
|
|
8
|
+
if ((_a = clonedRequest.headers.get("content-type")) === null || _a === void 0 ? void 0 : _a.startsWith("application/json")) {
|
|
9
|
+
try {
|
|
10
|
+
const json = await clonedRequest.json();
|
|
11
|
+
if (typeof json === "object") {
|
|
12
|
+
return json;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw error(400, "Invalid JSON body");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
export const parseBody = async (req, module) => {
|
|
21
|
+
var _a, _b, _c, _d, _e, _f;
|
|
22
|
+
let body = {};
|
|
23
|
+
// GET, HEAD, DELETE, OPTIONS have no body
|
|
24
|
+
const method = req.method.toUpperCase();
|
|
25
|
+
if (["GET", "HEAD", "DELETE", "OPTIONS"].includes(method)) {
|
|
26
|
+
return body;
|
|
27
|
+
}
|
|
28
|
+
const clonedRequest = req.clone();
|
|
29
|
+
// JSON body
|
|
30
|
+
if ((_a = clonedRequest.headers.get("content-type")) === null || _a === void 0 ? void 0 : _a.startsWith("application/json")) {
|
|
31
|
+
body = clonedRequest.json().catch(() => { throw error(400, "Invalid JSON body"); });
|
|
32
|
+
}
|
|
33
|
+
// Form body
|
|
34
|
+
else if (((_b = clonedRequest.headers
|
|
35
|
+
.get("content-type")) === null || _b === void 0 ? void 0 : _b.startsWith("application/x-www-form-urlencoded")) ||
|
|
36
|
+
((_c = clonedRequest.headers.get("content-type")) === null || _c === void 0 ? void 0 : _c.startsWith("multipart/form-data"))) {
|
|
37
|
+
const formBody = {};
|
|
38
|
+
const form = await clonedRequest.formData();
|
|
39
|
+
for (const [key, value] of form.entries()) {
|
|
40
|
+
if (formBody[key]) {
|
|
41
|
+
const existing = formBody[key];
|
|
42
|
+
if (Array.isArray(existing)) {
|
|
43
|
+
existing.push(value);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
formBody[key] = [existing, value];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
formBody[key] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
body = formBody;
|
|
54
|
+
}
|
|
55
|
+
// Text body
|
|
56
|
+
else if ((_d = clonedRequest.headers.get("content-type")) === null || _d === void 0 ? void 0 : _d.startsWith("text/plain")) {
|
|
57
|
+
body = { text: await clonedRequest.text() };
|
|
58
|
+
}
|
|
59
|
+
// Binary body
|
|
60
|
+
else if ((_e = clonedRequest.headers.get("content-type")) === null || _e === void 0 ? void 0 : _e.startsWith("application/octet-stream")) {
|
|
61
|
+
const buffer = await clonedRequest.arrayBuffer();
|
|
62
|
+
body = { buffer };
|
|
63
|
+
}
|
|
64
|
+
log("body: %O", body);
|
|
65
|
+
const validator = (_f = module.Input) !== null && _f !== void 0 ? _f : z.object({});
|
|
66
|
+
const validation = validator.safeParse(body);
|
|
67
|
+
if (!validation.success) {
|
|
68
|
+
throw error(400, `Invalid body.\n${fromZodError(validation.error).message}`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
body = validation.data;
|
|
72
|
+
}
|
|
73
|
+
return body;
|
|
74
|
+
};
|
|
75
|
+
export const parseQuery = async (request, module) => {
|
|
76
|
+
var _a;
|
|
77
|
+
let query = {};
|
|
78
|
+
for (const [key, value] of new URL(request.url).searchParams.entries()) {
|
|
79
|
+
if (query[key]) {
|
|
80
|
+
const existing = query[key];
|
|
81
|
+
if (Array.isArray(existing)) {
|
|
82
|
+
existing.push(value);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
query[key] = [existing, value];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
query[key] = value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
log("query: %O", query);
|
|
93
|
+
const validator = (_a = module.Query) !== null && _a !== void 0 ? _a : z.object({});
|
|
94
|
+
const validation = validator.safeParse(query);
|
|
95
|
+
if (!validation.success) {
|
|
96
|
+
throw error(400, `Invalid query.\n${fromZodError(validation.error).message}`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
query = validation.data;
|
|
100
|
+
}
|
|
101
|
+
return query;
|
|
102
|
+
};
|
|
103
|
+
export const parseParam = async (params, module) => {
|
|
104
|
+
var _a;
|
|
105
|
+
log("param: %O", params);
|
|
106
|
+
const validator = (_a = module.Param) !== null && _a !== void 0 ? _a : z.object({});
|
|
107
|
+
const validation = validator.safeParse(params);
|
|
108
|
+
if (!validation.success) {
|
|
109
|
+
throw error(400, `Invalid param.\n${fromZodError(validation.error).message}`);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
return validation.data;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
export const parseOutput = async (out, module) => {
|
|
116
|
+
var _a;
|
|
117
|
+
let output = Array.isArray(out)
|
|
118
|
+
? out
|
|
119
|
+
: out && typeof out === "object" ? out : {};
|
|
120
|
+
const validator = (_a = module.Output) !== null && _a !== void 0 ? _a : z.object({});
|
|
121
|
+
const validation = await validator.spa(output);
|
|
122
|
+
if (!validation.success) {
|
|
123
|
+
log.extend("error")("output: %O failed validation: %O", output, validation.error);
|
|
124
|
+
throw error(500, "Output validation failed. Please report this error to the developer.");
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
output = validation.data;
|
|
128
|
+
}
|
|
129
|
+
return output;
|
|
130
|
+
};
|
package/dist/zod.d.ts
ADDED
package/dist/zod.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@riddl/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@asteasolutions/zod-to-openapi": "^8.4.0",
|
|
8
|
+
"debug": "^4.4.0",
|
|
9
|
+
"express": "^5.2.1",
|
|
10
|
+
"set-cookie-parser": "^3.0.1",
|
|
11
|
+
"sse.js": "^2.5.0",
|
|
12
|
+
"zod": "^4.3.6",
|
|
13
|
+
"zod-validation-error": "^5.0.0"
|
|
14
|
+
},
|
|
15
|
+
"main": "dist/index.js",
|
|
16
|
+
"types": "dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./dist/index.js",
|
|
19
|
+
"./*": "./dist/*/index.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"/dist"
|
|
23
|
+
],
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/debug": "^4.1.12",
|
|
26
|
+
"@types/express": "^5.0.6",
|
|
27
|
+
"@types/set-cookie-parser": "^2.4.10",
|
|
28
|
+
"@typescript-eslint/eslint-plugin": "^8.19.0",
|
|
29
|
+
"@typescript-eslint/parser": "^8.19.0",
|
|
30
|
+
"concurrently": "^9.1.2",
|
|
31
|
+
"eslint": "^9.17.0",
|
|
32
|
+
"eslint-config-prettier": "^9.1.0",
|
|
33
|
+
"openapi3-ts": "^4.5.0",
|
|
34
|
+
"prettier": "^3.4.2",
|
|
35
|
+
"tsc-alias": "^1.8.10",
|
|
36
|
+
"type-fest": "^4.31.0",
|
|
37
|
+
"typescript": "^5.7.2"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"lint": "prettier --check . && eslint .",
|
|
41
|
+
"format": "prettier --write .",
|
|
42
|
+
"clean": "rm -f tsconfig.tsbuildinfo dist",
|
|
43
|
+
"build": "tsc && tsc-alias",
|
|
44
|
+
"build:watch": "tsc && tsc-alias && (concurrently \"tsc -w\" \"tsc-alias -w\")"
|
|
45
|
+
}
|
|
46
|
+
}
|