@riddl/core 0.2.7 → 0.9.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/dist/adapters.d.ts +3 -3
- package/dist/adapters.js +7 -7
- package/dist/api.d.ts +1 -1
- package/dist/api.js +75 -26
- package/dist/constants.d.ts +4 -3
- package/dist/constants.js +5 -4
- package/dist/endpoint.d.ts +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.js +10 -6
- package/dist/log.d.ts +1 -1
- package/dist/log.js +2 -2
- package/dist/multipart.d.ts +6 -1
- package/dist/multipart.js +41 -17
- package/dist/openapi.d.ts +2 -2
- package/dist/openapi.js +61 -47
- package/dist/parser.d.ts +1 -19
- package/dist/parser.js +9 -52
- package/dist/routing.d.ts +3 -3
- package/dist/routing.js +20 -7
- package/dist/types.d.ts +18 -14
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +43 -19
- package/dist/validation.d.ts +4 -4
- package/dist/validation.js +26 -33
- package/dist/zod.d.ts +1 -1
- package/dist/zod.js +2 -2
- package/package.json +10 -5
- package/dist/middlewares.d.ts +0 -0
- package/dist/middlewares.js +0 -1
package/dist/adapters.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Handler } from
|
|
2
|
-
import type { IncomingMessage, ServerResponse } from
|
|
3
|
-
import express, { type Router, Application } from
|
|
1
|
+
import type { Handler } from './types';
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import express, { type Router, Application } from 'express';
|
|
4
4
|
export declare const mountApi: (app: Application, prefix: string, apiHandler: Handler) => express.Application;
|
|
5
5
|
export declare const setResponse: (res: ServerResponse, response: Response) => Promise<void>;
|
|
6
6
|
export declare const rawAdapter: (apiHandler: Handler) => (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
package/dist/adapters.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import express from
|
|
2
|
-
import { toFetchRequest } from
|
|
3
|
-
import * as set_cookie_parser from
|
|
4
|
-
import { logError } from
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { toFetchRequest } from './utils.js';
|
|
3
|
+
import * as set_cookie_parser from 'set-cookie-parser';
|
|
4
|
+
import { logError } from './log.js';
|
|
5
5
|
export const mountApi = (app, prefix, apiHandler) => app.use(prefix, expressAdapter(apiHandler));
|
|
6
6
|
export const setResponse = async (res, response) => {
|
|
7
7
|
for (const [key, value] of response.headers) {
|
|
@@ -11,7 +11,7 @@ export const setResponse = async (res, response) => {
|
|
|
11
11
|
: value);
|
|
12
12
|
}
|
|
13
13
|
catch (error) {
|
|
14
|
-
logError(
|
|
14
|
+
logError('failed to set response header', error);
|
|
15
15
|
res.getHeaderNames().forEach((name) => res.removeHeader(name));
|
|
16
16
|
res.writeHead(500).end(String(error));
|
|
17
17
|
return;
|
|
@@ -62,11 +62,11 @@ export const setResponse = async (res, response) => {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
};
|
|
65
|
-
export const rawAdapter = (apiHandler) =>
|
|
65
|
+
export const rawAdapter = (apiHandler) => async (req, res) => {
|
|
66
66
|
const response = await apiHandler(toFetchRequest(req));
|
|
67
67
|
// Delegate entirely to setResponse. Do not preset Content-Length: 0 or
|
|
68
68
|
// Content-Type: application/json — that breaks SSE and other streaming
|
|
69
69
|
// bodies whose Response omits content-length (chunked transfer).
|
|
70
70
|
await setResponse(res, response);
|
|
71
|
-
}
|
|
71
|
+
};
|
|
72
72
|
export const expressAdapter = (apiHandler) => express.Router().use(rawAdapter(apiHandler));
|
package/dist/api.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { APIOptions, API } from './types';
|
|
2
|
-
export declare const api: ({ routeModuleMap, config, base, cors, registry, onInternalError, }: APIOptions) => Promise<API>;
|
|
2
|
+
export declare const api: ({ routeModuleMap, config, base, cors, registry, onInternalError, matchers, maxBodySize, }: APIOptions) => Promise<API>;
|
package/dist/api.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
|
|
2
|
-
import { json, recursiveAwait } from './utils.js';
|
|
3
|
-
import { CORS_DEFAULT, METHOD } from './constants.js';
|
|
2
|
+
import { json, recursiveAwait, validateRouteId } from './utils.js';
|
|
3
|
+
import { CORS_DEFAULT, METHOD, MAX_BODY_SIZE_DEFAULT } from './constants.js';
|
|
4
4
|
import { log, logError } from './log.js';
|
|
5
5
|
import { parseBody, parseOutput, parseParam, parseQuery } from './validation.js';
|
|
6
6
|
import { cleanupTempFiles } from './multipart.js';
|
|
@@ -8,7 +8,7 @@ import { parseRouteId } from './parser.js';
|
|
|
8
8
|
import { buildRouter } from './routing.js';
|
|
9
9
|
import { openapiHandler, registerRoutes } from './openapi.js';
|
|
10
10
|
import { error } from './errors.js';
|
|
11
|
-
export const api = async ({ routeModuleMap, config, base = '', cors = true, registry = new OpenAPIRegistry(), onInternalError, }) => {
|
|
11
|
+
export const api = async ({ routeModuleMap, config, base = '', cors = true, registry = new OpenAPIRegistry(), onInternalError, matchers = {}, maxBodySize = MAX_BODY_SIZE_DEFAULT, }) => {
|
|
12
12
|
const reportInternalError = (error) => {
|
|
13
13
|
logError('internal error', error);
|
|
14
14
|
onInternalError === null || onInternalError === void 0 ? void 0 : onInternalError(error);
|
|
@@ -22,8 +22,18 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
|
|
|
22
22
|
const id = parts.slice(0, -1).join('/');
|
|
23
23
|
if (METHOD.test(last)) {
|
|
24
24
|
const method = last.toUpperCase();
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
let endpointModule;
|
|
26
|
+
try {
|
|
27
|
+
endpointModule = (await module());
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
throw new Error(`Failed to load route module "${path}": ${e instanceof Error ? e.message : String(e)}`);
|
|
31
|
+
}
|
|
32
|
+
const endpoint = endpointModule.apiEndpoint;
|
|
33
|
+
if (!endpoint) {
|
|
34
|
+
throw new Error(`Route module "${path}" does not export "apiEndpoint"`);
|
|
35
|
+
}
|
|
36
|
+
validateRouteId(id);
|
|
27
37
|
acc.routes[id] = {
|
|
28
38
|
matcher: parseRouteId(id),
|
|
29
39
|
handlers: {
|
|
@@ -33,7 +43,12 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
|
|
|
33
43
|
};
|
|
34
44
|
}
|
|
35
45
|
if (last === 'middleware') {
|
|
36
|
-
|
|
46
|
+
try {
|
|
47
|
+
acc.middlewares[id] = (await module());
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
throw new Error(`Failed to load middleware module "${path}": ${e instanceof Error ? e.message : String(e)}`);
|
|
51
|
+
}
|
|
37
52
|
}
|
|
38
53
|
return acc;
|
|
39
54
|
}, Promise.resolve({
|
|
@@ -45,14 +60,14 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
|
|
|
45
60
|
log('config: %O', config);
|
|
46
61
|
log('base: %s', base);
|
|
47
62
|
registerRoutes({ routes, registry, base });
|
|
48
|
-
const router = buildRouter(routes, middlewares);
|
|
63
|
+
const router = buildRouter(routes, middlewares, matchers);
|
|
49
64
|
const effectiveCorsSettings = cors
|
|
50
65
|
? typeof cors === 'object'
|
|
51
66
|
? cors
|
|
52
67
|
: CORS_DEFAULT
|
|
53
68
|
: undefined;
|
|
54
69
|
const handler = async (request) => {
|
|
55
|
-
var _a, _b, _c;
|
|
70
|
+
var _a, _b, _c, _d, _e, _f;
|
|
56
71
|
// Temp files produced by streamed multipart uploads; removed once the
|
|
57
72
|
// handler has finished reading them (it copies bytes to permanent
|
|
58
73
|
// storage before returning).
|
|
@@ -75,7 +90,13 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
|
|
|
75
90
|
headers: (_b = effectiveCorsSettings) !== null && _b !== void 0 ? _b : {},
|
|
76
91
|
});
|
|
77
92
|
}
|
|
78
|
-
const
|
|
93
|
+
const isHead = request.method.toUpperCase() === 'HEAD';
|
|
94
|
+
// HEAD requests fall back to the GET handler
|
|
95
|
+
const reqMethod = (isHead ? 'GET' : request.method.toUpperCase());
|
|
96
|
+
let endpoint = routeWithMiddlewares.handlers[reqMethod];
|
|
97
|
+
if (!(endpoint === null || endpoint === void 0 ? void 0 : endpoint.handle) && isHead) {
|
|
98
|
+
endpoint = routeWithMiddlewares.handlers.GET;
|
|
99
|
+
}
|
|
79
100
|
if (!(endpoint === null || endpoint === void 0 ? void 0 : endpoint.handle)) {
|
|
80
101
|
throw error(405, 'Method not allowed');
|
|
81
102
|
}
|
|
@@ -83,9 +104,9 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
|
|
|
83
104
|
for (const mw of routeWithMiddlewares.middlewares) {
|
|
84
105
|
ctxEvent = await mw.pre(ctxEvent);
|
|
85
106
|
}
|
|
86
|
-
const param =
|
|
87
|
-
const query =
|
|
88
|
-
const body = await parseBody(request, endpoint, tempFiles);
|
|
107
|
+
const param = parseParam(params !== null && params !== void 0 ? params : {}, endpoint);
|
|
108
|
+
const query = parseQuery(request, endpoint);
|
|
109
|
+
const body = await parseBody(request, endpoint, tempFiles, maxBodySize);
|
|
89
110
|
log('handler: %O', endpoint.handle);
|
|
90
111
|
const output = await endpoint.handle({
|
|
91
112
|
body,
|
|
@@ -97,17 +118,17 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
|
|
|
97
118
|
ctxEvent = await mw.post(ctxEvent);
|
|
98
119
|
}
|
|
99
120
|
log('output: %O', output);
|
|
121
|
+
let res;
|
|
100
122
|
if (output instanceof Response) {
|
|
101
123
|
if (effectiveCorsSettings) {
|
|
102
124
|
for (const [key, value] of Object.entries(effectiveCorsSettings)) {
|
|
103
125
|
output.headers.set(key, value);
|
|
104
126
|
}
|
|
105
127
|
}
|
|
106
|
-
|
|
128
|
+
res = output;
|
|
107
129
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const res = new Response(output, {
|
|
130
|
+
else if (output instanceof ReadableStream) {
|
|
131
|
+
res = new Response(output, {
|
|
111
132
|
headers: {
|
|
112
133
|
'Content-Type': 'text/event-stream',
|
|
113
134
|
'Cache-Control': 'no-cache',
|
|
@@ -115,38 +136,66 @@ export const api = async ({ routeModuleMap, config, base = '', cors = true, regi
|
|
|
115
136
|
...(effectiveCorsSettings !== null && effectiveCorsSettings !== void 0 ? effectiveCorsSettings : {}),
|
|
116
137
|
},
|
|
117
138
|
});
|
|
118
|
-
return res;
|
|
119
139
|
}
|
|
120
|
-
if (output instanceof ArrayBuffer) {
|
|
121
|
-
|
|
140
|
+
else if (output instanceof ArrayBuffer) {
|
|
141
|
+
res = new Response(output, {
|
|
122
142
|
headers: {
|
|
123
143
|
'Content-Type': 'application/octet-stream',
|
|
124
144
|
...(effectiveCorsSettings !== null && effectiveCorsSettings !== void 0 ? effectiveCorsSettings : {}),
|
|
125
145
|
},
|
|
126
146
|
});
|
|
127
|
-
return res;
|
|
128
147
|
}
|
|
129
148
|
else {
|
|
130
|
-
|
|
131
|
-
|
|
149
|
+
// Skip recursiveAwait for primitives — it just returns them directly
|
|
150
|
+
// anyway, but avoids the unnecessary Promise wrapping.
|
|
151
|
+
const resolved = typeof output === 'object' &&
|
|
152
|
+
output !== null &&
|
|
153
|
+
!(output instanceof Response) &&
|
|
154
|
+
!(output instanceof ReadableStream) &&
|
|
155
|
+
!(output instanceof ArrayBuffer)
|
|
156
|
+
? await recursiveAwait(output)
|
|
157
|
+
: output;
|
|
158
|
+
const out = await parseOutput(resolved, endpoint);
|
|
159
|
+
res = json(out, {
|
|
132
160
|
headers: (_c = effectiveCorsSettings) !== null && _c !== void 0 ? _c : {},
|
|
133
161
|
});
|
|
134
|
-
return res;
|
|
135
162
|
}
|
|
163
|
+
// For HEAD requests, strip the response body while preserving
|
|
164
|
+
// status code and headers (including Content-Length from the original).
|
|
165
|
+
if (isHead) {
|
|
166
|
+
return new Response(null, {
|
|
167
|
+
status: res.status,
|
|
168
|
+
headers: res.headers,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return res;
|
|
136
172
|
}
|
|
137
173
|
catch (e) {
|
|
138
174
|
if (e && typeof e === 'object' && '__HTTP_ERROR__' in e) {
|
|
139
175
|
const httpError = e;
|
|
140
|
-
|
|
176
|
+
const status = typeof httpError.status === 'number' ? httpError.status : 500;
|
|
177
|
+
if (status >= 500) {
|
|
141
178
|
reportInternalError(e);
|
|
179
|
+
// For server errors, return an opaque response — don't expose
|
|
180
|
+
// internal error details, keys, or the __HTTP_ERROR__ flag.
|
|
181
|
+
return json({ message: 'Internal server error', status: 500 }, {
|
|
182
|
+
status: 500,
|
|
183
|
+
headers: (_d = effectiveCorsSettings) !== null && _d !== void 0 ? _d : {},
|
|
184
|
+
});
|
|
142
185
|
}
|
|
143
|
-
|
|
144
|
-
|
|
186
|
+
// For client errors (4xx), the error key and HTTP status code are
|
|
187
|
+
// intentional — they're part of the API contract. Strip only the
|
|
188
|
+
// internal __HTTP_ERROR__ flag so it doesn't leak to clients.
|
|
189
|
+
const { __HTTP_ERROR__, ...publicError } = e;
|
|
190
|
+
return json(publicError, {
|
|
191
|
+
status,
|
|
192
|
+
headers: (_e = effectiveCorsSettings) !== null && _e !== void 0 ? _e : {},
|
|
145
193
|
});
|
|
146
194
|
}
|
|
147
195
|
reportInternalError(e);
|
|
148
196
|
return json({ message: 'Internal server error', status: 500 }, {
|
|
149
197
|
status: 500,
|
|
198
|
+
headers: (_f = effectiveCorsSettings) !== null && _f !== void 0 ? _f : {},
|
|
150
199
|
});
|
|
151
200
|
}
|
|
152
201
|
finally {
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export declare const METHOD: RegExp;
|
|
2
|
+
export declare const MAX_BODY_SIZE_DEFAULT: number;
|
|
2
3
|
export declare const CORS_DEFAULT: {
|
|
3
|
-
readonly
|
|
4
|
-
readonly
|
|
5
|
-
readonly
|
|
4
|
+
readonly 'Access-Control-Allow-Origin': "*";
|
|
5
|
+
readonly 'Access-Control-Allow-Methods': "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD";
|
|
6
|
+
readonly 'Access-Control-Allow-Headers': "Content-Type, Authorization";
|
|
6
7
|
};
|
package/dist/constants.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
export const METHOD = /^(GET|POST|PUT|DELETE|PATCH|OPTIONS)$/i;
|
|
1
|
+
export const METHOD = /^(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)$/i;
|
|
2
|
+
export const MAX_BODY_SIZE_DEFAULT = 1024 * 1024 * 1024; // 1 GB
|
|
2
3
|
export const CORS_DEFAULT = {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
'Access-Control-Allow-Origin': '*',
|
|
5
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD',
|
|
6
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
6
7
|
};
|
package/dist/endpoint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EndpointHandler, EndpointSpecification, APIEndpoint, ParamType, QueryType, InputType, OutputType, StreamType, ErrorType } from
|
|
1
|
+
import type { EndpointHandler, EndpointSpecification, APIEndpoint, ParamType, QueryType, InputType, OutputType, StreamType, ErrorType } from './types';
|
|
2
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
3
|
handler: (handle: EndpointHandler<P, Q, I, O>) => APIEndpoint<P, Q, I, O, S, E>;
|
|
4
4
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
export * from
|
|
2
|
-
export * from
|
|
3
|
-
export * from
|
|
4
|
-
export * from
|
|
5
|
-
export * from
|
|
6
|
-
export * from
|
|
1
|
+
export * from './api';
|
|
2
|
+
export * from './openapi';
|
|
3
|
+
export * from './zod';
|
|
4
|
+
export * from './adapters';
|
|
5
|
+
export * from './types';
|
|
6
|
+
export * from './endpoint';
|
|
7
|
+
export * from './errors';
|
|
8
|
+
export * from './utils';
|
|
9
|
+
export * from './parser';
|
|
10
|
+
export * from './constants';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
export * from
|
|
2
|
-
export * from
|
|
3
|
-
export * from
|
|
4
|
-
export * from
|
|
5
|
-
export * from
|
|
6
|
-
export * from
|
|
1
|
+
export * from './api.js';
|
|
2
|
+
export * from './openapi.js';
|
|
3
|
+
export * from './zod.js';
|
|
4
|
+
export * from './adapters.js';
|
|
5
|
+
export * from './types.js';
|
|
6
|
+
export * from './endpoint.js';
|
|
7
|
+
export * from './errors.js';
|
|
8
|
+
export * from './utils.js';
|
|
9
|
+
export * from './parser.js';
|
|
10
|
+
export * from './constants.js';
|
package/dist/log.d.ts
CHANGED
package/dist/log.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import debug from
|
|
2
|
-
export const log = debug(
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
export const log = debug('riddl');
|
|
3
3
|
/** Always-on stderr log for internal errors (independent of DEBUG). */
|
|
4
4
|
export const logError = (message, error) => {
|
|
5
5
|
console.error(`riddl: ${message}`, error);
|
package/dist/multipart.d.ts
CHANGED
|
@@ -18,8 +18,13 @@ export type FormBody = Record<string, FormValue | FormValue[]>;
|
|
|
18
18
|
* Returns the parsed body together with the temp paths the caller must delete
|
|
19
19
|
* once the handler has finished reading the files — see
|
|
20
20
|
* {@link cleanupTempFiles}.
|
|
21
|
+
*
|
|
22
|
+
* @param req The incoming Request.
|
|
23
|
+
* @param maxBodySize Maximum total bytes to accept (content-length is checked
|
|
24
|
+
* in the caller; individual file sizes are also bounded by
|
|
25
|
+
* busboy's `fileSize` limit as a backstop).
|
|
21
26
|
*/
|
|
22
|
-
export declare const parseMultipart: (req: Request) => Promise<{
|
|
27
|
+
export declare const parseMultipart: (req: Request, maxBodySize?: number) => Promise<{
|
|
23
28
|
body: FormBody;
|
|
24
29
|
tempFiles: string[];
|
|
25
30
|
}>;
|
package/dist/multipart.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import busboy from
|
|
2
|
-
import { randomBytes } from
|
|
3
|
-
import { createWriteStream, openAsBlob } from
|
|
4
|
-
import { unlink } from
|
|
5
|
-
import { tmpdir } from
|
|
6
|
-
import { join } from
|
|
7
|
-
import { Readable } from
|
|
8
|
-
import { pipeline } from
|
|
1
|
+
import busboy from 'busboy';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { createWriteStream, openAsBlob } from 'node:fs';
|
|
4
|
+
import { unlink } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { Readable } from 'node:stream';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
|
+
import { MAX_BODY_SIZE_DEFAULT } from './constants.js';
|
|
10
|
+
import { error } from './errors.js';
|
|
9
11
|
const addEntry = (body, name, value) => {
|
|
10
12
|
const existing = body[name];
|
|
11
13
|
if (existing === undefined) {
|
|
@@ -27,32 +29,54 @@ const addEntry = (body, name, value) => {
|
|
|
27
29
|
* Returns the parsed body together with the temp paths the caller must delete
|
|
28
30
|
* once the handler has finished reading the files — see
|
|
29
31
|
* {@link cleanupTempFiles}.
|
|
32
|
+
*
|
|
33
|
+
* @param req The incoming Request.
|
|
34
|
+
* @param maxBodySize Maximum total bytes to accept (content-length is checked
|
|
35
|
+
* in the caller; individual file sizes are also bounded by
|
|
36
|
+
* busboy's `fileSize` limit as a backstop).
|
|
30
37
|
*/
|
|
31
|
-
export const parseMultipart = async (req) => {
|
|
32
|
-
const contentType = req.headers.get(
|
|
38
|
+
export const parseMultipart = async (req, maxBodySize = MAX_BODY_SIZE_DEFAULT) => {
|
|
39
|
+
const contentType = req.headers.get('content-type');
|
|
33
40
|
if (!req.body || !contentType) {
|
|
34
41
|
return { body: {}, tempFiles: [] };
|
|
35
42
|
}
|
|
43
|
+
// Check Content-Length first to reject oversized requests early
|
|
44
|
+
const contentLength = req.headers.get('content-length');
|
|
45
|
+
if (contentLength) {
|
|
46
|
+
const size = parseInt(contentLength, 10);
|
|
47
|
+
if (!Number.isNaN(size) && size > maxBodySize) {
|
|
48
|
+
throw error(413, 'Request body too large');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
36
51
|
const tempFiles = [];
|
|
37
52
|
const body = {};
|
|
38
53
|
await new Promise((resolve, reject) => {
|
|
39
|
-
const bb = busboy({
|
|
54
|
+
const bb = busboy({
|
|
55
|
+
headers: { 'content-type': contentType },
|
|
56
|
+
limits: { fileSize: maxBodySize },
|
|
57
|
+
});
|
|
40
58
|
// File parts finish writing asynchronously; `close` can fire before the
|
|
41
59
|
// last temp file is fully flushed, so we wait on these before resolving.
|
|
42
60
|
const pending = [];
|
|
43
|
-
bb.on(
|
|
44
|
-
bb.on(
|
|
45
|
-
const tempPath = join(tmpdir(), `riddl-upload-${randomBytes(16).toString(
|
|
61
|
+
bb.on('field', (name, value) => addEntry(body, name, value));
|
|
62
|
+
bb.on('file', (name, stream, info) => {
|
|
63
|
+
const tempPath = join(tmpdir(), `riddl-upload-${randomBytes(16).toString('hex')}`);
|
|
46
64
|
tempFiles.push(tempPath);
|
|
47
|
-
pending.push(pipeline(stream, createWriteStream(tempPath))
|
|
65
|
+
pending.push(pipeline(stream, createWriteStream(tempPath))
|
|
66
|
+
.then(async () => {
|
|
48
67
|
const blob = await openAsBlob(tempPath, {
|
|
49
68
|
type: info.mimeType,
|
|
50
69
|
});
|
|
51
70
|
addEntry(body, name, Object.assign(blob, { name: info.filename }));
|
|
71
|
+
})
|
|
72
|
+
.catch((err) => {
|
|
73
|
+
// Abort the whole parse on oversized file — clean up
|
|
74
|
+
// any temp files already created.
|
|
75
|
+
Promise.all(tempFiles.map((p) => unlink(p).catch(() => undefined))).finally(() => reject(err));
|
|
52
76
|
}));
|
|
53
77
|
});
|
|
54
|
-
bb.on(
|
|
55
|
-
bb.on(
|
|
78
|
+
bb.on('error', reject);
|
|
79
|
+
bb.on('close', () => {
|
|
56
80
|
Promise.all(pending)
|
|
57
81
|
.then(() => resolve())
|
|
58
82
|
.catch(reject);
|
package/dist/openapi.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { OpenAPIRegistry } from
|
|
2
|
-
import type { OpenAPIOptions, OpenAPIObjectConfig } from
|
|
1
|
+
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
|
|
2
|
+
import type { OpenAPIOptions, OpenAPIObjectConfig } from './types';
|
|
3
3
|
export declare const registerRoutes: ({ routes, registry, base, }: OpenAPIOptions) => void;
|
|
4
4
|
export declare const openapiHandler: (registry: OpenAPIRegistry, config: OpenAPIObjectConfig) => (request: Request) => Promise<Response>;
|
package/dist/openapi.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import { OpenApiGeneratorV3, } from
|
|
2
|
-
import { z } from
|
|
3
|
-
const openApiPath = (id, base) => id
|
|
4
|
-
.slice(0, -1)
|
|
5
|
-
.join("/")
|
|
1
|
+
import { OpenApiGeneratorV3, } from '@asteasolutions/zod-to-openapi';
|
|
2
|
+
import { z } from './zod.js';
|
|
3
|
+
const openApiPath = (id, base) => id
|
|
6
4
|
.replace(/^\./, base)
|
|
7
|
-
.replace(/\[(\.{3})?(.+?)\]/g,
|
|
8
|
-
.replace(/\(.+\)\//g,
|
|
9
|
-
export const registerRoutes = ({ routes, registry, base =
|
|
5
|
+
.replace(/\[(\.{3})?(.+?)\]/g, '{$2}')
|
|
6
|
+
.replace(/\(.+\)\//g, '');
|
|
7
|
+
export const registerRoutes = ({ routes, registry, base = '', }) => {
|
|
10
8
|
for (const [id, { handlers }] of Object.entries(routes)) {
|
|
11
9
|
Object.entries(handlers).forEach(([methodString, module]) => {
|
|
12
10
|
var _a;
|
|
@@ -16,6 +14,31 @@ export const registerRoutes = ({ routes, registry, base = "", }) => {
|
|
|
16
14
|
const body = module.Input || undefined;
|
|
17
15
|
const path = openApiPath(id, base);
|
|
18
16
|
const method = methodString.toLowerCase();
|
|
17
|
+
// Merge Output and Stream into a single 200 response with both
|
|
18
|
+
// content types rather than overwriting one with the other.
|
|
19
|
+
const hasOutput = !!module.Output;
|
|
20
|
+
const hasStream = !!module.Stream;
|
|
21
|
+
const response200 = hasOutput || hasStream
|
|
22
|
+
? {
|
|
23
|
+
description: '',
|
|
24
|
+
content: {
|
|
25
|
+
...(hasOutput
|
|
26
|
+
? {
|
|
27
|
+
'application/json': {
|
|
28
|
+
schema: module.Output,
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
: undefined),
|
|
32
|
+
...(hasStream
|
|
33
|
+
? {
|
|
34
|
+
'text/event-stream': {
|
|
35
|
+
schema: module.Stream,
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
: undefined),
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
: undefined;
|
|
19
42
|
registry.registerPath(modifier({
|
|
20
43
|
method,
|
|
21
44
|
path,
|
|
@@ -24,15 +47,15 @@ export const registerRoutes = ({ routes, registry, base = "", }) => {
|
|
|
24
47
|
query,
|
|
25
48
|
body: body
|
|
26
49
|
? {
|
|
27
|
-
description:
|
|
50
|
+
description: '',
|
|
28
51
|
content: {
|
|
29
|
-
|
|
52
|
+
'application/json': {
|
|
30
53
|
schema: body,
|
|
31
54
|
},
|
|
32
|
-
|
|
55
|
+
'application/x-www-form-urlencoded': {
|
|
33
56
|
schema: body,
|
|
34
57
|
},
|
|
35
|
-
|
|
58
|
+
'multipart/form-data': {
|
|
36
59
|
schema: body,
|
|
37
60
|
},
|
|
38
61
|
},
|
|
@@ -40,36 +63,13 @@ export const registerRoutes = ({ routes, registry, base = "", }) => {
|
|
|
40
63
|
: undefined,
|
|
41
64
|
},
|
|
42
65
|
responses: {
|
|
43
|
-
...(
|
|
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),
|
|
66
|
+
...(response200 ? { '200': response200 } : undefined),
|
|
67
67
|
...(query || module.Param || body
|
|
68
68
|
? {
|
|
69
|
-
|
|
70
|
-
description:
|
|
69
|
+
'400': {
|
|
70
|
+
description: 'Invalid input (path parameters, query string, or body)',
|
|
71
71
|
content: {
|
|
72
|
-
|
|
72
|
+
'application/json': {
|
|
73
73
|
schema: z.object({
|
|
74
74
|
message: z.string(),
|
|
75
75
|
}),
|
|
@@ -91,23 +91,37 @@ export const registerRoutes = ({ routes, registry, base = "", }) => {
|
|
|
91
91
|
};
|
|
92
92
|
export const openapiHandler = (registry, config) => {
|
|
93
93
|
let document = undefined;
|
|
94
|
+
let cachedOrigin = undefined;
|
|
94
95
|
return async (request) => {
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
const origin = new URL(request.url).origin;
|
|
97
|
+
// Regenerate the document if the origin changes (e.g., behind a
|
|
98
|
+
// load balancer with different hostnames over time).
|
|
99
|
+
if (!document || cachedOrigin !== origin) {
|
|
97
100
|
const generator = new OpenApiGeneratorV3(registry.definitions);
|
|
98
101
|
document = generator.generateDocument({
|
|
99
102
|
servers: [
|
|
100
103
|
{
|
|
101
|
-
url,
|
|
104
|
+
url: origin,
|
|
102
105
|
},
|
|
103
106
|
],
|
|
104
107
|
...config,
|
|
105
108
|
});
|
|
109
|
+
cachedOrigin = origin;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return new Response(JSON.stringify(document), {
|
|
113
|
+
headers: {
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return new Response(JSON.stringify({ error: 'Failed to serialize OpenAPI document' }), {
|
|
120
|
+
status: 500,
|
|
121
|
+
headers: {
|
|
122
|
+
'Content-Type': 'application/json',
|
|
123
|
+
},
|
|
124
|
+
});
|
|
106
125
|
}
|
|
107
|
-
return new Response(JSON.stringify(document), {
|
|
108
|
-
headers: {
|
|
109
|
-
"Content-Type": "application/json",
|
|
110
|
-
},
|
|
111
|
-
});
|
|
112
126
|
};
|
|
113
127
|
};
|
package/dist/parser.d.ts
CHANGED
|
@@ -1,29 +1,11 @@
|
|
|
1
|
-
import type { RouteParam } from
|
|
1
|
+
import type { RouteParam } from './types';
|
|
2
2
|
export declare const parseRouteId: (id: string) => {
|
|
3
3
|
pattern: RegExp;
|
|
4
4
|
params: RouteParam[];
|
|
5
5
|
};
|
|
6
|
-
/**
|
|
7
|
-
* Removes optional params from a route ID.
|
|
8
|
-
*/
|
|
9
|
-
export declare const removeOptionalParams: (id: string) => string;
|
|
10
6
|
/**
|
|
11
7
|
* Splits a route id into its segments, removing segments that
|
|
12
8
|
* don't affect the path (i.e. groups). The root route is represented by `/`
|
|
13
9
|
* and will be returned as `['']`.
|
|
14
10
|
*/
|
|
15
11
|
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
CHANGED
|
@@ -13,7 +13,7 @@ export const parseRouteId = (id) => {
|
|
|
13
13
|
matcher: restMatch[2],
|
|
14
14
|
optional: false,
|
|
15
15
|
rest: true,
|
|
16
|
-
chained: true
|
|
16
|
+
chained: true,
|
|
17
17
|
});
|
|
18
18
|
return '(?:/([^]*))?';
|
|
19
19
|
}
|
|
@@ -25,7 +25,7 @@ export const parseRouteId = (id) => {
|
|
|
25
25
|
matcher: optionalMatch[2],
|
|
26
26
|
optional: true,
|
|
27
27
|
rest: false,
|
|
28
|
-
chained: true
|
|
28
|
+
chained: true,
|
|
29
29
|
});
|
|
30
30
|
return '(?:/([^/]+))?';
|
|
31
31
|
}
|
|
@@ -45,7 +45,7 @@ export const parseRouteId = (id) => {
|
|
|
45
45
|
.split('-')
|
|
46
46
|
.map((code) => parseInt(code, 16))));
|
|
47
47
|
}
|
|
48
|
-
const match =
|
|
48
|
+
const match = paramPattern.exec(content);
|
|
49
49
|
if (!match) {
|
|
50
50
|
throw new Error(`Invalid param: ${content}. Params and matcher names can only have underscores and alphanumeric characters.`);
|
|
51
51
|
}
|
|
@@ -58,9 +58,13 @@ export const parseRouteId = (id) => {
|
|
|
58
58
|
matcher,
|
|
59
59
|
optional: !!isOptional,
|
|
60
60
|
rest: !!isRest,
|
|
61
|
-
chained: isRest ? i === 1 && parts[0] === '' : false
|
|
61
|
+
chained: isRest ? i === 1 && parts[0] === '' : false,
|
|
62
62
|
});
|
|
63
|
-
return isRest
|
|
63
|
+
return isRest
|
|
64
|
+
? '([^]*?)'
|
|
65
|
+
: isOptional
|
|
66
|
+
? '([^/]*)?'
|
|
67
|
+
: '([^/]+?)';
|
|
64
68
|
}
|
|
65
69
|
return escape(content);
|
|
66
70
|
})
|
|
@@ -70,16 +74,6 @@ export const parseRouteId = (id) => {
|
|
|
70
74
|
.join('')}/?$`);
|
|
71
75
|
return { pattern, params };
|
|
72
76
|
};
|
|
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
77
|
function affectsPath(segment) {
|
|
84
78
|
return segment !== '' && !/^\([^)]+\)$/.test(segment);
|
|
85
79
|
}
|
|
@@ -104,40 +98,3 @@ const escape = (str) => {
|
|
|
104
98
|
// escape characters that have special meaning in regex
|
|
105
99
|
.replace(/[.*+?^${}()|\\]/g, '\\$&'));
|
|
106
100
|
};
|
|
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
|
-
};
|
package/dist/routing.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { MiddlewareMap, RouteModuleMap, RouteParam } from
|
|
2
|
-
export declare const buildRouter: (routesMap: RouteModuleMap, middlewaresMap: MiddlewareMap) => (path: string) => {
|
|
1
|
+
import type { MiddlewareMap, ParamMatcher, RouteModuleMap, RouteParam } from './types';
|
|
2
|
+
export declare const buildRouter: (routesMap: RouteModuleMap, middlewaresMap: MiddlewareMap, matchers?: Record<string, ParamMatcher>) => (path: string) => {
|
|
3
3
|
id: string;
|
|
4
4
|
params: Record<string, string> | undefined;
|
|
5
5
|
routeWithMiddlewares: {
|
|
@@ -8,7 +8,7 @@ export declare const buildRouter: (routesMap: RouteModuleMap, middlewaresMap: Mi
|
|
|
8
8
|
pattern: RegExp;
|
|
9
9
|
params: RouteParam[];
|
|
10
10
|
};
|
|
11
|
-
handlers: Record<import("./types").Method, import("./types").GenericApiEndpoint
|
|
11
|
+
handlers: Partial<Record<import("./types").Method, import("./types").GenericApiEndpoint>>;
|
|
12
12
|
id: string;
|
|
13
13
|
};
|
|
14
14
|
} | undefined;
|
package/dist/routing.js
CHANGED
|
@@ -25,13 +25,18 @@ const exec = (match, params, matchers) => {
|
|
|
25
25
|
continue;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
-
if (!param.matcher ||
|
|
28
|
+
if (!param.matcher ||
|
|
29
|
+
(matchers[param.matcher] && matchers[param.matcher](value))) {
|
|
29
30
|
result[param.name] = value;
|
|
30
31
|
// Now that the params match, reset the buffer if the next param isn't the [...rest]
|
|
31
32
|
// and the next value is defined, otherwise the buffer will cause us to skip values
|
|
32
33
|
const nextParam = params[i + 1];
|
|
33
34
|
const nextValue = values[i + 1];
|
|
34
|
-
if (nextParam &&
|
|
35
|
+
if (nextParam &&
|
|
36
|
+
!nextParam.rest &&
|
|
37
|
+
nextParam.optional &&
|
|
38
|
+
nextValue &&
|
|
39
|
+
param.chained) {
|
|
35
40
|
buffered = 0;
|
|
36
41
|
}
|
|
37
42
|
// There are no more params and no more values, but all non-empty values have been matched
|
|
@@ -67,19 +72,27 @@ const routeSorter = (a, b) => {
|
|
|
67
72
|
return byParams;
|
|
68
73
|
return b.id.length - a.id.length;
|
|
69
74
|
};
|
|
70
|
-
export const buildRouter = (routesMap, middlewaresMap) => {
|
|
75
|
+
export const buildRouter = (routesMap, middlewaresMap, matchers = {}) => {
|
|
71
76
|
const middlewareEntries = Object.entries(middlewaresMap).sort((a, b) => a[0].length - b[0].length);
|
|
72
|
-
const routesWithMiddlewaresList = Object.entries(routesMap)
|
|
77
|
+
const routesWithMiddlewaresList = Object.entries(routesMap)
|
|
78
|
+
.map(([id, route]) => ({
|
|
73
79
|
id,
|
|
74
80
|
...route,
|
|
75
|
-
middlewares: middlewareEntries
|
|
76
|
-
|
|
81
|
+
middlewares: middlewareEntries
|
|
82
|
+
.filter(([middlewareId]) => id.startsWith(middlewareId))
|
|
83
|
+
.map(([, middleware]) => middleware),
|
|
84
|
+
}))
|
|
85
|
+
.sort(routeSorter);
|
|
77
86
|
return (path) => {
|
|
78
87
|
for (const route of routesWithMiddlewaresList) {
|
|
79
88
|
const { pattern } = route.matcher;
|
|
80
89
|
const matches = pattern.exec(path);
|
|
81
90
|
if (matches) {
|
|
82
|
-
return {
|
|
91
|
+
return {
|
|
92
|
+
id: route.id,
|
|
93
|
+
params: exec(matches, route.matcher.params, matchers),
|
|
94
|
+
routeWithMiddlewares: route,
|
|
95
|
+
};
|
|
83
96
|
}
|
|
84
97
|
}
|
|
85
98
|
return undefined;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { RouteConfig, OpenAPIRegistry } from
|
|
2
|
-
import type { OpenAPIObjectConfig } from
|
|
3
|
-
import type { z } from
|
|
4
|
-
export type { OpenAPIObject } from
|
|
5
|
-
export type { OpenAPIObjectConfig } from
|
|
6
|
-
export type Method =
|
|
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' | 'HEAD';
|
|
7
7
|
export interface HttpError {
|
|
8
8
|
status: number;
|
|
9
9
|
key?: string;
|
|
@@ -26,7 +26,7 @@ export interface ApiRoute {
|
|
|
26
26
|
pattern: RegExp;
|
|
27
27
|
params: RouteParam[];
|
|
28
28
|
};
|
|
29
|
-
handlers: Record<Method, GenericApiEndpoint
|
|
29
|
+
handlers: Partial<Record<Method, GenericApiEndpoint>>;
|
|
30
30
|
}
|
|
31
31
|
export interface ApiRouteWithMiddlewares extends ApiRoute {
|
|
32
32
|
middlewares: Middleware[];
|
|
@@ -36,7 +36,7 @@ export type RouteModuleMap = Record<string, {
|
|
|
36
36
|
pattern: RegExp;
|
|
37
37
|
params: RouteParam[];
|
|
38
38
|
};
|
|
39
|
-
handlers: Record<Method, GenericApiEndpoint
|
|
39
|
+
handlers: Partial<Record<Method, GenericApiEndpoint>>;
|
|
40
40
|
}>;
|
|
41
41
|
export type RouteWithMiddlewaresMap = Record<string, ApiRouteWithMiddlewares>;
|
|
42
42
|
export type MiddlewareMap = Record<string, Middleware>;
|
|
@@ -66,9 +66,9 @@ export interface APIContext {
|
|
|
66
66
|
*/
|
|
67
67
|
export type EndpointModifier = (r: RouteConfig) => RouteConfig;
|
|
68
68
|
export interface CORS {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
'Access-Control-Allow-Origin': string;
|
|
70
|
+
'Access-Control-Allow-Methods': string;
|
|
71
|
+
'Access-Control-Allow-Headers': string;
|
|
72
72
|
}
|
|
73
73
|
export interface APIOptions {
|
|
74
74
|
routeModuleMap: Record<string, () => Promise<unknown>>;
|
|
@@ -78,6 +78,10 @@ export interface APIOptions {
|
|
|
78
78
|
registry?: OpenAPIRegistry;
|
|
79
79
|
/** Called for unexpected throws and HTTP errors with status >= 500. */
|
|
80
80
|
onInternalError?: (error: unknown) => void;
|
|
81
|
+
/** Custom parameter matchers, keyed by name. Used in `[param=matcher]` route syntax. */
|
|
82
|
+
matchers?: Record<string, ParamMatcher>;
|
|
83
|
+
/** Maximum request body size in bytes. Defaults to 1 GB. */
|
|
84
|
+
maxBodySize?: number;
|
|
81
85
|
}
|
|
82
86
|
export interface OpenAPIOptions {
|
|
83
87
|
base?: string;
|
|
@@ -85,7 +89,7 @@ export interface OpenAPIOptions {
|
|
|
85
89
|
registry: OpenAPIRegistry;
|
|
86
90
|
}
|
|
87
91
|
export interface EndpointHandler<P, Q, I, O> {
|
|
88
|
-
({ body, query, param, event }: {
|
|
92
|
+
({ body, query, param, event, }: {
|
|
89
93
|
body: z.infer<I>;
|
|
90
94
|
query: z.infer<Q>;
|
|
91
95
|
param: z.infer<P>;
|
|
@@ -114,9 +118,9 @@ export interface APIEndpoint<P extends ParamType = z.ZodObject<Record<never, nev
|
|
|
114
118
|
handle?: EndpointHandler<P, Q, I, O>;
|
|
115
119
|
}
|
|
116
120
|
export interface EndpointBuilder<P extends ParamType, Q extends QueryType, I extends InputType, O extends OutputType, S extends StreamType, E extends ErrorType> {
|
|
117
|
-
(spec: EndpointSpecification<P, Q, I, O, S, E>):
|
|
121
|
+
(spec: EndpointSpecification<P, Q, I, O, S, E>): {
|
|
118
122
|
handler: () => APIEndpoint<P, Q, I, O, S, E>;
|
|
119
|
-
}
|
|
123
|
+
};
|
|
120
124
|
}
|
|
121
125
|
export interface EndpointStreamSetter<P extends ParamType, Q extends QueryType, I extends InputType, O extends OutputType, S extends StreamType, E extends ErrorType> {
|
|
122
126
|
(data: APIEndpoint<P, Q, I, O, S, E>): EndpointBuilder<P, Q, I, O, S, E>;
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { IncomingMessage } from
|
|
2
|
-
import type { Request as ExpressRequest } from
|
|
1
|
+
import type { IncomingMessage } from 'http';
|
|
2
|
+
import type { Request as ExpressRequest } from 'express';
|
|
3
3
|
export declare const recursiveAwait: (obj: unknown) => unknown;
|
|
4
4
|
export declare const validateRouteId: (id: string) => void;
|
|
5
5
|
export declare const toFetchRequest: (req: IncomingMessage | ExpressRequest) => Request;
|
package/dist/utils.js
CHANGED
|
@@ -8,6 +8,20 @@ export const recursiveAwait = (obj) => {
|
|
|
8
8
|
// dates become `{}` in API responses after the plain-object walk below.
|
|
9
9
|
return obj;
|
|
10
10
|
}
|
|
11
|
+
else if (obj instanceof Map) {
|
|
12
|
+
// `Map` extends `Object`, but `Object.keys(new Map())` is [] — without this branch,
|
|
13
|
+
// maps become `{}` in API responses after the plain-object walk below.
|
|
14
|
+
const entries = Array.from(obj.entries());
|
|
15
|
+
if (entries.length === 0)
|
|
16
|
+
return {};
|
|
17
|
+
return Promise.all(entries.map(async ([k, v]) => [await recursiveAwait(k), await recursiveAwait(v)])).then((resolved) => Object.fromEntries(resolved));
|
|
18
|
+
}
|
|
19
|
+
else if (obj instanceof Set) {
|
|
20
|
+
// `Set` extends `Object`, but `Object.keys(new Set())` is [] — without this branch,
|
|
21
|
+
// sets become `{}` in API responses after the plain-object walk below.
|
|
22
|
+
const values = Array.from(obj.values());
|
|
23
|
+
return Promise.all(values.map(recursiveAwait));
|
|
24
|
+
}
|
|
11
25
|
else if (obj instanceof Array) {
|
|
12
26
|
return Promise.all(obj.map(recursiveAwait));
|
|
13
27
|
}
|
|
@@ -48,21 +62,21 @@ export const validateRouteId = (id) => {
|
|
|
48
62
|
throw new Error(`Invalid route ${id} — a rest route segment is always optional, remove the outer square brackets`);
|
|
49
63
|
}
|
|
50
64
|
};
|
|
51
|
-
const isNodeReadable = (value) => !!value && typeof value.pipe ===
|
|
65
|
+
const isNodeReadable = (value) => !!value && typeof value.pipe === 'function';
|
|
52
66
|
const isBodyInit = (value) => {
|
|
53
|
-
if (typeof value ===
|
|
67
|
+
if (typeof value === 'string')
|
|
54
68
|
return true;
|
|
55
69
|
if (value instanceof ArrayBuffer)
|
|
56
70
|
return true;
|
|
57
71
|
if (ArrayBuffer.isView(value))
|
|
58
72
|
return true;
|
|
59
|
-
if (typeof Blob !==
|
|
73
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob)
|
|
60
74
|
return true;
|
|
61
|
-
if (typeof FormData !==
|
|
75
|
+
if (typeof FormData !== 'undefined' && value instanceof FormData)
|
|
62
76
|
return true;
|
|
63
77
|
if (value instanceof URLSearchParams)
|
|
64
78
|
return true;
|
|
65
|
-
if (typeof ReadableStream !==
|
|
79
|
+
if (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream)
|
|
66
80
|
return true;
|
|
67
81
|
if (isNodeReadable(value))
|
|
68
82
|
return true;
|
|
@@ -73,42 +87,52 @@ const coerceBody = (body, headers) => {
|
|
|
73
87
|
return undefined;
|
|
74
88
|
if (isBodyInit(body))
|
|
75
89
|
return body;
|
|
90
|
+
// At this point, `body` is a plain object (e.g., from Express's body
|
|
91
|
+
// parser). We JSON-stringify it so the Fetch Request can carry it as
|
|
92
|
+
// a JSON string body. Note: if Express's body parser was used, this
|
|
93
|
+
// means the body is serialized here and re-parsed in `parseBody` —
|
|
94
|
+
// a minor double-processing overhead. To avoid this, don't use
|
|
95
|
+
// Express body-parser middleware on riddl-mounted routes.
|
|
76
96
|
const json = JSON.stringify(body);
|
|
77
|
-
if (!headers.has(
|
|
78
|
-
headers.set(
|
|
97
|
+
if (!headers.has('content-type')) {
|
|
98
|
+
headers.set('content-type', 'application/json; charset=utf-8');
|
|
79
99
|
}
|
|
80
100
|
return json;
|
|
81
101
|
};
|
|
82
102
|
const getRequestUrl = (req) => {
|
|
83
103
|
var _a;
|
|
84
|
-
const rawUrl =
|
|
104
|
+
const rawUrl = 'originalUrl' in req ? req.originalUrl || req.url || '/' : req.url || '/';
|
|
85
105
|
if (/^https?:\/\//i.test(rawUrl))
|
|
86
106
|
return new URL(rawUrl);
|
|
87
|
-
if (
|
|
88
|
-
return new URL(rawUrl, `${req.protocol}://${req.get(
|
|
107
|
+
if ('protocol' in req) {
|
|
108
|
+
return new URL(rawUrl, `${req.protocol}://${req.get('host')}`);
|
|
89
109
|
}
|
|
90
|
-
const host = (_a = req.headers.host) !== null && _a !== void 0 ? _a :
|
|
91
|
-
const forwardedProto = req.headers[
|
|
110
|
+
const host = (_a = req.headers.host) !== null && _a !== void 0 ? _a : 'localhost';
|
|
111
|
+
const forwardedProto = req.headers['x-forwarded-proto'];
|
|
92
112
|
const proto = Array.isArray(forwardedProto)
|
|
93
113
|
? forwardedProto[0]
|
|
94
114
|
: forwardedProto;
|
|
95
|
-
const protocol = (proto === null || proto === void 0 ? void 0 : proto.split(
|
|
115
|
+
const protocol = (proto === null || proto === void 0 ? void 0 : proto.split(',')[0].trim()) || 'http';
|
|
96
116
|
return new URL(rawUrl, `${protocol}://${host}`);
|
|
97
117
|
};
|
|
98
118
|
export const toFetchRequest = (req) => {
|
|
99
119
|
const headers = new Headers();
|
|
100
120
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
101
121
|
if (Array.isArray(value))
|
|
102
|
-
headers.append(key, value.join(
|
|
122
|
+
headers.append(key, value.join(','));
|
|
103
123
|
else if (value != null)
|
|
104
124
|
headers.set(key, value);
|
|
105
125
|
}
|
|
106
|
-
const method = req.method ||
|
|
126
|
+
const method = req.method || 'GET';
|
|
127
|
+
// `rawBody` is set by some Express body-parsing middleware (e.g.,
|
|
128
|
+
// express.json()). If the middleware isn't present, `rawBody` is
|
|
129
|
+
// undefined and we fall through to using the parsed body or the raw
|
|
130
|
+
// stream itself.
|
|
107
131
|
const expressReq = req;
|
|
108
|
-
const parsedBody =
|
|
109
|
-
const rawBody =
|
|
132
|
+
const parsedBody = 'body' in expressReq ? expressReq.body : undefined;
|
|
133
|
+
const rawBody = 'rawBody' in expressReq ? expressReq.rawBody : undefined;
|
|
110
134
|
let body;
|
|
111
|
-
if (method !==
|
|
135
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
112
136
|
body = coerceBody(rawBody !== null && rawBody !== void 0 ? rawBody : parsedBody, headers);
|
|
113
137
|
if (body === undefined && isNodeReadable(req) && !req.readableEnded) {
|
|
114
138
|
body = req;
|
|
@@ -120,7 +144,7 @@ export const toFetchRequest = (req) => {
|
|
|
120
144
|
body: body,
|
|
121
145
|
};
|
|
122
146
|
if (isNodeReadable(body)) {
|
|
123
|
-
init.duplex =
|
|
147
|
+
init.duplex = 'half'; // required when body is a stream
|
|
124
148
|
}
|
|
125
149
|
return new Request(getRequestUrl(req).toString(), init);
|
|
126
150
|
};
|
package/dist/validation.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { GenericApiEndpoint } from './types';
|
|
2
|
-
export declare const parseBody: (req: Request, module: GenericApiEndpoint, tempFiles?: string[]) => Promise<unknown>;
|
|
3
|
-
export declare const parseQuery: (request: Request, module: GenericApiEndpoint) =>
|
|
4
|
-
export declare const parseParam: (params: Record<string, string>, module: GenericApiEndpoint) =>
|
|
1
|
+
import type { GenericApiEndpoint } from './types';
|
|
2
|
+
export declare const parseBody: (req: Request, module: GenericApiEndpoint, tempFiles?: string[], maxBodySize?: number) => Promise<unknown>;
|
|
3
|
+
export declare const parseQuery: (request: Request, module: GenericApiEndpoint) => Record<string, string | string[]>;
|
|
4
|
+
export declare const parseParam: (params: Record<string, string>, module: GenericApiEndpoint) => Record<string, string | never>;
|
|
5
5
|
export declare const parseOutput: (out: unknown, module: GenericApiEndpoint) => Promise<Record<string, unknown> | unknown[]>;
|
package/dist/validation.js
CHANGED
|
@@ -3,22 +3,10 @@ import { log } from './log.js';
|
|
|
3
3
|
import { z } from './zod.js';
|
|
4
4
|
import { error } from './errors.js';
|
|
5
5
|
import { parseMultipart } from './multipart.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
const json = await clonedRequest.json();
|
|
12
|
-
if (typeof json === 'object') {
|
|
13
|
-
return json;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
throw error(400, 'Invalid JSON body');
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
};
|
|
21
|
-
export const parseBody = async (req, module, tempFiles = []) => {
|
|
6
|
+
import { MAX_BODY_SIZE_DEFAULT } from './constants.js';
|
|
7
|
+
/** Cached default schema — avoids recreating z.object({}) on every request. */
|
|
8
|
+
const EMPTY_SCHEMA = z.object({});
|
|
9
|
+
export const parseBody = async (req, module, tempFiles = [], maxBodySize = MAX_BODY_SIZE_DEFAULT) => {
|
|
22
10
|
var _a, _b;
|
|
23
11
|
let body = {};
|
|
24
12
|
// GET, HEAD, DELETE, OPTIONS have no body
|
|
@@ -27,27 +15,32 @@ export const parseBody = async (req, module, tempFiles = []) => {
|
|
|
27
15
|
return body;
|
|
28
16
|
}
|
|
29
17
|
const contentType = (_a = req.headers.get('content-type')) !== null && _a !== void 0 ? _a : '';
|
|
18
|
+
// Enforce body size limit via Content-Length when available
|
|
19
|
+
const contentLength = req.headers.get('content-length');
|
|
20
|
+
if (contentLength) {
|
|
21
|
+
const size = parseInt(contentLength, 10);
|
|
22
|
+
if (!Number.isNaN(size) && size > maxBodySize) {
|
|
23
|
+
throw error(413, 'Request body too large');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// parseBody is the sole consumer of the request body — no clone() needed.
|
|
30
27
|
// JSON body
|
|
31
28
|
if (contentType.startsWith('application/json')) {
|
|
32
|
-
body = await req
|
|
33
|
-
.clone()
|
|
34
|
-
.json()
|
|
35
|
-
.catch(() => {
|
|
29
|
+
body = await req.json().catch(() => {
|
|
36
30
|
throw error(400, 'Invalid JSON body');
|
|
37
31
|
});
|
|
38
32
|
}
|
|
39
33
|
// Multipart form body: stream uploads to disk rather than buffering the
|
|
40
|
-
// whole payload in memory. Consume `req` directly
|
|
41
|
-
// would re-buffer the unread branch, defeating the point).
|
|
34
|
+
// whole payload in memory. Consume `req` directly.
|
|
42
35
|
else if (contentType.startsWith('multipart/form-data')) {
|
|
43
|
-
const result = await parseMultipart(req);
|
|
36
|
+
const result = await parseMultipart(req, maxBodySize);
|
|
44
37
|
tempFiles.push(...result.tempFiles);
|
|
45
38
|
body = result.body;
|
|
46
39
|
}
|
|
47
40
|
// URL-encoded form body (always small — buffering is fine)
|
|
48
41
|
else if (contentType.startsWith('application/x-www-form-urlencoded')) {
|
|
49
42
|
const formBody = {};
|
|
50
|
-
const form = await req.
|
|
43
|
+
const form = await req.formData();
|
|
51
44
|
for (const [key, value] of form.entries()) {
|
|
52
45
|
if (formBody[key]) {
|
|
53
46
|
const existing = formBody[key];
|
|
@@ -66,15 +59,15 @@ export const parseBody = async (req, module, tempFiles = []) => {
|
|
|
66
59
|
}
|
|
67
60
|
// Text body
|
|
68
61
|
else if (contentType.startsWith('text/plain')) {
|
|
69
|
-
body = { text: await req.
|
|
62
|
+
body = { text: await req.text() };
|
|
70
63
|
}
|
|
71
64
|
// Binary body
|
|
72
65
|
else if (contentType.startsWith('application/octet-stream')) {
|
|
73
|
-
const buffer = await req.
|
|
66
|
+
const buffer = await req.arrayBuffer();
|
|
74
67
|
body = { buffer };
|
|
75
68
|
}
|
|
76
69
|
log('body: %O', body);
|
|
77
|
-
const validator = (_b = module.Input) !== null && _b !== void 0 ? _b :
|
|
70
|
+
const validator = (_b = module.Input) !== null && _b !== void 0 ? _b : EMPTY_SCHEMA;
|
|
78
71
|
const validation = validator.safeParse(body);
|
|
79
72
|
if (!validation.success) {
|
|
80
73
|
throw error(400, `Invalid body.\n${fromZodError(validation.error).message}`);
|
|
@@ -84,7 +77,7 @@ export const parseBody = async (req, module, tempFiles = []) => {
|
|
|
84
77
|
}
|
|
85
78
|
return body;
|
|
86
79
|
};
|
|
87
|
-
export const parseQuery =
|
|
80
|
+
export const parseQuery = (request, module) => {
|
|
88
81
|
var _a;
|
|
89
82
|
let query = {};
|
|
90
83
|
for (const [key, value] of new URL(request.url).searchParams.entries()) {
|
|
@@ -102,7 +95,7 @@ export const parseQuery = async (request, module) => {
|
|
|
102
95
|
}
|
|
103
96
|
}
|
|
104
97
|
log('query: %O', query);
|
|
105
|
-
const validator = (_a = module.Query) !== null && _a !== void 0 ? _a :
|
|
98
|
+
const validator = (_a = module.Query) !== null && _a !== void 0 ? _a : EMPTY_SCHEMA;
|
|
106
99
|
const validation = validator.safeParse(query);
|
|
107
100
|
if (!validation.success) {
|
|
108
101
|
throw error(400, `Invalid query.\n${fromZodError(validation.error).message}`);
|
|
@@ -112,10 +105,10 @@ export const parseQuery = async (request, module) => {
|
|
|
112
105
|
}
|
|
113
106
|
return query;
|
|
114
107
|
};
|
|
115
|
-
export const parseParam =
|
|
108
|
+
export const parseParam = (params, module) => {
|
|
116
109
|
var _a;
|
|
117
110
|
log('param: %O', params);
|
|
118
|
-
const validator = (_a = module.Param) !== null && _a !== void 0 ? _a :
|
|
111
|
+
const validator = (_a = module.Param) !== null && _a !== void 0 ? _a : EMPTY_SCHEMA;
|
|
119
112
|
const validation = validator.safeParse(params);
|
|
120
113
|
if (!validation.success) {
|
|
121
114
|
throw error(400, `Invalid param.\n${fromZodError(validation.error).message}`);
|
|
@@ -131,8 +124,8 @@ export const parseOutput = async (out, module) => {
|
|
|
131
124
|
: out && typeof out === 'object'
|
|
132
125
|
? out
|
|
133
126
|
: {};
|
|
134
|
-
const validator = (_a = module.Output) !== null && _a !== void 0 ? _a :
|
|
135
|
-
const validation =
|
|
127
|
+
const validator = (_a = module.Output) !== null && _a !== void 0 ? _a : EMPTY_SCHEMA;
|
|
128
|
+
const validation = validator.safeParse(output);
|
|
136
129
|
if (!validation.success) {
|
|
137
130
|
const details = fromZodError(validation.error).message;
|
|
138
131
|
log.extend('error')('output: %O failed validation: %O', output, validation.error);
|
package/dist/zod.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { z } from
|
|
1
|
+
import { z } from 'zod';
|
|
2
2
|
export { z };
|
package/dist/zod.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riddl/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
"debug": "^4.4.0",
|
|
10
10
|
"express": "^5.2.1",
|
|
11
11
|
"set-cookie-parser": "^3.0.1",
|
|
12
|
-
"sse.js": "^2.5.0",
|
|
13
12
|
"zod": "^4.4.3",
|
|
14
13
|
"zod-validation-error": "^5.0.0"
|
|
15
14
|
},
|
|
@@ -32,17 +31,23 @@
|
|
|
32
31
|
"concurrently": "^9.1.2",
|
|
33
32
|
"eslint": "^9.17.0",
|
|
34
33
|
"eslint-config-prettier": "^9.1.0",
|
|
34
|
+
"@eslint/js": "^9.17.0",
|
|
35
|
+
"eslint-plugin-prettier": "^5.5.1",
|
|
35
36
|
"openapi3-ts": "^4.5.0",
|
|
36
37
|
"prettier": "^3.4.2",
|
|
37
38
|
"tsc-alias": "^1.8.10",
|
|
38
39
|
"type-fest": "^4.31.0",
|
|
39
|
-
"typescript": "^5.7.2"
|
|
40
|
+
"typescript": "^5.7.2",
|
|
41
|
+
"vitest": "^5.0.4"
|
|
40
42
|
},
|
|
41
43
|
"scripts": {
|
|
42
44
|
"lint": "prettier --check . && eslint .",
|
|
43
45
|
"format": "prettier --write .",
|
|
44
46
|
"clean": "rm -f tsconfig.tsbuildinfo dist",
|
|
45
47
|
"build": "tsc && tsc-alias",
|
|
46
|
-
"build:watch": "tsc && tsc-alias && (concurrently \"tsc -w\" \"tsc-alias -w\")"
|
|
48
|
+
"build:watch": "tsc && tsc-alias && (concurrently \"tsc -w\" \"tsc-alias -w\")",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:watch": "vitest",
|
|
51
|
+
"prepublishOnly": "pnpm build"
|
|
47
52
|
}
|
|
48
|
-
}
|
|
53
|
+
}
|
package/dist/middlewares.d.ts
DELETED
|
File without changes
|
package/dist/middlewares.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";
|