shokupan 0.0.1 → 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/README.md +12 -12
- package/dist/analysis/openapi-analyzer.d.ts +142 -0
- package/dist/cli.cjs +62 -2
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +62 -2
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts +48 -3
- package/dist/decorators.d.ts +5 -1
- package/dist/index.cjs +1032 -337
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1032 -337
- package/dist/index.js.map +1 -1
- package/dist/openapi-analyzer-CFqgSLNK.cjs +769 -0
- package/dist/openapi-analyzer-CFqgSLNK.cjs.map +1 -0
- package/dist/openapi-analyzer-cjdGeQ5a.js +769 -0
- package/dist/openapi-analyzer-cjdGeQ5a.js.map +1 -0
- package/dist/plugins/openapi.d.ts +10 -0
- package/dist/plugins/scalar.d.ts +3 -1
- package/dist/plugins/serve-static.d.ts +3 -0
- package/dist/plugins/server-adapter.d.ts +13 -0
- package/dist/router.d.ts +41 -15
- package/dist/shokupan.d.ts +14 -7
- package/dist/symbol.d.ts +2 -0
- package/dist/types.d.ts +108 -1
- package/package.json +5 -2
package/dist/index.cjs
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
const api = require("@opentelemetry/api");
|
|
4
|
-
const exporterTraceOtlpProto = require("@opentelemetry/exporter-trace-otlp-proto");
|
|
5
|
-
const resources = require("@opentelemetry/resources");
|
|
6
|
-
const sdkTraceBase = require("@opentelemetry/sdk-trace-base");
|
|
7
|
-
const sdkTraceNode = require("@opentelemetry/sdk-trace-node");
|
|
8
|
-
const semanticConventions = require("@opentelemetry/semantic-conventions");
|
|
9
4
|
const eta$2 = require("eta");
|
|
10
5
|
const promises = require("fs/promises");
|
|
11
6
|
const path = require("path");
|
|
12
7
|
const node_async_hooks = require("node:async_hooks");
|
|
13
8
|
const arctic = require("arctic");
|
|
14
9
|
const jose = require("jose");
|
|
10
|
+
const openapiAnalyzer = require("./openapi-analyzer-CFqgSLNK.cjs");
|
|
15
11
|
const crypto = require("crypto");
|
|
16
12
|
const events = require("events");
|
|
13
|
+
const classTransformer = require("class-transformer");
|
|
14
|
+
const classValidator = require("class-validator");
|
|
17
15
|
function _interopNamespaceDefault(e) {
|
|
18
16
|
const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
|
|
19
17
|
if (e) {
|
|
@@ -86,8 +84,10 @@ class ShokupanResponse {
|
|
|
86
84
|
}
|
|
87
85
|
}
|
|
88
86
|
class ShokupanContext {
|
|
89
|
-
constructor(request, state) {
|
|
87
|
+
constructor(request, server, state, app) {
|
|
90
88
|
this.request = request;
|
|
89
|
+
this.server = server;
|
|
90
|
+
this.app = app;
|
|
91
91
|
this.url = new URL(request.url);
|
|
92
92
|
this.state = state || {};
|
|
93
93
|
this.response = new ShokupanResponse();
|
|
@@ -120,12 +120,55 @@ class ShokupanContext {
|
|
|
120
120
|
get query() {
|
|
121
121
|
return Object.fromEntries(this.url.searchParams);
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Client IP address
|
|
125
|
+
*/
|
|
126
|
+
get ip() {
|
|
127
|
+
return this.server?.requestIP(this.request);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Request hostname (e.g. "localhost")
|
|
131
|
+
*/
|
|
132
|
+
get hostname() {
|
|
133
|
+
return this.url.hostname;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Request host (e.g. "localhost:3000")
|
|
137
|
+
*/
|
|
138
|
+
get host() {
|
|
139
|
+
return this.url.host;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Request protocol (e.g. "http:", "https:")
|
|
143
|
+
*/
|
|
144
|
+
get protocol() {
|
|
145
|
+
return this.url.protocol;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Whether request is secure (https)
|
|
149
|
+
*/
|
|
150
|
+
get secure() {
|
|
151
|
+
return this.url.protocol === "https:";
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Request origin (e.g. "http://localhost:3000")
|
|
155
|
+
*/
|
|
156
|
+
get origin() {
|
|
157
|
+
return this.url.origin;
|
|
158
|
+
}
|
|
123
159
|
/**
|
|
124
160
|
* Request headers
|
|
125
161
|
*/
|
|
126
162
|
get headers() {
|
|
127
163
|
return this.request.headers;
|
|
128
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Get a request header
|
|
167
|
+
* @param name Header name
|
|
168
|
+
*/
|
|
169
|
+
get(name) {
|
|
170
|
+
return this.request.headers.get(name);
|
|
171
|
+
}
|
|
129
172
|
/**
|
|
130
173
|
* Base response object
|
|
131
174
|
*/
|
|
@@ -134,6 +177,8 @@ class ShokupanContext {
|
|
|
134
177
|
}
|
|
135
178
|
/**
|
|
136
179
|
* Helper to set a header on the response
|
|
180
|
+
* @param key Header key
|
|
181
|
+
* @param value Header value
|
|
137
182
|
*/
|
|
138
183
|
set(key, value) {
|
|
139
184
|
this.response.set(key, value);
|
|
@@ -245,6 +290,23 @@ class ShokupanContext {
|
|
|
245
290
|
const status = responseOptions?.status ?? this.response.status;
|
|
246
291
|
return new Response(Bun.file(path2, fileOptions), { status, headers });
|
|
247
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* JSX Rendering Function
|
|
295
|
+
*/
|
|
296
|
+
renderer;
|
|
297
|
+
/**
|
|
298
|
+
* Render a JSX element
|
|
299
|
+
* @param element JSX Element
|
|
300
|
+
* @param status HTTP Status
|
|
301
|
+
* @param headers HTTP Headers
|
|
302
|
+
*/
|
|
303
|
+
async jsx(element, args, status, headers) {
|
|
304
|
+
if (!this.renderer) {
|
|
305
|
+
throw new Error("No JSX renderer configured");
|
|
306
|
+
}
|
|
307
|
+
const html = await this.renderer(element, args);
|
|
308
|
+
return this.html(html, status, headers);
|
|
309
|
+
}
|
|
248
310
|
}
|
|
249
311
|
const $isApplication = /* @__PURE__ */ Symbol.for("Shokupan.app");
|
|
250
312
|
const $appRoot = /* @__PURE__ */ Symbol.for("Shokupan.app-root");
|
|
@@ -259,6 +321,8 @@ const $childRouters = /* @__PURE__ */ Symbol.for("Shokupan.child-routers");
|
|
|
259
321
|
const $childControllers = /* @__PURE__ */ Symbol.for("Shokupan.child-controllers");
|
|
260
322
|
const $mountPath = /* @__PURE__ */ Symbol.for("Shokupan.mount-path");
|
|
261
323
|
const $dispatch = /* @__PURE__ */ Symbol.for("Shokupan.dispatch");
|
|
324
|
+
const $routes = /* @__PURE__ */ Symbol.for("Shokupan.routes");
|
|
325
|
+
const $routeSpec = /* @__PURE__ */ Symbol.for("Shokupan.routeSpec");
|
|
262
326
|
const HTTPMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "ALL"];
|
|
263
327
|
var RouteParamType = /* @__PURE__ */ ((RouteParamType2) => {
|
|
264
328
|
RouteParamType2["BODY"] = "BODY";
|
|
@@ -311,6 +375,14 @@ const Query = createParamDecorator(RouteParamType.QUERY);
|
|
|
311
375
|
const Headers$1 = createParamDecorator(RouteParamType.HEADER);
|
|
312
376
|
const Req = createParamDecorator(RouteParamType.REQUEST);
|
|
313
377
|
const Ctx = createParamDecorator(RouteParamType.CONTEXT);
|
|
378
|
+
function Spec(spec) {
|
|
379
|
+
return (target, propertyKey, descriptor) => {
|
|
380
|
+
if (!target[$routeSpec]) {
|
|
381
|
+
target[$routeSpec] = /* @__PURE__ */ new Map();
|
|
382
|
+
}
|
|
383
|
+
target[$routeSpec].set(propertyKey, spec);
|
|
384
|
+
};
|
|
385
|
+
}
|
|
314
386
|
function createMethodDecorator(method) {
|
|
315
387
|
return (path2 = "/") => {
|
|
316
388
|
return (target, propertyKey, descriptor) => {
|
|
@@ -365,20 +437,6 @@ function Inject(token) {
|
|
|
365
437
|
});
|
|
366
438
|
};
|
|
367
439
|
}
|
|
368
|
-
const provider = new sdkTraceNode.NodeTracerProvider({
|
|
369
|
-
resource: resources.resourceFromAttributes({
|
|
370
|
-
[semanticConventions.ATTR_SERVICE_NAME]: "basic-service"
|
|
371
|
-
}),
|
|
372
|
-
spanProcessors: [
|
|
373
|
-
new sdkTraceBase.SimpleSpanProcessor(
|
|
374
|
-
new exporterTraceOtlpProto.OTLPTraceExporter({
|
|
375
|
-
url: "http://localhost:4318/v1/traces"
|
|
376
|
-
// Default OTLP port
|
|
377
|
-
})
|
|
378
|
-
)
|
|
379
|
-
]
|
|
380
|
-
});
|
|
381
|
-
provider.register();
|
|
382
440
|
const tracer = api.trace.getTracer("shokupan.middleware");
|
|
383
441
|
function traceMiddleware(fn, name) {
|
|
384
442
|
const middlewareName = fn.name || "anonymous middleware";
|
|
@@ -468,7 +526,6 @@ class ShokupanRequestBase {
|
|
|
468
526
|
}
|
|
469
527
|
}
|
|
470
528
|
const ShokupanRequest = ShokupanRequestBase;
|
|
471
|
-
const asyncContext = new node_async_hooks.AsyncLocalStorage();
|
|
472
529
|
function isObject(item) {
|
|
473
530
|
return item && typeof item === "object" && !Array.isArray(item);
|
|
474
531
|
}
|
|
@@ -482,7 +539,17 @@ function deepMerge(target, ...sources) {
|
|
|
482
539
|
deepMerge(target[key], source[key]);
|
|
483
540
|
} else if (Array.isArray(source[key])) {
|
|
484
541
|
if (!target[key]) Object.assign(target, { [key]: [] });
|
|
485
|
-
|
|
542
|
+
if (key === "tags") {
|
|
543
|
+
target[key] = source[key];
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const mergedArray = target[key].concat(source[key]);
|
|
547
|
+
const isPrimitive = (item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean";
|
|
548
|
+
if (mergedArray.every(isPrimitive)) {
|
|
549
|
+
target[key] = Array.from(new Set(mergedArray));
|
|
550
|
+
} else {
|
|
551
|
+
target[key] = mergedArray;
|
|
552
|
+
}
|
|
486
553
|
} else {
|
|
487
554
|
Object.assign(target, { [key]: source[key] });
|
|
488
555
|
}
|
|
@@ -490,12 +557,583 @@ function deepMerge(target, ...sources) {
|
|
|
490
557
|
}
|
|
491
558
|
return deepMerge(target, ...sources);
|
|
492
559
|
}
|
|
560
|
+
function analyzeHandler(handler) {
|
|
561
|
+
const handlerSource = handler.toString();
|
|
562
|
+
const inferredSpec = {};
|
|
563
|
+
if (handlerSource.includes("ctx.body") || handlerSource.includes("await ctx.req.json()")) {
|
|
564
|
+
inferredSpec.requestBody = {
|
|
565
|
+
content: { "application/json": { schema: { type: "object" } } }
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
const queryParams = /* @__PURE__ */ new Map();
|
|
569
|
+
const queryIntMatch = handlerSource.match(/parseInt\(ctx\.query\.(\w+)\)/g);
|
|
570
|
+
if (queryIntMatch) {
|
|
571
|
+
queryIntMatch.forEach((match) => {
|
|
572
|
+
const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
|
|
573
|
+
if (paramName) queryParams.set(paramName, { type: "integer", format: "int32" });
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
const queryFloatMatch = handlerSource.match(/parseFloat\(ctx\.query\.(\w+)\)/g);
|
|
577
|
+
if (queryFloatMatch) {
|
|
578
|
+
queryFloatMatch.forEach((match) => {
|
|
579
|
+
const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
|
|
580
|
+
if (paramName) queryParams.set(paramName, { type: "number", format: "float" });
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
const queryNumberMatch = handlerSource.match(/Number\(ctx\.query\.(\w+)\)/g);
|
|
584
|
+
if (queryNumberMatch) {
|
|
585
|
+
queryNumberMatch.forEach((match) => {
|
|
586
|
+
const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
|
|
587
|
+
if (paramName && !queryParams.has(paramName)) {
|
|
588
|
+
queryParams.set(paramName, { type: "number" });
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
const queryBoolMatch = handlerSource.match(/(?:Boolean\(ctx\.query\.(\w+)\)|!+ctx\.query\.(\w+))/g);
|
|
593
|
+
if (queryBoolMatch) {
|
|
594
|
+
queryBoolMatch.forEach((match) => {
|
|
595
|
+
const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
|
|
596
|
+
if (paramName && !queryParams.has(paramName)) {
|
|
597
|
+
queryParams.set(paramName, { type: "boolean" });
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
const queryMatch = handlerSource.match(/ctx\.query\.(\w+)/g);
|
|
602
|
+
if (queryMatch) {
|
|
603
|
+
queryMatch.forEach((match) => {
|
|
604
|
+
const paramName = match.split(".")[2];
|
|
605
|
+
if (paramName && !queryParams.has(paramName)) {
|
|
606
|
+
queryParams.set(paramName, { type: "string" });
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
if (queryParams.size > 0) {
|
|
611
|
+
if (!inferredSpec.parameters) inferredSpec.parameters = [];
|
|
612
|
+
queryParams.forEach((schema, paramName) => {
|
|
613
|
+
inferredSpec.parameters.push({
|
|
614
|
+
name: paramName,
|
|
615
|
+
in: "query",
|
|
616
|
+
schema: { type: schema.type, ...schema.format ? { format: schema.format } : {} }
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
const pathParams = /* @__PURE__ */ new Map();
|
|
621
|
+
const paramIntMatch = handlerSource.match(/parseInt\(ctx\.params\.(\w+)\)/g);
|
|
622
|
+
if (paramIntMatch) {
|
|
623
|
+
paramIntMatch.forEach((match) => {
|
|
624
|
+
const paramName = match.match(/ctx\.params\.(\w+)/)?.[1];
|
|
625
|
+
if (paramName) pathParams.set(paramName, { type: "integer", format: "int32" });
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const paramFloatMatch = handlerSource.match(/parseFloat\(ctx\.params\.(\w+)\)/g);
|
|
629
|
+
if (paramFloatMatch) {
|
|
630
|
+
paramFloatMatch.forEach((match) => {
|
|
631
|
+
const paramName = match.match(/ctx\.params\.(\w+)/)?.[1];
|
|
632
|
+
if (paramName) pathParams.set(paramName, { type: "number", format: "float" });
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
if (pathParams.size > 0) {
|
|
636
|
+
if (!inferredSpec.parameters) inferredSpec.parameters = [];
|
|
637
|
+
pathParams.forEach((schema, paramName) => {
|
|
638
|
+
inferredSpec.parameters.push({
|
|
639
|
+
name: paramName,
|
|
640
|
+
in: "path",
|
|
641
|
+
required: true,
|
|
642
|
+
schema: { type: schema.type, ...schema.format ? { format: schema.format } : {} }
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
const headerMatch = handlerSource.match(/ctx\.get\(['"](\w+)['"]\)/g);
|
|
647
|
+
if (headerMatch) {
|
|
648
|
+
if (!inferredSpec.parameters) inferredSpec.parameters = [];
|
|
649
|
+
headerMatch.forEach((match) => {
|
|
650
|
+
const headerName = match.match(/['"](\w+)['"]/)?.[1];
|
|
651
|
+
if (headerName) {
|
|
652
|
+
inferredSpec.parameters.push({
|
|
653
|
+
name: headerName,
|
|
654
|
+
in: "header",
|
|
655
|
+
schema: { type: "string" }
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
const responses = {};
|
|
661
|
+
if (handlerSource.includes("ctx.json(")) {
|
|
662
|
+
responses["200"] = {
|
|
663
|
+
description: "Successful response",
|
|
664
|
+
content: {
|
|
665
|
+
"application/json": { schema: { type: "object" } }
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
if (handlerSource.includes("ctx.html(")) {
|
|
670
|
+
responses["200"] = {
|
|
671
|
+
description: "Successful response",
|
|
672
|
+
content: {
|
|
673
|
+
"text/html": { schema: { type: "string" } }
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
if (handlerSource.includes("ctx.text(")) {
|
|
678
|
+
responses["200"] = {
|
|
679
|
+
description: "Successful response",
|
|
680
|
+
content: {
|
|
681
|
+
"text/plain": { schema: { type: "string" } }
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
if (handlerSource.includes("ctx.file(")) {
|
|
686
|
+
responses["200"] = {
|
|
687
|
+
description: "File download",
|
|
688
|
+
content: {
|
|
689
|
+
"application/octet-stream": { schema: { type: "string", format: "binary" } }
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
if (handlerSource.includes("ctx.redirect(")) {
|
|
694
|
+
responses["302"] = {
|
|
695
|
+
description: "Redirect"
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
if (!responses["200"] && /return\s+\{/.test(handlerSource)) {
|
|
699
|
+
responses["200"] = {
|
|
700
|
+
description: "Successful response",
|
|
701
|
+
content: {
|
|
702
|
+
"application/json": { schema: { type: "object" } }
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
const errorStatusMatch = handlerSource.match(/ctx\.(?:json|text|html)\([^)]+,\s*(\d{3,})\)/g);
|
|
707
|
+
if (errorStatusMatch) {
|
|
708
|
+
errorStatusMatch.forEach((match) => {
|
|
709
|
+
const statusCode = match.match(/,\s*(\d{3,})\)/)?.[1];
|
|
710
|
+
if (statusCode && statusCode !== "200") {
|
|
711
|
+
responses[statusCode] = {
|
|
712
|
+
description: `Error response (${statusCode})`
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
if (Object.keys(responses).length > 0) {
|
|
718
|
+
inferredSpec.responses = responses;
|
|
719
|
+
}
|
|
720
|
+
return { inferredSpec };
|
|
721
|
+
}
|
|
722
|
+
async function generateOpenApi(rootRouter, options = {}) {
|
|
723
|
+
const paths = {};
|
|
724
|
+
const tagGroups = /* @__PURE__ */ new Map();
|
|
725
|
+
const defaultTagGroup = options.defaultTagGroup || "General";
|
|
726
|
+
const defaultTagName = options.defaultTag || "Application";
|
|
727
|
+
let astRoutes = [];
|
|
728
|
+
try {
|
|
729
|
+
const { OpenAPIAnalyzer } = await Promise.resolve().then(() => require("./openapi-analyzer-CFqgSLNK.cjs"));
|
|
730
|
+
const analyzer = new OpenAPIAnalyzer(process.cwd());
|
|
731
|
+
const { applications } = await analyzer.analyze();
|
|
732
|
+
const appMap = /* @__PURE__ */ new Map();
|
|
733
|
+
applications.forEach((app) => {
|
|
734
|
+
appMap.set(app.name, app);
|
|
735
|
+
if (app.name !== app.className) {
|
|
736
|
+
appMap.set(app.className, app);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
const getExpandedRoutes = (app, prefix = "", seen = /* @__PURE__ */ new Set()) => {
|
|
740
|
+
if (seen.has(app.name)) return [];
|
|
741
|
+
const newSeen = new Set(seen);
|
|
742
|
+
newSeen.add(app.name);
|
|
743
|
+
const expanded = [];
|
|
744
|
+
for (const route of app.routes) {
|
|
745
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
746
|
+
const cleanPath = route.path.startsWith("/") ? route.path : "/" + route.path;
|
|
747
|
+
let joined = cleanPrefix + cleanPath;
|
|
748
|
+
if (joined.length > 1 && joined.endsWith("/")) {
|
|
749
|
+
joined = joined.slice(0, -1);
|
|
750
|
+
}
|
|
751
|
+
expanded.push({
|
|
752
|
+
...route,
|
|
753
|
+
path: joined || "/"
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
if (app.mounted) {
|
|
757
|
+
for (const mount of app.mounted) {
|
|
758
|
+
const targetApp = appMap.get(mount.target);
|
|
759
|
+
if (targetApp) {
|
|
760
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
761
|
+
const mountPrefix = mount.prefix.startsWith("/") ? mount.prefix : "/" + mount.prefix;
|
|
762
|
+
expanded.push(...getExpandedRoutes(targetApp, cleanPrefix + mountPrefix, newSeen));
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return expanded;
|
|
767
|
+
};
|
|
768
|
+
applications.forEach((app) => {
|
|
769
|
+
astRoutes.push(...getExpandedRoutes(app));
|
|
770
|
+
});
|
|
771
|
+
const dedupedRoutes = /* @__PURE__ */ new Map();
|
|
772
|
+
for (const route of astRoutes) {
|
|
773
|
+
const key = `${route.method.toUpperCase()}:${route.path}`;
|
|
774
|
+
let score = 0;
|
|
775
|
+
if (route.responseSchema) score += 10;
|
|
776
|
+
if (route.handlerSource) score += 5;
|
|
777
|
+
if (!dedupedRoutes.has(key) || score > dedupedRoutes.get(key).score) {
|
|
778
|
+
dedupedRoutes.set(key, { route, score });
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
astRoutes = Array.from(dedupedRoutes.values()).map((v) => v.route);
|
|
782
|
+
} catch (e) {
|
|
783
|
+
console.warn("OpenAPI AST analysis failed or skipped:", e);
|
|
784
|
+
}
|
|
785
|
+
const collect = (router, prefix = "", currentGroup = defaultTagGroup, defaultTag = defaultTagName) => {
|
|
786
|
+
let group = currentGroup;
|
|
787
|
+
let tag = defaultTag;
|
|
788
|
+
if (router.config?.group) group = router.config.group;
|
|
789
|
+
if (router.config?.name) {
|
|
790
|
+
tag = router.config.name;
|
|
791
|
+
} else {
|
|
792
|
+
const mountPath = router[$mountPath];
|
|
793
|
+
if (mountPath && mountPath !== "/") {
|
|
794
|
+
const segments = mountPath.split("/").filter(Boolean);
|
|
795
|
+
if (segments.length > 0) {
|
|
796
|
+
const lastSegment = segments[segments.length - 1];
|
|
797
|
+
tag = lastSegment.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (!tagGroups.has(group)) tagGroups.set(group, /* @__PURE__ */ new Set());
|
|
802
|
+
const routes = router[$routes] || [];
|
|
803
|
+
for (const route of routes) {
|
|
804
|
+
const routeGroup = route.group || group;
|
|
805
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
806
|
+
const cleanSubPath = route.path.startsWith("/") ? route.path : "/" + route.path;
|
|
807
|
+
let fullPath = cleanPrefix + cleanSubPath || "/";
|
|
808
|
+
fullPath = fullPath.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
|
|
809
|
+
if (fullPath.length > 1 && fullPath.endsWith("/")) {
|
|
810
|
+
fullPath = fullPath.slice(0, -1);
|
|
811
|
+
}
|
|
812
|
+
if (!paths[fullPath]) paths[fullPath] = {};
|
|
813
|
+
const operation = {
|
|
814
|
+
responses: { "200": { description: "Successful response" } },
|
|
815
|
+
tags: [tag]
|
|
816
|
+
};
|
|
817
|
+
if (route.guards) {
|
|
818
|
+
for (const guard of route.guards) {
|
|
819
|
+
if (guard.spec) {
|
|
820
|
+
if (guard.spec.security) {
|
|
821
|
+
const existing = operation.security || [];
|
|
822
|
+
for (const req of guard.spec.security) {
|
|
823
|
+
const reqStr = JSON.stringify(req);
|
|
824
|
+
if (!existing.some((e) => JSON.stringify(e) === reqStr)) {
|
|
825
|
+
existing.push(req);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
operation.security = existing;
|
|
829
|
+
}
|
|
830
|
+
if (guard.spec.responses) {
|
|
831
|
+
operation.responses = { ...operation.responses, ...guard.spec.responses };
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
let astMatch = astRoutes.find(
|
|
837
|
+
(r) => r.method.toUpperCase() === route.method.toUpperCase() && r.path === fullPath
|
|
838
|
+
);
|
|
839
|
+
if (!astMatch) {
|
|
840
|
+
let runtimeSource = route.handler.toString();
|
|
841
|
+
if (route.handler.originalHandler) {
|
|
842
|
+
runtimeSource = route.handler.originalHandler.toString();
|
|
843
|
+
}
|
|
844
|
+
const runtimeHandlerSrc = runtimeSource.replace(/\s+/g, " ");
|
|
845
|
+
const sameMethodRoutes = astRoutes.filter((r) => r.method.toUpperCase() === route.method.toUpperCase());
|
|
846
|
+
astMatch = sameMethodRoutes.find((r) => {
|
|
847
|
+
const astHandlerSrc = (r.handlerSource || r.handlerName || "").replace(/\s+/g, " ");
|
|
848
|
+
if (!astHandlerSrc || astHandlerSrc.length < 20) return false;
|
|
849
|
+
const match = runtimeHandlerSrc.includes(astHandlerSrc) || astHandlerSrc.includes(runtimeHandlerSrc) || r.handlerSource && runtimeHandlerSrc.includes(r.handlerSource.substring(0, 50));
|
|
850
|
+
return match;
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
const potentialMatches = astRoutes.filter(
|
|
854
|
+
(r) => r.method.toUpperCase() === route.method.toUpperCase() && r.path === fullPath
|
|
855
|
+
);
|
|
856
|
+
if (potentialMatches.length > 1) {
|
|
857
|
+
const runtimeHandlerSrc = route.handler.toString().replace(/\s+/g, " ");
|
|
858
|
+
const preciseMatch = potentialMatches.find((r) => {
|
|
859
|
+
const astHandlerSrc = (r.handlerSource || r.handlerName || "").replace(/\s+/g, " ");
|
|
860
|
+
const match = runtimeHandlerSrc.includes(astHandlerSrc) || astHandlerSrc.includes(runtimeHandlerSrc) || r.handlerSource && runtimeHandlerSrc.includes(r.handlerSource.substring(0, 50));
|
|
861
|
+
return match;
|
|
862
|
+
});
|
|
863
|
+
if (preciseMatch) {
|
|
864
|
+
astMatch = preciseMatch;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (astMatch) {
|
|
868
|
+
if (astMatch.summary) operation.summary = astMatch.summary;
|
|
869
|
+
if (astMatch.description) operation.description = astMatch.description;
|
|
870
|
+
if (astMatch.tags) operation.tags = astMatch.tags;
|
|
871
|
+
if (astMatch.operationId) operation.operationId = astMatch.operationId;
|
|
872
|
+
if (astMatch.requestTypes?.body) {
|
|
873
|
+
operation.requestBody = {
|
|
874
|
+
content: {
|
|
875
|
+
"application/json": { schema: astMatch.requestTypes.body }
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
if (astMatch.responseSchema) {
|
|
880
|
+
operation.responses["200"] = {
|
|
881
|
+
description: "Successful response",
|
|
882
|
+
content: {
|
|
883
|
+
"application/json": { schema: astMatch.responseSchema }
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
} else if (astMatch.responseType) {
|
|
887
|
+
const contentType = astMatch.responseType === "string" ? "text/plain" : "application/json";
|
|
888
|
+
operation.responses["200"] = {
|
|
889
|
+
description: "Successful response",
|
|
890
|
+
content: {
|
|
891
|
+
[contentType]: { schema: { type: astMatch.responseType } }
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
const params = [];
|
|
896
|
+
if (astMatch.requestTypes?.query) {
|
|
897
|
+
for (const [name, _type] of Object.entries(astMatch.requestTypes.query)) {
|
|
898
|
+
params.push({ name, in: "query", schema: { type: "string" } });
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (params.length > 0) {
|
|
902
|
+
operation.parameters = params;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (route.keys.length > 0) {
|
|
906
|
+
const pathParams = route.keys.map((key) => ({
|
|
907
|
+
name: key,
|
|
908
|
+
in: "path",
|
|
909
|
+
required: true,
|
|
910
|
+
schema: { type: "string" }
|
|
911
|
+
}));
|
|
912
|
+
const existingParams = operation.parameters || [];
|
|
913
|
+
const mergedParams = [...existingParams];
|
|
914
|
+
pathParams.forEach((p) => {
|
|
915
|
+
const idx = mergedParams.findIndex((ep) => ep.in === "path" && ep.name === p.name);
|
|
916
|
+
if (idx >= 0) {
|
|
917
|
+
mergedParams[idx] = deepMerge(mergedParams[idx], p);
|
|
918
|
+
} else {
|
|
919
|
+
mergedParams.push(p);
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
operation.parameters = mergedParams;
|
|
923
|
+
}
|
|
924
|
+
const { inferredSpec } = analyzeHandler(route.handler);
|
|
925
|
+
if (inferredSpec) {
|
|
926
|
+
if (inferredSpec.parameters) {
|
|
927
|
+
const existingParams = operation.parameters || [];
|
|
928
|
+
const mergedParams = [...existingParams];
|
|
929
|
+
for (const p of inferredSpec.parameters) {
|
|
930
|
+
const idx = mergedParams.findIndex((ep) => ep.name === p.name && ep.in === p.in);
|
|
931
|
+
if (idx >= 0) {
|
|
932
|
+
mergedParams[idx] = deepMerge(mergedParams[idx], p);
|
|
933
|
+
} else {
|
|
934
|
+
mergedParams.push(p);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
operation.parameters = mergedParams;
|
|
938
|
+
delete inferredSpec.parameters;
|
|
939
|
+
}
|
|
940
|
+
deepMerge(operation, inferredSpec);
|
|
941
|
+
}
|
|
942
|
+
if (route.handlerSpec) {
|
|
943
|
+
const spec = route.handlerSpec;
|
|
944
|
+
if (spec.summary) operation.summary = spec.summary;
|
|
945
|
+
if (spec.description) operation.description = spec.description;
|
|
946
|
+
if (spec.operationId) operation.operationId = spec.operationId;
|
|
947
|
+
if (spec.tags) operation.tags = spec.tags;
|
|
948
|
+
if (spec.security) operation.security = spec.security;
|
|
949
|
+
if (spec.responses) {
|
|
950
|
+
operation.responses = { ...operation.responses, ...spec.responses };
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (!operation.tags || operation.tags.length === 0) operation.tags = [tag];
|
|
954
|
+
if (operation.tags) {
|
|
955
|
+
operation.tags = Array.from(new Set(operation.tags));
|
|
956
|
+
for (const t of operation.tags) {
|
|
957
|
+
if (!tagGroups.has(routeGroup)) tagGroups.set(routeGroup, /* @__PURE__ */ new Set());
|
|
958
|
+
tagGroups.get(routeGroup)?.add(t);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
const methodLower = route.method.toLowerCase();
|
|
962
|
+
if (methodLower === "all") {
|
|
963
|
+
["get", "post", "put", "delete", "patch"].forEach((m) => {
|
|
964
|
+
if (!paths[fullPath][m]) paths[fullPath][m] = { ...operation };
|
|
965
|
+
});
|
|
966
|
+
} else {
|
|
967
|
+
paths[fullPath][methodLower] = operation;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
for (const controller of router[$childControllers]) {
|
|
971
|
+
const controllerName = controller.constructor.name || "UnknownController";
|
|
972
|
+
tagGroups.get(group)?.add(controllerName);
|
|
973
|
+
}
|
|
974
|
+
for (const child of router[$childRouters]) {
|
|
975
|
+
const mountPath = child[$mountPath];
|
|
976
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
977
|
+
const cleanMount = mountPath.startsWith("/") ? mountPath : "/" + mountPath;
|
|
978
|
+
const nextPrefix = cleanPrefix + cleanMount || "/";
|
|
979
|
+
collect(child, nextPrefix, group, tag);
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
collect(rootRouter);
|
|
983
|
+
const xTagGroups = [];
|
|
984
|
+
for (const [name, tags] of tagGroups) {
|
|
985
|
+
xTagGroups.push({ name, tags: Array.from(tags).sort() });
|
|
986
|
+
}
|
|
987
|
+
return {
|
|
988
|
+
openapi: "3.1.0",
|
|
989
|
+
info: { title: "Shokupan API", version: "1.0.0", ...options.info },
|
|
990
|
+
paths,
|
|
991
|
+
components: options.components,
|
|
992
|
+
servers: options.servers,
|
|
993
|
+
tags: options.tags,
|
|
994
|
+
externalDocs: options.externalDocs,
|
|
995
|
+
"x-tagGroups": xTagGroups
|
|
996
|
+
};
|
|
997
|
+
}
|
|
493
998
|
const eta$1 = new eta$2.Eta();
|
|
999
|
+
function serveStatic(ctx, config, prefix) {
|
|
1000
|
+
const rootPath = path.resolve(config.root || ".");
|
|
1001
|
+
const normalizedPrefix = prefix.endsWith("/") && prefix !== "/" ? prefix.slice(0, -1) : prefix;
|
|
1002
|
+
return async () => {
|
|
1003
|
+
let relative = ctx.path.slice(normalizedPrefix.length);
|
|
1004
|
+
if (!relative.startsWith("/") && relative.length > 0) relative = "/" + relative;
|
|
1005
|
+
if (relative.length === 0) relative = "/";
|
|
1006
|
+
relative = decodeURIComponent(relative);
|
|
1007
|
+
const requestPath = path.join(rootPath, relative);
|
|
1008
|
+
if (!requestPath.startsWith(rootPath)) {
|
|
1009
|
+
return ctx.json({ error: "Forbidden" }, 403);
|
|
1010
|
+
}
|
|
1011
|
+
if (requestPath.includes("\0")) {
|
|
1012
|
+
return ctx.json({ error: "Forbidden" }, 403);
|
|
1013
|
+
}
|
|
1014
|
+
if (config.hooks?.onRequest) {
|
|
1015
|
+
const res = await config.hooks.onRequest(ctx);
|
|
1016
|
+
if (res) return res;
|
|
1017
|
+
}
|
|
1018
|
+
if (config.exclude) {
|
|
1019
|
+
for (const pattern of config.exclude) {
|
|
1020
|
+
if (pattern instanceof RegExp) {
|
|
1021
|
+
if (pattern.test(relative)) return ctx.json({ error: "Forbidden" }, 403);
|
|
1022
|
+
} else if (typeof pattern === "string") {
|
|
1023
|
+
if (relative.includes(pattern)) return ctx.json({ error: "Forbidden" }, 403);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (path.basename(requestPath).startsWith(".")) {
|
|
1028
|
+
const behavior = config.dotfiles || "ignore";
|
|
1029
|
+
if (behavior === "deny") return ctx.json({ error: "Forbidden" }, 403);
|
|
1030
|
+
if (behavior === "ignore") return ctx.json({ error: "Not Found" }, 404);
|
|
1031
|
+
}
|
|
1032
|
+
let finalPath = requestPath;
|
|
1033
|
+
let stats;
|
|
1034
|
+
try {
|
|
1035
|
+
stats = await promises.stat(requestPath);
|
|
1036
|
+
} catch (e) {
|
|
1037
|
+
if (config.extensions) {
|
|
1038
|
+
for (const ext of config.extensions) {
|
|
1039
|
+
const p = requestPath + (ext.startsWith(".") ? ext : "." + ext);
|
|
1040
|
+
try {
|
|
1041
|
+
const s = await promises.stat(p);
|
|
1042
|
+
if (s.isFile()) {
|
|
1043
|
+
finalPath = p;
|
|
1044
|
+
stats = s;
|
|
1045
|
+
break;
|
|
1046
|
+
}
|
|
1047
|
+
} catch {
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if (!stats) return ctx.json({ error: "Not Found" }, 404);
|
|
1052
|
+
}
|
|
1053
|
+
if (stats.isDirectory()) {
|
|
1054
|
+
if (!ctx.path.endsWith("/")) {
|
|
1055
|
+
const query = ctx.url.search;
|
|
1056
|
+
return ctx.redirect(ctx.path + "/" + query, 302);
|
|
1057
|
+
}
|
|
1058
|
+
let indexes = [];
|
|
1059
|
+
if (config.index === void 0) {
|
|
1060
|
+
indexes = ["index.html", "index.htm"];
|
|
1061
|
+
} else if (Array.isArray(config.index)) {
|
|
1062
|
+
indexes = config.index;
|
|
1063
|
+
} else if (config.index) {
|
|
1064
|
+
indexes = [config.index];
|
|
1065
|
+
}
|
|
1066
|
+
let foundIndex = false;
|
|
1067
|
+
for (const idx of indexes) {
|
|
1068
|
+
const idxPath = path.join(finalPath, idx);
|
|
1069
|
+
try {
|
|
1070
|
+
const idxStats = await promises.stat(idxPath);
|
|
1071
|
+
if (idxStats.isFile()) {
|
|
1072
|
+
finalPath = idxPath;
|
|
1073
|
+
foundIndex = true;
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
} catch {
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (!foundIndex) {
|
|
1080
|
+
if (config.listDirectory) {
|
|
1081
|
+
try {
|
|
1082
|
+
const files = await promises.readdir(requestPath);
|
|
1083
|
+
const listing = eta$1.renderString(`
|
|
1084
|
+
<!DOCTYPE html>
|
|
1085
|
+
<html>
|
|
1086
|
+
<head>
|
|
1087
|
+
<title>Index of <%= it.relative %></title>
|
|
1088
|
+
<style>
|
|
1089
|
+
body { font-family: system-ui, -apple-system, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
|
1090
|
+
ul { list-style: none; padding: 0; }
|
|
1091
|
+
li { padding: 0.5rem 0; border-bottom: 1px solid #eee; }
|
|
1092
|
+
a { text-decoration: none; color: #0066cc; }
|
|
1093
|
+
a:hover { text-decoration: underline; }
|
|
1094
|
+
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
|
|
1095
|
+
</style>
|
|
1096
|
+
</head>
|
|
1097
|
+
<body>
|
|
1098
|
+
<h1>Index of <%= it.relative %></h1>
|
|
1099
|
+
<ul>
|
|
1100
|
+
<% if (it.relative !== '/') { %>
|
|
1101
|
+
<li><a href="../">../</a></li>
|
|
1102
|
+
<% } %>
|
|
1103
|
+
<% it.files.forEach(function(f) { %>
|
|
1104
|
+
<li><a href="<%= f %>"><%= f %></a></li>
|
|
1105
|
+
<% }) %>
|
|
1106
|
+
</ul>
|
|
1107
|
+
</body>
|
|
1108
|
+
</html>
|
|
1109
|
+
`, { relative, files, join: path.join });
|
|
1110
|
+
return new Response(listing, { headers: { "Content-Type": "text/html" } });
|
|
1111
|
+
} catch (e) {
|
|
1112
|
+
return ctx.json({ error: "Internal Server Error" }, 500);
|
|
1113
|
+
}
|
|
1114
|
+
} else {
|
|
1115
|
+
return ctx.json({ error: "Forbidden" }, 403);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
const file = Bun.file(finalPath);
|
|
1120
|
+
let response = new Response(file);
|
|
1121
|
+
if (config.hooks?.onResponse) {
|
|
1122
|
+
const hooked = await config.hooks.onResponse(ctx, response);
|
|
1123
|
+
if (hooked) response = hooked;
|
|
1124
|
+
}
|
|
1125
|
+
return response;
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
const asyncContext = new node_async_hooks.AsyncLocalStorage();
|
|
494
1129
|
const RouterRegistry = /* @__PURE__ */ new Map();
|
|
495
1130
|
const ShokupanApplicationTree = {};
|
|
496
1131
|
class ShokupanRouter {
|
|
497
1132
|
constructor(config) {
|
|
498
1133
|
this.config = config;
|
|
1134
|
+
if (config?.requestTimeout) {
|
|
1135
|
+
this.requestTimeout = config.requestTimeout;
|
|
1136
|
+
}
|
|
499
1137
|
}
|
|
500
1138
|
// Internal marker to identify Router vs. Application
|
|
501
1139
|
[$isApplication] = false;
|
|
@@ -503,6 +1141,7 @@ class ShokupanRouter {
|
|
|
503
1141
|
[$isRouter] = true;
|
|
504
1142
|
[$appRoot];
|
|
505
1143
|
[$mountPath] = "/";
|
|
1144
|
+
// Public via Symbol for OpenAPI generator
|
|
506
1145
|
[$parent] = null;
|
|
507
1146
|
[$childRouters] = [];
|
|
508
1147
|
[$childControllers] = [];
|
|
@@ -512,7 +1151,8 @@ class ShokupanRouter {
|
|
|
512
1151
|
get root() {
|
|
513
1152
|
return this[$appRoot];
|
|
514
1153
|
}
|
|
515
|
-
routes = [];
|
|
1154
|
+
[$routes] = [];
|
|
1155
|
+
// Public via Symbol for OpenAPI generator
|
|
516
1156
|
currentGuards = [];
|
|
517
1157
|
isRouterInstance(target) {
|
|
518
1158
|
return typeof target === "object" && target !== null && $isRouter in target;
|
|
@@ -528,6 +1168,12 @@ class ShokupanRouter {
|
|
|
528
1168
|
* - postCreate(ctx) -> POST /prefix/create
|
|
529
1169
|
*/
|
|
530
1170
|
mount(prefix, controller) {
|
|
1171
|
+
const isRouter = this.isRouterInstance(controller);
|
|
1172
|
+
const isFunction = typeof controller === "function";
|
|
1173
|
+
const controllersOnly = this.config?.controllersOnly ?? this.rootConfig?.controllersOnly ?? false;
|
|
1174
|
+
if (controllersOnly && !isFunction && !isRouter) {
|
|
1175
|
+
throw new Error(`[Shokupan] strict controller check failed: ${controller.constructor.name || typeof controller} is not a class constructor.`);
|
|
1176
|
+
}
|
|
531
1177
|
if (this.isRouterInstance(controller)) {
|
|
532
1178
|
if (controller[$isMounted]) {
|
|
533
1179
|
throw new Error("Router is already mounted");
|
|
@@ -554,6 +1200,15 @@ class ShokupanRouter {
|
|
|
554
1200
|
prefix = p1 + p2;
|
|
555
1201
|
if (!prefix) prefix = "/";
|
|
556
1202
|
}
|
|
1203
|
+
} else {
|
|
1204
|
+
const ctor = instance.constructor;
|
|
1205
|
+
const controllerPath = ctor[$controllerPath];
|
|
1206
|
+
if (controllerPath) {
|
|
1207
|
+
const p1 = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1208
|
+
const p2 = controllerPath.startsWith("/") ? controllerPath : "/" + controllerPath;
|
|
1209
|
+
prefix = p1 + p2;
|
|
1210
|
+
if (!prefix) prefix = "/";
|
|
1211
|
+
}
|
|
557
1212
|
}
|
|
558
1213
|
instance[$mountPath] = prefix;
|
|
559
1214
|
this[$childControllers].push(instance);
|
|
@@ -671,8 +1326,14 @@ class ShokupanRouter {
|
|
|
671
1326
|
return composed(ctx, () => wrappedHandler(ctx));
|
|
672
1327
|
};
|
|
673
1328
|
}
|
|
1329
|
+
finalHandler.originalHandler = originalHandler;
|
|
1330
|
+
if (finalHandler !== wrappedHandler) {
|
|
1331
|
+
wrappedHandler.originalHandler = originalHandler;
|
|
1332
|
+
}
|
|
674
1333
|
const tagName = instance.constructor.name;
|
|
675
|
-
const
|
|
1334
|
+
const decoratedSpecs = instance[$routeSpec] || proto && proto[$routeSpec];
|
|
1335
|
+
const userSpec = decoratedSpecs && decoratedSpecs.get(name);
|
|
1336
|
+
const spec = { tags: [tagName], ...userSpec };
|
|
676
1337
|
this.add({ method, path: normalizedPath, handler: finalHandler, spec });
|
|
677
1338
|
}
|
|
678
1339
|
}
|
|
@@ -687,7 +1348,7 @@ class ShokupanRouter {
|
|
|
687
1348
|
* Returns all routes attached to this router and its descendants.
|
|
688
1349
|
*/
|
|
689
1350
|
getRoutes() {
|
|
690
|
-
const routes = this
|
|
1351
|
+
const routes = this[$routes].map((r) => ({
|
|
691
1352
|
method: r.method,
|
|
692
1353
|
path: r.path,
|
|
693
1354
|
handler: r.handler
|
|
@@ -788,6 +1449,30 @@ class ShokupanRouter {
|
|
|
788
1449
|
data: result
|
|
789
1450
|
};
|
|
790
1451
|
}
|
|
1452
|
+
applyHooks(match) {
|
|
1453
|
+
if (!this.config?.hooks) return match;
|
|
1454
|
+
const hooks = this.config.hooks;
|
|
1455
|
+
const originalHandler = match.handler;
|
|
1456
|
+
match.handler = async (ctx) => {
|
|
1457
|
+
if (hooks.onRequestStart) await hooks.onRequestStart(ctx);
|
|
1458
|
+
try {
|
|
1459
|
+
const result = await originalHandler(ctx);
|
|
1460
|
+
if (hooks.onRequestEnd) await hooks.onRequestEnd(ctx);
|
|
1461
|
+
return result;
|
|
1462
|
+
} catch (err) {
|
|
1463
|
+
if (hooks.onError) {
|
|
1464
|
+
try {
|
|
1465
|
+
await hooks.onError(err, ctx);
|
|
1466
|
+
} catch (e) {
|
|
1467
|
+
console.error("Error in router onError hook:", e);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
throw err;
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
match.handler.originalHandler = originalHandler.originalHandler || originalHandler;
|
|
1474
|
+
return match;
|
|
1475
|
+
}
|
|
791
1476
|
/**
|
|
792
1477
|
* Find a route matching the given method and path.
|
|
793
1478
|
* @param method HTTP method
|
|
@@ -795,7 +1480,7 @@ class ShokupanRouter {
|
|
|
795
1480
|
* @returns Route handler and parameters if found, otherwise null
|
|
796
1481
|
*/
|
|
797
1482
|
find(method, path2) {
|
|
798
|
-
for (const route of this
|
|
1483
|
+
for (const route of this[$routes]) {
|
|
799
1484
|
if (route.method !== "ALL" && route.method !== method) continue;
|
|
800
1485
|
const match = route.regex.exec(path2);
|
|
801
1486
|
if (match) {
|
|
@@ -803,7 +1488,7 @@ class ShokupanRouter {
|
|
|
803
1488
|
route.keys.forEach((key, index) => {
|
|
804
1489
|
params[key] = match[index + 1];
|
|
805
1490
|
});
|
|
806
|
-
return { handler: route.handler, params };
|
|
1491
|
+
return this.applyHooks({ handler: route.handler, params });
|
|
807
1492
|
}
|
|
808
1493
|
}
|
|
809
1494
|
for (const child of this[$childRouters]) {
|
|
@@ -811,13 +1496,13 @@ class ShokupanRouter {
|
|
|
811
1496
|
if (path2 === prefix || path2.startsWith(prefix + "/")) {
|
|
812
1497
|
const subPath = path2.slice(prefix.length) || "/";
|
|
813
1498
|
const match = child.find(method, subPath);
|
|
814
|
-
if (match) return match;
|
|
1499
|
+
if (match) return this.applyHooks(match);
|
|
815
1500
|
}
|
|
816
1501
|
if (prefix.endsWith("/")) {
|
|
817
1502
|
if (path2.startsWith(prefix)) {
|
|
818
1503
|
const subPath = path2.slice(prefix.length) || "/";
|
|
819
1504
|
const match = child.find(method, subPath);
|
|
820
|
-
if (match) return match;
|
|
1505
|
+
if (match) return this.applyHooks(match);
|
|
821
1506
|
}
|
|
822
1507
|
}
|
|
823
1508
|
}
|
|
@@ -835,6 +1520,7 @@ class ShokupanRouter {
|
|
|
835
1520
|
};
|
|
836
1521
|
}
|
|
837
1522
|
// --- Functional Routing ---
|
|
1523
|
+
requestTimeout;
|
|
838
1524
|
/**
|
|
839
1525
|
* Adds a route to the router.
|
|
840
1526
|
*
|
|
@@ -842,12 +1528,25 @@ class ShokupanRouter {
|
|
|
842
1528
|
* @param path - URL path
|
|
843
1529
|
* @param spec - OpenAPI specification for the route
|
|
844
1530
|
* @param handler - Route handler function
|
|
1531
|
+
* @param requestTimeout - Timeout for this route in milliseconds
|
|
845
1532
|
*/
|
|
846
|
-
add({ method, path: path2, spec, handler, regex: customRegex, group }) {
|
|
1533
|
+
add({ method, path: path2, spec, handler, regex: customRegex, group, requestTimeout, renderer }) {
|
|
847
1534
|
const { regex, keys } = customRegex ? { regex: customRegex, keys: [] } : this.parsePath(path2);
|
|
848
1535
|
let wrappedHandler = handler;
|
|
849
1536
|
const routeGuards = [...this.currentGuards];
|
|
1537
|
+
const effectiveTimeout = requestTimeout ?? this.requestTimeout ?? this.rootConfig?.requestTimeout;
|
|
1538
|
+
if (effectiveTimeout !== void 0 && effectiveTimeout > 0) {
|
|
1539
|
+
const originalHandler = wrappedHandler;
|
|
1540
|
+
wrappedHandler = async (ctx) => {
|
|
1541
|
+
if (ctx.server) {
|
|
1542
|
+
ctx.server.timeout(ctx.req, effectiveTimeout / 1e3);
|
|
1543
|
+
}
|
|
1544
|
+
return originalHandler(ctx);
|
|
1545
|
+
};
|
|
1546
|
+
wrappedHandler.originalHandler = originalHandler.originalHandler || originalHandler;
|
|
1547
|
+
}
|
|
850
1548
|
if (routeGuards.length > 0) {
|
|
1549
|
+
const innerHandler = wrappedHandler;
|
|
851
1550
|
wrappedHandler = async (ctx) => {
|
|
852
1551
|
for (const guard of routeGuards) {
|
|
853
1552
|
let guardPassed = false;
|
|
@@ -872,10 +1571,18 @@ class ShokupanRouter {
|
|
|
872
1571
|
return ctx.json({ error: "Forbidden" }, 403);
|
|
873
1572
|
}
|
|
874
1573
|
}
|
|
875
|
-
return
|
|
1574
|
+
return innerHandler(ctx);
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
const effectiveRenderer = renderer ?? this.config?.renderer ?? this.rootConfig?.renderer;
|
|
1578
|
+
if (effectiveRenderer) {
|
|
1579
|
+
const innerHandler = wrappedHandler;
|
|
1580
|
+
wrappedHandler = async (ctx) => {
|
|
1581
|
+
ctx.renderer = effectiveRenderer;
|
|
1582
|
+
return innerHandler(ctx);
|
|
876
1583
|
};
|
|
877
1584
|
}
|
|
878
|
-
this
|
|
1585
|
+
this[$routes].push({
|
|
879
1586
|
method,
|
|
880
1587
|
path: path2,
|
|
881
1588
|
regex,
|
|
@@ -883,7 +1590,9 @@ class ShokupanRouter {
|
|
|
883
1590
|
handler: wrappedHandler,
|
|
884
1591
|
handlerSpec: spec,
|
|
885
1592
|
group,
|
|
886
|
-
guards: routeGuards.length > 0 ? routeGuards : void 0
|
|
1593
|
+
guards: routeGuards.length > 0 ? routeGuards : void 0,
|
|
1594
|
+
requestTimeout: effectiveTimeout
|
|
1595
|
+
// Save for inspection? Or just relying on closure
|
|
887
1596
|
});
|
|
888
1597
|
return this;
|
|
889
1598
|
}
|
|
@@ -928,133 +1637,12 @@ class ShokupanRouter {
|
|
|
928
1637
|
*/
|
|
929
1638
|
static(uriPath, options) {
|
|
930
1639
|
const config = typeof options === "string" ? { root: options } : options;
|
|
931
|
-
const rootPath = path.resolve(config.root || ".");
|
|
932
1640
|
const prefix = uriPath.startsWith("/") ? uriPath : "/" + uriPath;
|
|
933
1641
|
const normalizedPrefix = prefix.endsWith("/") && prefix !== "/" ? prefix.slice(0, -1) : prefix;
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
relative = decodeURIComponent(relative);
|
|
939
|
-
const requestPath = path.join(rootPath, relative);
|
|
940
|
-
if (!requestPath.startsWith(rootPath)) {
|
|
941
|
-
return ctx.json({ error: "Forbidden" }, 403);
|
|
942
|
-
}
|
|
943
|
-
if (requestPath.includes("\0")) {
|
|
944
|
-
return ctx.json({ error: "Forbidden" }, 403);
|
|
945
|
-
}
|
|
946
|
-
if (config.hooks?.onRequest) {
|
|
947
|
-
const res = await config.hooks.onRequest(ctx);
|
|
948
|
-
if (res) return res;
|
|
949
|
-
}
|
|
950
|
-
if (config.exclude) {
|
|
951
|
-
for (const pattern2 of config.exclude) {
|
|
952
|
-
if (pattern2 instanceof RegExp) {
|
|
953
|
-
if (pattern2.test(relative)) return ctx.json({ error: "Forbidden" }, 403);
|
|
954
|
-
} else if (typeof pattern2 === "string") {
|
|
955
|
-
if (relative.includes(pattern2)) return ctx.json({ error: "Forbidden" }, 403);
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
if (path.basename(requestPath).startsWith(".")) {
|
|
960
|
-
const behavior = config.dotfiles || "ignore";
|
|
961
|
-
if (behavior === "deny") return ctx.json({ error: "Forbidden" }, 403);
|
|
962
|
-
if (behavior === "ignore") return ctx.json({ error: "Not Found" }, 404);
|
|
963
|
-
}
|
|
964
|
-
let finalPath = requestPath;
|
|
965
|
-
let stats;
|
|
966
|
-
try {
|
|
967
|
-
stats = await promises.stat(requestPath);
|
|
968
|
-
} catch (e) {
|
|
969
|
-
if (config.extensions) {
|
|
970
|
-
for (const ext of config.extensions) {
|
|
971
|
-
const p = requestPath + (ext.startsWith(".") ? ext : "." + ext);
|
|
972
|
-
try {
|
|
973
|
-
const s = await promises.stat(p);
|
|
974
|
-
if (s.isFile()) {
|
|
975
|
-
finalPath = p;
|
|
976
|
-
stats = s;
|
|
977
|
-
break;
|
|
978
|
-
}
|
|
979
|
-
} catch {
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
if (!stats) return ctx.json({ error: "Not Found" }, 404);
|
|
984
|
-
}
|
|
985
|
-
if (stats.isDirectory()) {
|
|
986
|
-
if (!ctx.path.endsWith("/")) {
|
|
987
|
-
const query = ctx.url.search;
|
|
988
|
-
return ctx.redirect(ctx.path + "/" + query, 302);
|
|
989
|
-
}
|
|
990
|
-
let indexes = [];
|
|
991
|
-
if (config.index === void 0) {
|
|
992
|
-
indexes = ["index.html", "index.htm"];
|
|
993
|
-
} else if (Array.isArray(config.index)) {
|
|
994
|
-
indexes = config.index;
|
|
995
|
-
} else if (config.index) {
|
|
996
|
-
indexes = [config.index];
|
|
997
|
-
}
|
|
998
|
-
let foundIndex = false;
|
|
999
|
-
for (const idx of indexes) {
|
|
1000
|
-
const idxPath = path.join(finalPath, idx);
|
|
1001
|
-
try {
|
|
1002
|
-
const idxStats = await promises.stat(idxPath);
|
|
1003
|
-
if (idxStats.isFile()) {
|
|
1004
|
-
finalPath = idxPath;
|
|
1005
|
-
foundIndex = true;
|
|
1006
|
-
break;
|
|
1007
|
-
}
|
|
1008
|
-
} catch {
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
if (!foundIndex) {
|
|
1012
|
-
if (config.listDirectory) {
|
|
1013
|
-
try {
|
|
1014
|
-
const files = await promises.readdir(requestPath);
|
|
1015
|
-
const listing = eta$1.renderString(`
|
|
1016
|
-
<!DOCTYPE html>
|
|
1017
|
-
<html>
|
|
1018
|
-
<head>
|
|
1019
|
-
<title>Index of <%= it.relative %></title>
|
|
1020
|
-
<style>
|
|
1021
|
-
body { font-family: system-ui, -apple-system, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
|
1022
|
-
ul { list-style: none; padding: 0; }
|
|
1023
|
-
li { padding: 0.5rem 0; border-bottom: 1px solid #eee; }
|
|
1024
|
-
a { text-decoration: none; color: #0066cc; }
|
|
1025
|
-
a:hover { text-decoration: underline; }
|
|
1026
|
-
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
|
|
1027
|
-
</style>
|
|
1028
|
-
</head>
|
|
1029
|
-
<body>
|
|
1030
|
-
<h1>Index of <%= it.relative %></h1>
|
|
1031
|
-
<ul>
|
|
1032
|
-
<% if (it.relative !== '/') { %>
|
|
1033
|
-
<li><a href="../">../</a></li>
|
|
1034
|
-
<% } %>
|
|
1035
|
-
<% it.files.forEach(function(f) { %>
|
|
1036
|
-
<li><a href="<%= f %>"><%= f %></a></li>
|
|
1037
|
-
<% }) %>
|
|
1038
|
-
</ul>
|
|
1039
|
-
</body>
|
|
1040
|
-
</html>
|
|
1041
|
-
`, { relative, files, join: path.join });
|
|
1042
|
-
return new Response(listing, { headers: { "Content-Type": "text/html" } });
|
|
1043
|
-
} catch (e) {
|
|
1044
|
-
return ctx.json({ error: "Internal Server Error" }, 500);
|
|
1045
|
-
}
|
|
1046
|
-
} else {
|
|
1047
|
-
return ctx.json({ error: "Forbidden" }, 403);
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
const file = Bun.file(finalPath);
|
|
1052
|
-
let response = new Response(file);
|
|
1053
|
-
if (config.hooks?.onResponse) {
|
|
1054
|
-
const hooked = await config.hooks.onResponse(ctx, response);
|
|
1055
|
-
if (hooked) response = hooked;
|
|
1056
|
-
}
|
|
1057
|
-
return response;
|
|
1642
|
+
serveStatic(null, config, prefix);
|
|
1643
|
+
const routeHandler = async (ctx) => {
|
|
1644
|
+
const runner = serveStatic(ctx, config, prefix);
|
|
1645
|
+
return runner();
|
|
1058
1646
|
};
|
|
1059
1647
|
let groupName = "Static";
|
|
1060
1648
|
const segments = normalizedPrefix.split("/").filter(Boolean);
|
|
@@ -1073,8 +1661,8 @@ class ShokupanRouter {
|
|
|
1073
1661
|
const pattern = `^${normalizedPrefix}(/.*)?$`;
|
|
1074
1662
|
const regex = new RegExp(pattern);
|
|
1075
1663
|
const displayPath = normalizedPrefix === "/" ? "/*" : normalizedPrefix + "/*";
|
|
1076
|
-
this.add({ method: "GET", path: displayPath, handler, spec, regex });
|
|
1077
|
-
this.add({ method: "HEAD", path: displayPath, handler, spec, regex });
|
|
1664
|
+
this.add({ method: "GET", path: displayPath, handler: routeHandler, spec, regex });
|
|
1665
|
+
this.add({ method: "HEAD", path: displayPath, handler: routeHandler, spec, regex });
|
|
1078
1666
|
return this;
|
|
1079
1667
|
}
|
|
1080
1668
|
/**
|
|
@@ -1109,137 +1697,23 @@ class ShokupanRouter {
|
|
|
1109
1697
|
}
|
|
1110
1698
|
/**
|
|
1111
1699
|
* Generates an OpenAPI 3.1 Document by recursing through the router and its descendants.
|
|
1700
|
+
* Now includes runtime analysis of handler functions to infer request/response types.
|
|
1112
1701
|
*/
|
|
1113
1702
|
generateApiSpec(options = {}) {
|
|
1114
|
-
|
|
1115
|
-
const tagGroups = /* @__PURE__ */ new Map();
|
|
1116
|
-
const defaultTagGroup = options.defaultTagGroup || "General";
|
|
1117
|
-
const defaultTagName = options.defaultTag || "Application";
|
|
1118
|
-
const collect = (router, prefix = "", currentGroup = defaultTagGroup, defaultTag = defaultTagName) => {
|
|
1119
|
-
let group = currentGroup;
|
|
1120
|
-
let tag = defaultTag;
|
|
1121
|
-
if (router.config?.group) {
|
|
1122
|
-
group = router.config.group;
|
|
1123
|
-
}
|
|
1124
|
-
if (router.config?.name) {
|
|
1125
|
-
tag = router.config.name;
|
|
1126
|
-
} else {
|
|
1127
|
-
const mountPath = router[$mountPath];
|
|
1128
|
-
if (mountPath && mountPath !== "/") {
|
|
1129
|
-
const segments = mountPath.split("/").filter(Boolean);
|
|
1130
|
-
if (segments.length > 0) {
|
|
1131
|
-
const lastSegment = segments[segments.length - 1];
|
|
1132
|
-
const humanized = lastSegment.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1133
|
-
tag = humanized;
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
if (!tagGroups.has(group)) {
|
|
1138
|
-
tagGroups.set(group, /* @__PURE__ */ new Set());
|
|
1139
|
-
}
|
|
1140
|
-
for (const route of router.routes) {
|
|
1141
|
-
const routeGroup = route.group || group;
|
|
1142
|
-
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1143
|
-
const cleanSubPath = route.path.startsWith("/") ? route.path : "/" + route.path;
|
|
1144
|
-
let fullPath = cleanPrefix + cleanSubPath || "/";
|
|
1145
|
-
fullPath = fullPath.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
|
|
1146
|
-
if (!paths[fullPath]) {
|
|
1147
|
-
paths[fullPath] = {};
|
|
1148
|
-
}
|
|
1149
|
-
const operation = {
|
|
1150
|
-
responses: {
|
|
1151
|
-
200: { description: "OK" }
|
|
1152
|
-
}
|
|
1153
|
-
};
|
|
1154
|
-
if (route.keys.length > 0) {
|
|
1155
|
-
operation.parameters = route.keys.map((key) => ({
|
|
1156
|
-
name: key,
|
|
1157
|
-
in: "path",
|
|
1158
|
-
required: true,
|
|
1159
|
-
schema: { type: "string" }
|
|
1160
|
-
}));
|
|
1161
|
-
}
|
|
1162
|
-
if (route.guards) {
|
|
1163
|
-
for (const guard of route.guards) {
|
|
1164
|
-
if (guard.spec) {
|
|
1165
|
-
deepMerge(operation, guard.spec);
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
if (route.handlerSpec) {
|
|
1170
|
-
deepMerge(operation, route.handlerSpec);
|
|
1171
|
-
}
|
|
1172
|
-
if (!operation.tags || operation.tags.length === 0) {
|
|
1173
|
-
operation.tags = [tag];
|
|
1174
|
-
}
|
|
1175
|
-
if (operation.tags) {
|
|
1176
|
-
operation.tags = Array.from(new Set(operation.tags));
|
|
1177
|
-
for (const t of operation.tags) {
|
|
1178
|
-
if (!tagGroups.has(routeGroup)) {
|
|
1179
|
-
tagGroups.set(routeGroup, /* @__PURE__ */ new Set());
|
|
1180
|
-
}
|
|
1181
|
-
tagGroups.get(routeGroup)?.add(t);
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
const methodLower = route.method.toLowerCase();
|
|
1185
|
-
if (methodLower === "all") {
|
|
1186
|
-
["get", "post", "put", "delete", "patch"].forEach((m) => {
|
|
1187
|
-
if (!paths[fullPath][m]) {
|
|
1188
|
-
paths[fullPath][m] = { ...operation };
|
|
1189
|
-
}
|
|
1190
|
-
});
|
|
1191
|
-
} else {
|
|
1192
|
-
paths[fullPath][methodLower] = operation;
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
for (const controller of router[$childControllers]) {
|
|
1196
|
-
const mountPath = controller[$mountPath] || "";
|
|
1197
|
-
prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1198
|
-
mountPath.startsWith("/") ? mountPath : "/" + mountPath;
|
|
1199
|
-
const controllerName = controller.constructor.name || "UnknownController";
|
|
1200
|
-
tagGroups.get(group)?.add(controllerName);
|
|
1201
|
-
}
|
|
1202
|
-
for (const child of router[$childRouters]) {
|
|
1203
|
-
const mountPath = child[$mountPath];
|
|
1204
|
-
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1205
|
-
const cleanMount = mountPath.startsWith("/") ? mountPath : "/" + mountPath;
|
|
1206
|
-
const nextPrefix = cleanPrefix + cleanMount || "/";
|
|
1207
|
-
collect(child, nextPrefix, group, tag);
|
|
1208
|
-
}
|
|
1209
|
-
};
|
|
1210
|
-
collect(this);
|
|
1211
|
-
const xTagGroups = [];
|
|
1212
|
-
for (const [name, tags] of tagGroups) {
|
|
1213
|
-
xTagGroups.push({
|
|
1214
|
-
name,
|
|
1215
|
-
tags: Array.from(tags).sort()
|
|
1216
|
-
});
|
|
1217
|
-
}
|
|
1218
|
-
return {
|
|
1219
|
-
openapi: "3.1.0",
|
|
1220
|
-
info: {
|
|
1221
|
-
title: "Shokupan API",
|
|
1222
|
-
version: "1.0.0",
|
|
1223
|
-
...options.info
|
|
1224
|
-
},
|
|
1225
|
-
paths,
|
|
1226
|
-
components: options.components,
|
|
1227
|
-
servers: options.servers,
|
|
1228
|
-
tags: options.tags,
|
|
1229
|
-
externalDocs: options.externalDocs,
|
|
1230
|
-
"x-tagGroups": xTagGroups
|
|
1231
|
-
};
|
|
1703
|
+
return generateOpenApi(this, options);
|
|
1232
1704
|
}
|
|
1233
1705
|
}
|
|
1234
1706
|
const defaults = {
|
|
1235
1707
|
port: 3e3,
|
|
1236
1708
|
hostname: "localhost",
|
|
1237
1709
|
development: process.env.NODE_ENV !== "production",
|
|
1238
|
-
enableAsyncLocalStorage: false
|
|
1710
|
+
enableAsyncLocalStorage: false,
|
|
1711
|
+
reusePort: false
|
|
1239
1712
|
};
|
|
1240
1713
|
api.trace.getTracer("shokupan.application");
|
|
1241
1714
|
class Shokupan extends ShokupanRouter {
|
|
1242
1715
|
applicationConfig = {};
|
|
1716
|
+
openApiSpec;
|
|
1243
1717
|
middleware = [];
|
|
1244
1718
|
get logger() {
|
|
1245
1719
|
return this.applicationConfig.logger;
|
|
@@ -1257,24 +1731,41 @@ class Shokupan extends ShokupanRouter {
|
|
|
1257
1731
|
this.middleware.push(middleware);
|
|
1258
1732
|
return this;
|
|
1259
1733
|
}
|
|
1734
|
+
startupHooks = [];
|
|
1735
|
+
/**
|
|
1736
|
+
* Registers a callback to be executed before the server starts listening.
|
|
1737
|
+
*/
|
|
1738
|
+
onStart(callback) {
|
|
1739
|
+
this.startupHooks.push(callback);
|
|
1740
|
+
return this;
|
|
1741
|
+
}
|
|
1260
1742
|
/**
|
|
1261
1743
|
* Starts the application server.
|
|
1262
1744
|
*
|
|
1263
1745
|
* @param port - The port to listen on. If not specified, the port from the configuration is used. If that is not specified, port 3000 is used.
|
|
1264
1746
|
* @returns The server instance.
|
|
1265
1747
|
*/
|
|
1266
|
-
listen(port) {
|
|
1748
|
+
async listen(port) {
|
|
1267
1749
|
const finalPort = port ?? this.applicationConfig.port ?? 3e3;
|
|
1268
1750
|
if (finalPort < 0 || finalPort > 65535) {
|
|
1269
1751
|
throw new Error("Invalid port number");
|
|
1270
1752
|
}
|
|
1753
|
+
for (const hook of this.startupHooks) {
|
|
1754
|
+
await hook();
|
|
1755
|
+
}
|
|
1756
|
+
if (this.applicationConfig.enableOpenApiGen) {
|
|
1757
|
+
this.openApiSpec = await generateOpenApi(this);
|
|
1758
|
+
}
|
|
1271
1759
|
if (port === 0 && process.platform === "linux") ;
|
|
1272
|
-
const
|
|
1760
|
+
const serveOptions = {
|
|
1273
1761
|
port: finalPort,
|
|
1274
1762
|
hostname: this.applicationConfig.hostname,
|
|
1275
1763
|
development: this.applicationConfig.development,
|
|
1276
|
-
fetch: this.fetch.bind(this)
|
|
1277
|
-
|
|
1764
|
+
fetch: this.fetch.bind(this),
|
|
1765
|
+
reusePort: this.applicationConfig.reusePort,
|
|
1766
|
+
idleTimeout: this.applicationConfig.readTimeout ? this.applicationConfig.readTimeout / 1e3 : void 0
|
|
1767
|
+
};
|
|
1768
|
+
const server = this.applicationConfig.serverFactory ? await this.applicationConfig.serverFactory(serveOptions) : Bun.serve(serveOptions);
|
|
1278
1769
|
console.log(`Shokupan server listening on http://${server.hostname}:${server.port}`);
|
|
1279
1770
|
return server;
|
|
1280
1771
|
}
|
|
@@ -1325,9 +1816,10 @@ class Shokupan extends ShokupanRouter {
|
|
|
1325
1816
|
* This logic contains the middleware chain and router dispatch.
|
|
1326
1817
|
*
|
|
1327
1818
|
* @param req - The request to handle.
|
|
1819
|
+
* @param server - The server instance.
|
|
1328
1820
|
* @returns The response to send.
|
|
1329
1821
|
*/
|
|
1330
|
-
async fetch(req) {
|
|
1822
|
+
async fetch(req, server) {
|
|
1331
1823
|
const tracer2 = api.trace.getTracer("shokupan.application");
|
|
1332
1824
|
const store = asyncContext.getStore();
|
|
1333
1825
|
const attrs = {
|
|
@@ -1344,10 +1836,13 @@ class Shokupan extends ShokupanRouter {
|
|
|
1344
1836
|
ctxMap.set("request", req);
|
|
1345
1837
|
const runCallback = () => {
|
|
1346
1838
|
const request = req;
|
|
1839
|
+
const ctx2 = new ShokupanContext(request, server, void 0, this);
|
|
1347
1840
|
const handle = async () => {
|
|
1348
|
-
const ctx2 = new ShokupanContext(request);
|
|
1349
|
-
const fn = compose(this.middleware);
|
|
1350
1841
|
try {
|
|
1842
|
+
if (this.applicationConfig.hooks?.onRequestStart) {
|
|
1843
|
+
await this.applicationConfig.hooks.onRequestStart(ctx2);
|
|
1844
|
+
}
|
|
1845
|
+
const fn = compose(this.middleware);
|
|
1351
1846
|
const result = await fn(ctx2, async () => {
|
|
1352
1847
|
const match = this.find(req.method, ctx2.path);
|
|
1353
1848
|
if (match) {
|
|
@@ -1356,17 +1851,24 @@ class Shokupan extends ShokupanRouter {
|
|
|
1356
1851
|
}
|
|
1357
1852
|
return null;
|
|
1358
1853
|
});
|
|
1854
|
+
let response;
|
|
1359
1855
|
if (result instanceof Response) {
|
|
1360
|
-
|
|
1361
|
-
}
|
|
1362
|
-
if (result === null || result === void 0) {
|
|
1856
|
+
response = result;
|
|
1857
|
+
} else if (result === null || result === void 0) {
|
|
1363
1858
|
span.setAttribute("http.status_code", 404);
|
|
1364
|
-
|
|
1859
|
+
response = ctx2.text("Not Found", 404);
|
|
1860
|
+
} else if (typeof result === "object") {
|
|
1861
|
+
response = ctx2.json(result);
|
|
1862
|
+
} else {
|
|
1863
|
+
response = ctx2.text(String(result));
|
|
1864
|
+
}
|
|
1865
|
+
if (this.applicationConfig.hooks?.onRequestEnd) {
|
|
1866
|
+
await this.applicationConfig.hooks.onRequestEnd(ctx2);
|
|
1365
1867
|
}
|
|
1366
|
-
if (
|
|
1367
|
-
|
|
1868
|
+
if (this.applicationConfig.hooks?.onResponseStart) {
|
|
1869
|
+
await this.applicationConfig.hooks.onResponseStart(ctx2, response);
|
|
1368
1870
|
}
|
|
1369
|
-
return
|
|
1871
|
+
return response;
|
|
1370
1872
|
} catch (err) {
|
|
1371
1873
|
console.error(err);
|
|
1372
1874
|
span.recordException(err);
|
|
@@ -1374,10 +1876,46 @@ class Shokupan extends ShokupanRouter {
|
|
|
1374
1876
|
const status = err.status || err.statusCode || 500;
|
|
1375
1877
|
const body = { error: err.message || "Internal Server Error" };
|
|
1376
1878
|
if (err.errors) body.errors = err.errors;
|
|
1879
|
+
if (this.applicationConfig.hooks?.onError) {
|
|
1880
|
+
try {
|
|
1881
|
+
await this.applicationConfig.hooks.onError(err, ctx2);
|
|
1882
|
+
} catch (hookErr) {
|
|
1883
|
+
console.error("Error in onError hook:", hookErr);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1377
1886
|
return ctx2.json(body, status);
|
|
1378
1887
|
}
|
|
1379
1888
|
};
|
|
1380
|
-
|
|
1889
|
+
let executionPromise = handle();
|
|
1890
|
+
const timeoutMs = this.applicationConfig.requestTimeout;
|
|
1891
|
+
if (timeoutMs && timeoutMs > 0 && this.applicationConfig.hooks?.onRequestTimeout) {
|
|
1892
|
+
let timeoutId;
|
|
1893
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1894
|
+
timeoutId = setTimeout(async () => {
|
|
1895
|
+
try {
|
|
1896
|
+
if (this.applicationConfig.hooks?.onRequestTimeout) {
|
|
1897
|
+
await this.applicationConfig.hooks.onRequestTimeout(ctx2);
|
|
1898
|
+
}
|
|
1899
|
+
} catch (e) {
|
|
1900
|
+
console.error("Error in onRequestTimeout hook:", e);
|
|
1901
|
+
}
|
|
1902
|
+
reject(new Error("Request Timeout"));
|
|
1903
|
+
}, timeoutMs);
|
|
1904
|
+
});
|
|
1905
|
+
executionPromise = Promise.race([executionPromise, timeoutPromise]).finally(() => clearTimeout(timeoutId));
|
|
1906
|
+
}
|
|
1907
|
+
return executionPromise.catch((err) => {
|
|
1908
|
+
if (err.message === "Request Timeout") {
|
|
1909
|
+
return ctx2.text("Request Timeout", 408);
|
|
1910
|
+
}
|
|
1911
|
+
console.error("Unexpected error in request execution:", err);
|
|
1912
|
+
return ctx2.text("Internal Server Error", 500);
|
|
1913
|
+
}).then(async (res) => {
|
|
1914
|
+
if (this.applicationConfig.hooks?.onResponseEnd) {
|
|
1915
|
+
await this.applicationConfig.hooks.onResponseEnd(ctx2, res);
|
|
1916
|
+
}
|
|
1917
|
+
return res;
|
|
1918
|
+
}).finally(() => span.end());
|
|
1381
1919
|
};
|
|
1382
1920
|
if (this.applicationConfig.enableAsyncLocalStorage) {
|
|
1383
1921
|
return asyncContext.run(ctxMap, runCallback);
|
|
@@ -1435,8 +1973,8 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1435
1973
|
init() {
|
|
1436
1974
|
for (const [providerName, providerConfig] of Object.entries(this.authConfig.providers)) {
|
|
1437
1975
|
if (!providerConfig) continue;
|
|
1438
|
-
const
|
|
1439
|
-
if (!
|
|
1976
|
+
const provider = this.getProviderInstance(providerName, providerConfig);
|
|
1977
|
+
if (!provider) {
|
|
1440
1978
|
continue;
|
|
1441
1979
|
}
|
|
1442
1980
|
this.get(`/auth/${providerName}/login`, async (ctx) => {
|
|
@@ -1444,15 +1982,15 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1444
1982
|
const codeVerifier = providerName === "google" || providerName === "microsoft" || providerName === "auth0" || providerName === "okta" ? arctic.generateCodeVerifier() : void 0;
|
|
1445
1983
|
const scopes = providerConfig.scopes || [];
|
|
1446
1984
|
let url;
|
|
1447
|
-
if (
|
|
1448
|
-
url = await
|
|
1449
|
-
} else if (
|
|
1450
|
-
url = await
|
|
1451
|
-
} else if (
|
|
1452
|
-
url = await
|
|
1453
|
-
} else if (
|
|
1985
|
+
if (provider instanceof arctic.GitHub) {
|
|
1986
|
+
url = await provider.createAuthorizationURL(state, scopes);
|
|
1987
|
+
} else if (provider instanceof arctic.Google || provider instanceof arctic.MicrosoftEntraId || provider instanceof arctic.Auth0 || provider instanceof arctic.Okta) {
|
|
1988
|
+
url = await provider.createAuthorizationURL(state, codeVerifier, scopes);
|
|
1989
|
+
} else if (provider instanceof arctic.Apple) {
|
|
1990
|
+
url = await provider.createAuthorizationURL(state, scopes);
|
|
1991
|
+
} else if (provider instanceof arctic.OAuth2Client) {
|
|
1454
1992
|
if (!providerConfig.authUrl) return ctx.text("Config error: authUrl required for oauth2", 500);
|
|
1455
|
-
url = await
|
|
1993
|
+
url = await provider.createAuthorizationURL(providerConfig.authUrl, state, scopes);
|
|
1456
1994
|
} else {
|
|
1457
1995
|
return ctx.text("Provider config error", 500);
|
|
1458
1996
|
}
|
|
@@ -1475,19 +2013,19 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1475
2013
|
try {
|
|
1476
2014
|
let tokens;
|
|
1477
2015
|
let idToken;
|
|
1478
|
-
if (
|
|
1479
|
-
tokens = await
|
|
1480
|
-
} else if (
|
|
2016
|
+
if (provider instanceof arctic.GitHub) {
|
|
2017
|
+
tokens = await provider.validateAuthorizationCode(code);
|
|
2018
|
+
} else if (provider instanceof arctic.Google || provider instanceof arctic.MicrosoftEntraId) {
|
|
1481
2019
|
if (!storedVerifier) return ctx.text("Missing verifier", 400);
|
|
1482
|
-
tokens = await
|
|
1483
|
-
} else if (
|
|
1484
|
-
tokens = await
|
|
1485
|
-
} else if (
|
|
1486
|
-
tokens = await
|
|
2020
|
+
tokens = await provider.validateAuthorizationCode(code, storedVerifier);
|
|
2021
|
+
} else if (provider instanceof arctic.Auth0 || provider instanceof arctic.Okta) {
|
|
2022
|
+
tokens = await provider.validateAuthorizationCode(code, storedVerifier || "");
|
|
2023
|
+
} else if (provider instanceof arctic.Apple) {
|
|
2024
|
+
tokens = await provider.validateAuthorizationCode(code);
|
|
1487
2025
|
idToken = tokens.idToken;
|
|
1488
|
-
} else if (
|
|
2026
|
+
} else if (provider instanceof arctic.OAuth2Client) {
|
|
1489
2027
|
if (!providerConfig.tokenUrl) return ctx.text("Config error: tokenUrl required for oauth2", 500);
|
|
1490
|
-
tokens = await
|
|
2028
|
+
tokens = await provider.validateAuthorizationCode(providerConfig.tokenUrl, code, null);
|
|
1491
2029
|
}
|
|
1492
2030
|
const accessToken = tokens.accessToken || tokens.access_token;
|
|
1493
2031
|
const user = await this.fetchUser(providerName, accessToken, providerConfig, idToken);
|
|
@@ -1504,9 +2042,9 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1504
2042
|
});
|
|
1505
2043
|
}
|
|
1506
2044
|
}
|
|
1507
|
-
async fetchUser(
|
|
1508
|
-
let user = { id: "unknown", provider
|
|
1509
|
-
if (
|
|
2045
|
+
async fetchUser(provider, token, config, idToken) {
|
|
2046
|
+
let user = { id: "unknown", provider };
|
|
2047
|
+
if (provider === "github") {
|
|
1510
2048
|
const res = await fetch("https://api.github.com/user", {
|
|
1511
2049
|
headers: { Authorization: `Bearer ${token}` }
|
|
1512
2050
|
});
|
|
@@ -1516,10 +2054,10 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1516
2054
|
name: data.name || data.login,
|
|
1517
2055
|
email: data.email,
|
|
1518
2056
|
picture: data.avatar_url,
|
|
1519
|
-
provider
|
|
2057
|
+
provider,
|
|
1520
2058
|
raw: data
|
|
1521
2059
|
};
|
|
1522
|
-
} else if (
|
|
2060
|
+
} else if (provider === "google") {
|
|
1523
2061
|
const res = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
|
|
1524
2062
|
headers: { Authorization: `Bearer ${token}` }
|
|
1525
2063
|
});
|
|
@@ -1529,10 +2067,10 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1529
2067
|
name: data.name,
|
|
1530
2068
|
email: data.email,
|
|
1531
2069
|
picture: data.picture,
|
|
1532
|
-
provider
|
|
2070
|
+
provider,
|
|
1533
2071
|
raw: data
|
|
1534
2072
|
};
|
|
1535
|
-
} else if (
|
|
2073
|
+
} else if (provider === "microsoft") {
|
|
1536
2074
|
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
|
|
1537
2075
|
headers: { Authorization: `Bearer ${token}` }
|
|
1538
2076
|
});
|
|
@@ -1541,12 +2079,12 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1541
2079
|
id: data.id,
|
|
1542
2080
|
name: data.displayName,
|
|
1543
2081
|
email: data.mail || data.userPrincipalName,
|
|
1544
|
-
provider
|
|
2082
|
+
provider,
|
|
1545
2083
|
raw: data
|
|
1546
2084
|
};
|
|
1547
|
-
} else if (
|
|
2085
|
+
} else if (provider === "auth0" || provider === "okta") {
|
|
1548
2086
|
const domain = config.domain.startsWith("http") ? config.domain : `https://${config.domain}`;
|
|
1549
|
-
const endpoint =
|
|
2087
|
+
const endpoint = provider === "auth0" ? `${domain}/userinfo` : `${domain}/oauth2/v1/userinfo`;
|
|
1550
2088
|
const res = await fetch(endpoint, {
|
|
1551
2089
|
headers: { Authorization: `Bearer ${token}` }
|
|
1552
2090
|
});
|
|
@@ -1556,20 +2094,20 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1556
2094
|
name: data.name,
|
|
1557
2095
|
email: data.email,
|
|
1558
2096
|
picture: data.picture,
|
|
1559
|
-
provider
|
|
2097
|
+
provider,
|
|
1560
2098
|
raw: data
|
|
1561
2099
|
};
|
|
1562
|
-
} else if (
|
|
2100
|
+
} else if (provider === "apple") {
|
|
1563
2101
|
if (idToken) {
|
|
1564
2102
|
const payload = jose__namespace.decodeJwt(idToken);
|
|
1565
2103
|
user = {
|
|
1566
2104
|
id: payload.sub,
|
|
1567
2105
|
email: payload["email"],
|
|
1568
|
-
provider
|
|
2106
|
+
provider,
|
|
1569
2107
|
raw: payload
|
|
1570
2108
|
};
|
|
1571
2109
|
}
|
|
1572
|
-
} else if (
|
|
2110
|
+
} else if (provider === "oauth2") {
|
|
1573
2111
|
if (config.userInfoUrl) {
|
|
1574
2112
|
const res = await fetch(config.userInfoUrl, {
|
|
1575
2113
|
headers: { Authorization: `Bearer ${token}` }
|
|
@@ -1580,7 +2118,7 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1580
2118
|
name: data.name,
|
|
1581
2119
|
email: data.email,
|
|
1582
2120
|
picture: data.picture,
|
|
1583
|
-
provider
|
|
2121
|
+
provider,
|
|
1584
2122
|
raw: data
|
|
1585
2123
|
};
|
|
1586
2124
|
}
|
|
@@ -1721,6 +2259,66 @@ function Cors(options = {}) {
|
|
|
1721
2259
|
return response;
|
|
1722
2260
|
};
|
|
1723
2261
|
}
|
|
2262
|
+
function useExpress(expressMiddleware) {
|
|
2263
|
+
return async (ctx, next) => {
|
|
2264
|
+
return new Promise((resolve, reject) => {
|
|
2265
|
+
const reqStore = {
|
|
2266
|
+
method: ctx.method,
|
|
2267
|
+
url: ctx.url.pathname + ctx.url.search,
|
|
2268
|
+
path: ctx.url.pathname,
|
|
2269
|
+
query: ctx.query,
|
|
2270
|
+
headers: ctx.headers,
|
|
2271
|
+
get: (name) => ctx.headers.get(name)
|
|
2272
|
+
};
|
|
2273
|
+
const req = new Proxy(ctx.request, {
|
|
2274
|
+
get(target, prop) {
|
|
2275
|
+
if (prop in reqStore) return reqStore[prop];
|
|
2276
|
+
const val = target[prop];
|
|
2277
|
+
if (typeof val === "function") return val.bind(target);
|
|
2278
|
+
return val;
|
|
2279
|
+
},
|
|
2280
|
+
set(target, prop, value) {
|
|
2281
|
+
reqStore[prop] = value;
|
|
2282
|
+
ctx.state[prop] = value;
|
|
2283
|
+
return true;
|
|
2284
|
+
}
|
|
2285
|
+
});
|
|
2286
|
+
const res = {
|
|
2287
|
+
locals: {},
|
|
2288
|
+
statusCode: 200,
|
|
2289
|
+
setHeader: (name, value) => {
|
|
2290
|
+
ctx.response.headers.set(name, value);
|
|
2291
|
+
},
|
|
2292
|
+
set: (name, value) => {
|
|
2293
|
+
ctx.response.headers.set(name, value);
|
|
2294
|
+
},
|
|
2295
|
+
end: (chunk) => {
|
|
2296
|
+
resolve(new Response(chunk, { status: res.statusCode }));
|
|
2297
|
+
},
|
|
2298
|
+
status: (code) => {
|
|
2299
|
+
res.statusCode = code;
|
|
2300
|
+
return res;
|
|
2301
|
+
},
|
|
2302
|
+
send: (body) => {
|
|
2303
|
+
let content = body;
|
|
2304
|
+
if (typeof body === "object") content = JSON.stringify(body);
|
|
2305
|
+
resolve(new Response(content, { status: res.statusCode }));
|
|
2306
|
+
},
|
|
2307
|
+
json: (body) => {
|
|
2308
|
+
resolve(Response.json(body, { status: res.statusCode }));
|
|
2309
|
+
}
|
|
2310
|
+
};
|
|
2311
|
+
try {
|
|
2312
|
+
expressMiddleware(req, res, (err) => {
|
|
2313
|
+
if (err) return reject(err);
|
|
2314
|
+
resolve(next());
|
|
2315
|
+
});
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
reject(err);
|
|
2318
|
+
}
|
|
2319
|
+
});
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
1724
2322
|
function RateLimit(options = {}) {
|
|
1725
2323
|
const windowMs = options.windowMs || 60 * 1e3;
|
|
1726
2324
|
const max = options.max || 5;
|
|
@@ -1811,10 +2409,43 @@ class ScalarPlugin extends ShokupanRouter {
|
|
|
1811
2409
|
this.get("/scalar.js", (ctx) => {
|
|
1812
2410
|
return ctx.file(__dirname + "/../../node_modules/@scalar/api-reference/dist/browser/standalone.js");
|
|
1813
2411
|
});
|
|
1814
|
-
this.get("/openapi.json", (ctx) => {
|
|
1815
|
-
|
|
2412
|
+
this.get("/openapi.json", async (ctx) => {
|
|
2413
|
+
let spec;
|
|
2414
|
+
if (this.root.openApiSpec) {
|
|
2415
|
+
try {
|
|
2416
|
+
spec = structuredClone(this.root.openApiSpec);
|
|
2417
|
+
} catch (e) {
|
|
2418
|
+
spec = Object.assign({}, this.root.openApiSpec);
|
|
2419
|
+
}
|
|
2420
|
+
} else {
|
|
2421
|
+
spec = await (this.root || this).generateApiSpec();
|
|
2422
|
+
}
|
|
2423
|
+
if (this.pluginOptions.baseDocument) {
|
|
2424
|
+
deepMerge(spec, this.pluginOptions.baseDocument);
|
|
2425
|
+
}
|
|
2426
|
+
return ctx.json(spec);
|
|
1816
2427
|
});
|
|
1817
2428
|
}
|
|
2429
|
+
// New lifecycle method to be called by router.mount
|
|
2430
|
+
onMount(parent) {
|
|
2431
|
+
if (parent.onStart) {
|
|
2432
|
+
parent.onStart(async () => {
|
|
2433
|
+
if (this.pluginOptions.enableStaticAnalysis) {
|
|
2434
|
+
try {
|
|
2435
|
+
const entrypoint = process.argv[1];
|
|
2436
|
+
console.log(`[ScalarPlugin] Running eager static analysis on entrypoint: ${entrypoint}`);
|
|
2437
|
+
const analyzer = new openapiAnalyzer.OpenAPIAnalyzer(process.cwd(), entrypoint);
|
|
2438
|
+
let staticSpec = await analyzer.analyze();
|
|
2439
|
+
if (!this.pluginOptions.baseDocument) this.pluginOptions.baseDocument = {};
|
|
2440
|
+
deepMerge(this.pluginOptions.baseDocument, staticSpec);
|
|
2441
|
+
console.log("[ScalarPlugin] Static analysis completed successfully.");
|
|
2442
|
+
} catch (err) {
|
|
2443
|
+
console.error("[ScalarPlugin] Failed to run static analysis:", err);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
1818
2449
|
}
|
|
1819
2450
|
function SecurityHeaders(options = {}) {
|
|
1820
2451
|
return async (ctx, next) => {
|
|
@@ -2174,6 +2805,30 @@ async function validateValibotWrapper(wrapper, data) {
|
|
|
2174
2805
|
}
|
|
2175
2806
|
return result.output;
|
|
2176
2807
|
}
|
|
2808
|
+
function isClass(schema) {
|
|
2809
|
+
try {
|
|
2810
|
+
if (typeof schema === "function" && /^\s*class\s+/.test(schema.toString())) {
|
|
2811
|
+
return true;
|
|
2812
|
+
}
|
|
2813
|
+
return typeof schema === "function" && schema.prototype && schema.name;
|
|
2814
|
+
} catch {
|
|
2815
|
+
return false;
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
async function validateClassValidator(schema, data) {
|
|
2819
|
+
const object = classTransformer.plainToInstance(schema, data);
|
|
2820
|
+
try {
|
|
2821
|
+
await classValidator.validateOrReject(object);
|
|
2822
|
+
return object;
|
|
2823
|
+
} catch (errors) {
|
|
2824
|
+
const formattedErrors = Array.isArray(errors) ? errors.map((err) => ({
|
|
2825
|
+
property: err.property,
|
|
2826
|
+
constraints: err.constraints,
|
|
2827
|
+
children: err.children
|
|
2828
|
+
})) : errors;
|
|
2829
|
+
throw new ValidationError(formattedErrors);
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2177
2832
|
const safelyGetBody = async (ctx) => {
|
|
2178
2833
|
const req = ctx.req;
|
|
2179
2834
|
if (req._bodyParsed) {
|
|
@@ -2205,21 +2860,38 @@ const safelyGetBody = async (ctx) => {
|
|
|
2205
2860
|
};
|
|
2206
2861
|
function validate(config) {
|
|
2207
2862
|
return async (ctx, next) => {
|
|
2863
|
+
const dataToValidate = {};
|
|
2864
|
+
if (config.params) dataToValidate.params = ctx.params;
|
|
2865
|
+
let queryObj;
|
|
2866
|
+
if (config.query) {
|
|
2867
|
+
const url = new URL(ctx.req.url);
|
|
2868
|
+
queryObj = Object.fromEntries(url.searchParams.entries());
|
|
2869
|
+
dataToValidate.query = queryObj;
|
|
2870
|
+
}
|
|
2871
|
+
if (config.headers) dataToValidate.headers = Object.fromEntries(ctx.req.headers.entries());
|
|
2872
|
+
let body;
|
|
2873
|
+
if (config.body) {
|
|
2874
|
+
body = await safelyGetBody(ctx);
|
|
2875
|
+
dataToValidate.body = body;
|
|
2876
|
+
}
|
|
2877
|
+
if (ctx.app?.applicationConfig.hooks?.beforeValidate) {
|
|
2878
|
+
await ctx.app.applicationConfig.hooks.beforeValidate(ctx, dataToValidate);
|
|
2879
|
+
}
|
|
2208
2880
|
if (config.params) {
|
|
2209
2881
|
ctx.params = await runValidation(config.params, ctx.params);
|
|
2210
2882
|
}
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
await runValidation(config.query, queryObj);
|
|
2883
|
+
let validQuery;
|
|
2884
|
+
if (config.query && queryObj) {
|
|
2885
|
+
validQuery = await runValidation(config.query, queryObj);
|
|
2215
2886
|
}
|
|
2216
2887
|
if (config.headers) {
|
|
2217
2888
|
const headersObj = Object.fromEntries(ctx.req.headers.entries());
|
|
2218
2889
|
await runValidation(config.headers, headersObj);
|
|
2219
2890
|
}
|
|
2891
|
+
let validBody;
|
|
2220
2892
|
if (config.body) {
|
|
2221
|
-
const
|
|
2222
|
-
|
|
2893
|
+
const b = body ?? await safelyGetBody(ctx);
|
|
2894
|
+
validBody = await runValidation(config.body, b);
|
|
2223
2895
|
const req = ctx.req;
|
|
2224
2896
|
req._bodyValue = validBody;
|
|
2225
2897
|
Object.defineProperty(req, "json", {
|
|
@@ -2228,6 +2900,13 @@ function validate(config) {
|
|
|
2228
2900
|
});
|
|
2229
2901
|
ctx.body = validBody;
|
|
2230
2902
|
}
|
|
2903
|
+
if (ctx.app?.applicationConfig.hooks?.afterValidate) {
|
|
2904
|
+
const validatedData = { ...dataToValidate };
|
|
2905
|
+
if (config.params) validatedData.params = ctx.params;
|
|
2906
|
+
if (config.query) validatedData.query = validQuery;
|
|
2907
|
+
if (config.body) validatedData.body = validBody;
|
|
2908
|
+
await ctx.app.applicationConfig.hooks.afterValidate(ctx, validatedData);
|
|
2909
|
+
}
|
|
2231
2910
|
return next();
|
|
2232
2911
|
};
|
|
2233
2912
|
}
|
|
@@ -2244,6 +2923,18 @@ async function runValidation(schema, data) {
|
|
|
2244
2923
|
if (isValibotWrapper(schema)) {
|
|
2245
2924
|
return validateValibotWrapper(schema, data);
|
|
2246
2925
|
}
|
|
2926
|
+
if (isClass(schema)) {
|
|
2927
|
+
return validateClassValidator(schema, data);
|
|
2928
|
+
}
|
|
2929
|
+
if (isTypeBox(schema)) {
|
|
2930
|
+
return validateTypeBox(schema, data);
|
|
2931
|
+
}
|
|
2932
|
+
if (isAjv(schema)) {
|
|
2933
|
+
return validateAjv(schema, data);
|
|
2934
|
+
}
|
|
2935
|
+
if (isValibotWrapper(schema)) {
|
|
2936
|
+
return validateValibotWrapper(schema, data);
|
|
2937
|
+
}
|
|
2247
2938
|
if (typeof schema === "function") {
|
|
2248
2939
|
return schema(data);
|
|
2249
2940
|
}
|
|
@@ -2262,6 +2953,8 @@ exports.$mountPath = $mountPath;
|
|
|
2262
2953
|
exports.$parent = $parent;
|
|
2263
2954
|
exports.$routeArgs = $routeArgs;
|
|
2264
2955
|
exports.$routeMethods = $routeMethods;
|
|
2956
|
+
exports.$routeSpec = $routeSpec;
|
|
2957
|
+
exports.$routes = $routes;
|
|
2265
2958
|
exports.All = All;
|
|
2266
2959
|
exports.AuthPlugin = AuthPlugin;
|
|
2267
2960
|
exports.Body = Body;
|
|
@@ -2297,9 +2990,11 @@ exports.ShokupanContext = ShokupanContext;
|
|
|
2297
2990
|
exports.ShokupanRequest = ShokupanRequest;
|
|
2298
2991
|
exports.ShokupanResponse = ShokupanResponse;
|
|
2299
2992
|
exports.ShokupanRouter = ShokupanRouter;
|
|
2993
|
+
exports.Spec = Spec;
|
|
2300
2994
|
exports.Use = Use;
|
|
2301
2995
|
exports.ValidationError = ValidationError;
|
|
2302
2996
|
exports.compose = compose;
|
|
2997
|
+
exports.useExpress = useExpress;
|
|
2303
2998
|
exports.valibot = valibot;
|
|
2304
2999
|
exports.validate = validate;
|
|
2305
3000
|
//# sourceMappingURL=index.cjs.map
|