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.js
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import { trace, SpanKind, SpanStatusCode, context } from "@opentelemetry/api";
|
|
2
|
-
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
|
3
|
-
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
4
|
-
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
5
|
-
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
6
|
-
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
7
2
|
import { Eta } from "eta";
|
|
8
3
|
import { stat, readdir } from "fs/promises";
|
|
9
4
|
import { resolve, join, basename } from "path";
|
|
10
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
6
|
import { OAuth2Client, Okta, Auth0, Apple, MicrosoftEntraId, Google, GitHub, generateState, generateCodeVerifier } from "arctic";
|
|
12
7
|
import * as jose from "jose";
|
|
8
|
+
import { OpenAPIAnalyzer } from "./openapi-analyzer-cjdGeQ5a.js";
|
|
13
9
|
import { randomUUID, createHmac } from "crypto";
|
|
14
10
|
import { EventEmitter } from "events";
|
|
11
|
+
import { plainToInstance } from "class-transformer";
|
|
12
|
+
import { validateOrReject } from "class-validator";
|
|
15
13
|
class ShokupanResponse {
|
|
16
14
|
_headers = new Headers();
|
|
17
15
|
_status = 200;
|
|
@@ -67,8 +65,10 @@ class ShokupanResponse {
|
|
|
67
65
|
}
|
|
68
66
|
}
|
|
69
67
|
class ShokupanContext {
|
|
70
|
-
constructor(request, state) {
|
|
68
|
+
constructor(request, server, state, app) {
|
|
71
69
|
this.request = request;
|
|
70
|
+
this.server = server;
|
|
71
|
+
this.app = app;
|
|
72
72
|
this.url = new URL(request.url);
|
|
73
73
|
this.state = state || {};
|
|
74
74
|
this.response = new ShokupanResponse();
|
|
@@ -101,12 +101,55 @@ class ShokupanContext {
|
|
|
101
101
|
get query() {
|
|
102
102
|
return Object.fromEntries(this.url.searchParams);
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Client IP address
|
|
106
|
+
*/
|
|
107
|
+
get ip() {
|
|
108
|
+
return this.server?.requestIP(this.request);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Request hostname (e.g. "localhost")
|
|
112
|
+
*/
|
|
113
|
+
get hostname() {
|
|
114
|
+
return this.url.hostname;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Request host (e.g. "localhost:3000")
|
|
118
|
+
*/
|
|
119
|
+
get host() {
|
|
120
|
+
return this.url.host;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Request protocol (e.g. "http:", "https:")
|
|
124
|
+
*/
|
|
125
|
+
get protocol() {
|
|
126
|
+
return this.url.protocol;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Whether request is secure (https)
|
|
130
|
+
*/
|
|
131
|
+
get secure() {
|
|
132
|
+
return this.url.protocol === "https:";
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Request origin (e.g. "http://localhost:3000")
|
|
136
|
+
*/
|
|
137
|
+
get origin() {
|
|
138
|
+
return this.url.origin;
|
|
139
|
+
}
|
|
104
140
|
/**
|
|
105
141
|
* Request headers
|
|
106
142
|
*/
|
|
107
143
|
get headers() {
|
|
108
144
|
return this.request.headers;
|
|
109
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Get a request header
|
|
148
|
+
* @param name Header name
|
|
149
|
+
*/
|
|
150
|
+
get(name) {
|
|
151
|
+
return this.request.headers.get(name);
|
|
152
|
+
}
|
|
110
153
|
/**
|
|
111
154
|
* Base response object
|
|
112
155
|
*/
|
|
@@ -115,6 +158,8 @@ class ShokupanContext {
|
|
|
115
158
|
}
|
|
116
159
|
/**
|
|
117
160
|
* Helper to set a header on the response
|
|
161
|
+
* @param key Header key
|
|
162
|
+
* @param value Header value
|
|
118
163
|
*/
|
|
119
164
|
set(key, value) {
|
|
120
165
|
this.response.set(key, value);
|
|
@@ -226,6 +271,23 @@ class ShokupanContext {
|
|
|
226
271
|
const status = responseOptions?.status ?? this.response.status;
|
|
227
272
|
return new Response(Bun.file(path, fileOptions), { status, headers });
|
|
228
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* JSX Rendering Function
|
|
276
|
+
*/
|
|
277
|
+
renderer;
|
|
278
|
+
/**
|
|
279
|
+
* Render a JSX element
|
|
280
|
+
* @param element JSX Element
|
|
281
|
+
* @param status HTTP Status
|
|
282
|
+
* @param headers HTTP Headers
|
|
283
|
+
*/
|
|
284
|
+
async jsx(element, args, status, headers) {
|
|
285
|
+
if (!this.renderer) {
|
|
286
|
+
throw new Error("No JSX renderer configured");
|
|
287
|
+
}
|
|
288
|
+
const html = await this.renderer(element, args);
|
|
289
|
+
return this.html(html, status, headers);
|
|
290
|
+
}
|
|
229
291
|
}
|
|
230
292
|
const $isApplication = /* @__PURE__ */ Symbol.for("Shokupan.app");
|
|
231
293
|
const $appRoot = /* @__PURE__ */ Symbol.for("Shokupan.app-root");
|
|
@@ -240,6 +302,8 @@ const $childRouters = /* @__PURE__ */ Symbol.for("Shokupan.child-routers");
|
|
|
240
302
|
const $childControllers = /* @__PURE__ */ Symbol.for("Shokupan.child-controllers");
|
|
241
303
|
const $mountPath = /* @__PURE__ */ Symbol.for("Shokupan.mount-path");
|
|
242
304
|
const $dispatch = /* @__PURE__ */ Symbol.for("Shokupan.dispatch");
|
|
305
|
+
const $routes = /* @__PURE__ */ Symbol.for("Shokupan.routes");
|
|
306
|
+
const $routeSpec = /* @__PURE__ */ Symbol.for("Shokupan.routeSpec");
|
|
243
307
|
const HTTPMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "ALL"];
|
|
244
308
|
var RouteParamType = /* @__PURE__ */ ((RouteParamType2) => {
|
|
245
309
|
RouteParamType2["BODY"] = "BODY";
|
|
@@ -292,6 +356,14 @@ const Query = createParamDecorator(RouteParamType.QUERY);
|
|
|
292
356
|
const Headers$1 = createParamDecorator(RouteParamType.HEADER);
|
|
293
357
|
const Req = createParamDecorator(RouteParamType.REQUEST);
|
|
294
358
|
const Ctx = createParamDecorator(RouteParamType.CONTEXT);
|
|
359
|
+
function Spec(spec) {
|
|
360
|
+
return (target, propertyKey, descriptor) => {
|
|
361
|
+
if (!target[$routeSpec]) {
|
|
362
|
+
target[$routeSpec] = /* @__PURE__ */ new Map();
|
|
363
|
+
}
|
|
364
|
+
target[$routeSpec].set(propertyKey, spec);
|
|
365
|
+
};
|
|
366
|
+
}
|
|
295
367
|
function createMethodDecorator(method) {
|
|
296
368
|
return (path = "/") => {
|
|
297
369
|
return (target, propertyKey, descriptor) => {
|
|
@@ -346,20 +418,6 @@ function Inject(token) {
|
|
|
346
418
|
});
|
|
347
419
|
};
|
|
348
420
|
}
|
|
349
|
-
const provider = new NodeTracerProvider({
|
|
350
|
-
resource: resourceFromAttributes({
|
|
351
|
-
[ATTR_SERVICE_NAME]: "basic-service"
|
|
352
|
-
}),
|
|
353
|
-
spanProcessors: [
|
|
354
|
-
new SimpleSpanProcessor(
|
|
355
|
-
new OTLPTraceExporter({
|
|
356
|
-
url: "http://localhost:4318/v1/traces"
|
|
357
|
-
// Default OTLP port
|
|
358
|
-
})
|
|
359
|
-
)
|
|
360
|
-
]
|
|
361
|
-
});
|
|
362
|
-
provider.register();
|
|
363
421
|
const tracer = trace.getTracer("shokupan.middleware");
|
|
364
422
|
function traceMiddleware(fn, name) {
|
|
365
423
|
const middlewareName = fn.name || "anonymous middleware";
|
|
@@ -449,7 +507,6 @@ class ShokupanRequestBase {
|
|
|
449
507
|
}
|
|
450
508
|
}
|
|
451
509
|
const ShokupanRequest = ShokupanRequestBase;
|
|
452
|
-
const asyncContext = new AsyncLocalStorage();
|
|
453
510
|
function isObject(item) {
|
|
454
511
|
return item && typeof item === "object" && !Array.isArray(item);
|
|
455
512
|
}
|
|
@@ -463,7 +520,17 @@ function deepMerge(target, ...sources) {
|
|
|
463
520
|
deepMerge(target[key], source[key]);
|
|
464
521
|
} else if (Array.isArray(source[key])) {
|
|
465
522
|
if (!target[key]) Object.assign(target, { [key]: [] });
|
|
466
|
-
|
|
523
|
+
if (key === "tags") {
|
|
524
|
+
target[key] = source[key];
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const mergedArray = target[key].concat(source[key]);
|
|
528
|
+
const isPrimitive = (item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean";
|
|
529
|
+
if (mergedArray.every(isPrimitive)) {
|
|
530
|
+
target[key] = Array.from(new Set(mergedArray));
|
|
531
|
+
} else {
|
|
532
|
+
target[key] = mergedArray;
|
|
533
|
+
}
|
|
467
534
|
} else {
|
|
468
535
|
Object.assign(target, { [key]: source[key] });
|
|
469
536
|
}
|
|
@@ -471,12 +538,583 @@ function deepMerge(target, ...sources) {
|
|
|
471
538
|
}
|
|
472
539
|
return deepMerge(target, ...sources);
|
|
473
540
|
}
|
|
541
|
+
function analyzeHandler(handler) {
|
|
542
|
+
const handlerSource = handler.toString();
|
|
543
|
+
const inferredSpec = {};
|
|
544
|
+
if (handlerSource.includes("ctx.body") || handlerSource.includes("await ctx.req.json()")) {
|
|
545
|
+
inferredSpec.requestBody = {
|
|
546
|
+
content: { "application/json": { schema: { type: "object" } } }
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
const queryParams = /* @__PURE__ */ new Map();
|
|
550
|
+
const queryIntMatch = handlerSource.match(/parseInt\(ctx\.query\.(\w+)\)/g);
|
|
551
|
+
if (queryIntMatch) {
|
|
552
|
+
queryIntMatch.forEach((match) => {
|
|
553
|
+
const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
|
|
554
|
+
if (paramName) queryParams.set(paramName, { type: "integer", format: "int32" });
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
const queryFloatMatch = handlerSource.match(/parseFloat\(ctx\.query\.(\w+)\)/g);
|
|
558
|
+
if (queryFloatMatch) {
|
|
559
|
+
queryFloatMatch.forEach((match) => {
|
|
560
|
+
const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
|
|
561
|
+
if (paramName) queryParams.set(paramName, { type: "number", format: "float" });
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
const queryNumberMatch = handlerSource.match(/Number\(ctx\.query\.(\w+)\)/g);
|
|
565
|
+
if (queryNumberMatch) {
|
|
566
|
+
queryNumberMatch.forEach((match) => {
|
|
567
|
+
const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
|
|
568
|
+
if (paramName && !queryParams.has(paramName)) {
|
|
569
|
+
queryParams.set(paramName, { type: "number" });
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
const queryBoolMatch = handlerSource.match(/(?:Boolean\(ctx\.query\.(\w+)\)|!+ctx\.query\.(\w+))/g);
|
|
574
|
+
if (queryBoolMatch) {
|
|
575
|
+
queryBoolMatch.forEach((match) => {
|
|
576
|
+
const paramName = match.match(/ctx\.query\.(\w+)/)?.[1];
|
|
577
|
+
if (paramName && !queryParams.has(paramName)) {
|
|
578
|
+
queryParams.set(paramName, { type: "boolean" });
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
const queryMatch = handlerSource.match(/ctx\.query\.(\w+)/g);
|
|
583
|
+
if (queryMatch) {
|
|
584
|
+
queryMatch.forEach((match) => {
|
|
585
|
+
const paramName = match.split(".")[2];
|
|
586
|
+
if (paramName && !queryParams.has(paramName)) {
|
|
587
|
+
queryParams.set(paramName, { type: "string" });
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
if (queryParams.size > 0) {
|
|
592
|
+
if (!inferredSpec.parameters) inferredSpec.parameters = [];
|
|
593
|
+
queryParams.forEach((schema, paramName) => {
|
|
594
|
+
inferredSpec.parameters.push({
|
|
595
|
+
name: paramName,
|
|
596
|
+
in: "query",
|
|
597
|
+
schema: { type: schema.type, ...schema.format ? { format: schema.format } : {} }
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
const pathParams = /* @__PURE__ */ new Map();
|
|
602
|
+
const paramIntMatch = handlerSource.match(/parseInt\(ctx\.params\.(\w+)\)/g);
|
|
603
|
+
if (paramIntMatch) {
|
|
604
|
+
paramIntMatch.forEach((match) => {
|
|
605
|
+
const paramName = match.match(/ctx\.params\.(\w+)/)?.[1];
|
|
606
|
+
if (paramName) pathParams.set(paramName, { type: "integer", format: "int32" });
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
const paramFloatMatch = handlerSource.match(/parseFloat\(ctx\.params\.(\w+)\)/g);
|
|
610
|
+
if (paramFloatMatch) {
|
|
611
|
+
paramFloatMatch.forEach((match) => {
|
|
612
|
+
const paramName = match.match(/ctx\.params\.(\w+)/)?.[1];
|
|
613
|
+
if (paramName) pathParams.set(paramName, { type: "number", format: "float" });
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
if (pathParams.size > 0) {
|
|
617
|
+
if (!inferredSpec.parameters) inferredSpec.parameters = [];
|
|
618
|
+
pathParams.forEach((schema, paramName) => {
|
|
619
|
+
inferredSpec.parameters.push({
|
|
620
|
+
name: paramName,
|
|
621
|
+
in: "path",
|
|
622
|
+
required: true,
|
|
623
|
+
schema: { type: schema.type, ...schema.format ? { format: schema.format } : {} }
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
const headerMatch = handlerSource.match(/ctx\.get\(['"](\w+)['"]\)/g);
|
|
628
|
+
if (headerMatch) {
|
|
629
|
+
if (!inferredSpec.parameters) inferredSpec.parameters = [];
|
|
630
|
+
headerMatch.forEach((match) => {
|
|
631
|
+
const headerName = match.match(/['"](\w+)['"]/)?.[1];
|
|
632
|
+
if (headerName) {
|
|
633
|
+
inferredSpec.parameters.push({
|
|
634
|
+
name: headerName,
|
|
635
|
+
in: "header",
|
|
636
|
+
schema: { type: "string" }
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
const responses = {};
|
|
642
|
+
if (handlerSource.includes("ctx.json(")) {
|
|
643
|
+
responses["200"] = {
|
|
644
|
+
description: "Successful response",
|
|
645
|
+
content: {
|
|
646
|
+
"application/json": { schema: { type: "object" } }
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
if (handlerSource.includes("ctx.html(")) {
|
|
651
|
+
responses["200"] = {
|
|
652
|
+
description: "Successful response",
|
|
653
|
+
content: {
|
|
654
|
+
"text/html": { schema: { type: "string" } }
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (handlerSource.includes("ctx.text(")) {
|
|
659
|
+
responses["200"] = {
|
|
660
|
+
description: "Successful response",
|
|
661
|
+
content: {
|
|
662
|
+
"text/plain": { schema: { type: "string" } }
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
if (handlerSource.includes("ctx.file(")) {
|
|
667
|
+
responses["200"] = {
|
|
668
|
+
description: "File download",
|
|
669
|
+
content: {
|
|
670
|
+
"application/octet-stream": { schema: { type: "string", format: "binary" } }
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
if (handlerSource.includes("ctx.redirect(")) {
|
|
675
|
+
responses["302"] = {
|
|
676
|
+
description: "Redirect"
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
if (!responses["200"] && /return\s+\{/.test(handlerSource)) {
|
|
680
|
+
responses["200"] = {
|
|
681
|
+
description: "Successful response",
|
|
682
|
+
content: {
|
|
683
|
+
"application/json": { schema: { type: "object" } }
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
const errorStatusMatch = handlerSource.match(/ctx\.(?:json|text|html)\([^)]+,\s*(\d{3,})\)/g);
|
|
688
|
+
if (errorStatusMatch) {
|
|
689
|
+
errorStatusMatch.forEach((match) => {
|
|
690
|
+
const statusCode = match.match(/,\s*(\d{3,})\)/)?.[1];
|
|
691
|
+
if (statusCode && statusCode !== "200") {
|
|
692
|
+
responses[statusCode] = {
|
|
693
|
+
description: `Error response (${statusCode})`
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
if (Object.keys(responses).length > 0) {
|
|
699
|
+
inferredSpec.responses = responses;
|
|
700
|
+
}
|
|
701
|
+
return { inferredSpec };
|
|
702
|
+
}
|
|
703
|
+
async function generateOpenApi(rootRouter, options = {}) {
|
|
704
|
+
const paths = {};
|
|
705
|
+
const tagGroups = /* @__PURE__ */ new Map();
|
|
706
|
+
const defaultTagGroup = options.defaultTagGroup || "General";
|
|
707
|
+
const defaultTagName = options.defaultTag || "Application";
|
|
708
|
+
let astRoutes = [];
|
|
709
|
+
try {
|
|
710
|
+
const { OpenAPIAnalyzer: OpenAPIAnalyzer2 } = await import("./openapi-analyzer-cjdGeQ5a.js");
|
|
711
|
+
const analyzer = new OpenAPIAnalyzer2(process.cwd());
|
|
712
|
+
const { applications } = await analyzer.analyze();
|
|
713
|
+
const appMap = /* @__PURE__ */ new Map();
|
|
714
|
+
applications.forEach((app) => {
|
|
715
|
+
appMap.set(app.name, app);
|
|
716
|
+
if (app.name !== app.className) {
|
|
717
|
+
appMap.set(app.className, app);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
const getExpandedRoutes = (app, prefix = "", seen = /* @__PURE__ */ new Set()) => {
|
|
721
|
+
if (seen.has(app.name)) return [];
|
|
722
|
+
const newSeen = new Set(seen);
|
|
723
|
+
newSeen.add(app.name);
|
|
724
|
+
const expanded = [];
|
|
725
|
+
for (const route of app.routes) {
|
|
726
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
727
|
+
const cleanPath = route.path.startsWith("/") ? route.path : "/" + route.path;
|
|
728
|
+
let joined = cleanPrefix + cleanPath;
|
|
729
|
+
if (joined.length > 1 && joined.endsWith("/")) {
|
|
730
|
+
joined = joined.slice(0, -1);
|
|
731
|
+
}
|
|
732
|
+
expanded.push({
|
|
733
|
+
...route,
|
|
734
|
+
path: joined || "/"
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
if (app.mounted) {
|
|
738
|
+
for (const mount of app.mounted) {
|
|
739
|
+
const targetApp = appMap.get(mount.target);
|
|
740
|
+
if (targetApp) {
|
|
741
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
742
|
+
const mountPrefix = mount.prefix.startsWith("/") ? mount.prefix : "/" + mount.prefix;
|
|
743
|
+
expanded.push(...getExpandedRoutes(targetApp, cleanPrefix + mountPrefix, newSeen));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return expanded;
|
|
748
|
+
};
|
|
749
|
+
applications.forEach((app) => {
|
|
750
|
+
astRoutes.push(...getExpandedRoutes(app));
|
|
751
|
+
});
|
|
752
|
+
const dedupedRoutes = /* @__PURE__ */ new Map();
|
|
753
|
+
for (const route of astRoutes) {
|
|
754
|
+
const key = `${route.method.toUpperCase()}:${route.path}`;
|
|
755
|
+
let score = 0;
|
|
756
|
+
if (route.responseSchema) score += 10;
|
|
757
|
+
if (route.handlerSource) score += 5;
|
|
758
|
+
if (!dedupedRoutes.has(key) || score > dedupedRoutes.get(key).score) {
|
|
759
|
+
dedupedRoutes.set(key, { route, score });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
astRoutes = Array.from(dedupedRoutes.values()).map((v) => v.route);
|
|
763
|
+
} catch (e) {
|
|
764
|
+
console.warn("OpenAPI AST analysis failed or skipped:", e);
|
|
765
|
+
}
|
|
766
|
+
const collect = (router, prefix = "", currentGroup = defaultTagGroup, defaultTag = defaultTagName) => {
|
|
767
|
+
let group = currentGroup;
|
|
768
|
+
let tag = defaultTag;
|
|
769
|
+
if (router.config?.group) group = router.config.group;
|
|
770
|
+
if (router.config?.name) {
|
|
771
|
+
tag = router.config.name;
|
|
772
|
+
} else {
|
|
773
|
+
const mountPath = router[$mountPath];
|
|
774
|
+
if (mountPath && mountPath !== "/") {
|
|
775
|
+
const segments = mountPath.split("/").filter(Boolean);
|
|
776
|
+
if (segments.length > 0) {
|
|
777
|
+
const lastSegment = segments[segments.length - 1];
|
|
778
|
+
tag = lastSegment.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (!tagGroups.has(group)) tagGroups.set(group, /* @__PURE__ */ new Set());
|
|
783
|
+
const routes = router[$routes] || [];
|
|
784
|
+
for (const route of routes) {
|
|
785
|
+
const routeGroup = route.group || group;
|
|
786
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
787
|
+
const cleanSubPath = route.path.startsWith("/") ? route.path : "/" + route.path;
|
|
788
|
+
let fullPath = cleanPrefix + cleanSubPath || "/";
|
|
789
|
+
fullPath = fullPath.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
|
|
790
|
+
if (fullPath.length > 1 && fullPath.endsWith("/")) {
|
|
791
|
+
fullPath = fullPath.slice(0, -1);
|
|
792
|
+
}
|
|
793
|
+
if (!paths[fullPath]) paths[fullPath] = {};
|
|
794
|
+
const operation = {
|
|
795
|
+
responses: { "200": { description: "Successful response" } },
|
|
796
|
+
tags: [tag]
|
|
797
|
+
};
|
|
798
|
+
if (route.guards) {
|
|
799
|
+
for (const guard of route.guards) {
|
|
800
|
+
if (guard.spec) {
|
|
801
|
+
if (guard.spec.security) {
|
|
802
|
+
const existing = operation.security || [];
|
|
803
|
+
for (const req of guard.spec.security) {
|
|
804
|
+
const reqStr = JSON.stringify(req);
|
|
805
|
+
if (!existing.some((e) => JSON.stringify(e) === reqStr)) {
|
|
806
|
+
existing.push(req);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
operation.security = existing;
|
|
810
|
+
}
|
|
811
|
+
if (guard.spec.responses) {
|
|
812
|
+
operation.responses = { ...operation.responses, ...guard.spec.responses };
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
let astMatch = astRoutes.find(
|
|
818
|
+
(r) => r.method.toUpperCase() === route.method.toUpperCase() && r.path === fullPath
|
|
819
|
+
);
|
|
820
|
+
if (!astMatch) {
|
|
821
|
+
let runtimeSource = route.handler.toString();
|
|
822
|
+
if (route.handler.originalHandler) {
|
|
823
|
+
runtimeSource = route.handler.originalHandler.toString();
|
|
824
|
+
}
|
|
825
|
+
const runtimeHandlerSrc = runtimeSource.replace(/\s+/g, " ");
|
|
826
|
+
const sameMethodRoutes = astRoutes.filter((r) => r.method.toUpperCase() === route.method.toUpperCase());
|
|
827
|
+
astMatch = sameMethodRoutes.find((r) => {
|
|
828
|
+
const astHandlerSrc = (r.handlerSource || r.handlerName || "").replace(/\s+/g, " ");
|
|
829
|
+
if (!astHandlerSrc || astHandlerSrc.length < 20) return false;
|
|
830
|
+
const match = runtimeHandlerSrc.includes(astHandlerSrc) || astHandlerSrc.includes(runtimeHandlerSrc) || r.handlerSource && runtimeHandlerSrc.includes(r.handlerSource.substring(0, 50));
|
|
831
|
+
return match;
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
const potentialMatches = astRoutes.filter(
|
|
835
|
+
(r) => r.method.toUpperCase() === route.method.toUpperCase() && r.path === fullPath
|
|
836
|
+
);
|
|
837
|
+
if (potentialMatches.length > 1) {
|
|
838
|
+
const runtimeHandlerSrc = route.handler.toString().replace(/\s+/g, " ");
|
|
839
|
+
const preciseMatch = potentialMatches.find((r) => {
|
|
840
|
+
const astHandlerSrc = (r.handlerSource || r.handlerName || "").replace(/\s+/g, " ");
|
|
841
|
+
const match = runtimeHandlerSrc.includes(astHandlerSrc) || astHandlerSrc.includes(runtimeHandlerSrc) || r.handlerSource && runtimeHandlerSrc.includes(r.handlerSource.substring(0, 50));
|
|
842
|
+
return match;
|
|
843
|
+
});
|
|
844
|
+
if (preciseMatch) {
|
|
845
|
+
astMatch = preciseMatch;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (astMatch) {
|
|
849
|
+
if (astMatch.summary) operation.summary = astMatch.summary;
|
|
850
|
+
if (astMatch.description) operation.description = astMatch.description;
|
|
851
|
+
if (astMatch.tags) operation.tags = astMatch.tags;
|
|
852
|
+
if (astMatch.operationId) operation.operationId = astMatch.operationId;
|
|
853
|
+
if (astMatch.requestTypes?.body) {
|
|
854
|
+
operation.requestBody = {
|
|
855
|
+
content: {
|
|
856
|
+
"application/json": { schema: astMatch.requestTypes.body }
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
if (astMatch.responseSchema) {
|
|
861
|
+
operation.responses["200"] = {
|
|
862
|
+
description: "Successful response",
|
|
863
|
+
content: {
|
|
864
|
+
"application/json": { schema: astMatch.responseSchema }
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
} else if (astMatch.responseType) {
|
|
868
|
+
const contentType = astMatch.responseType === "string" ? "text/plain" : "application/json";
|
|
869
|
+
operation.responses["200"] = {
|
|
870
|
+
description: "Successful response",
|
|
871
|
+
content: {
|
|
872
|
+
[contentType]: { schema: { type: astMatch.responseType } }
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
const params = [];
|
|
877
|
+
if (astMatch.requestTypes?.query) {
|
|
878
|
+
for (const [name, _type] of Object.entries(astMatch.requestTypes.query)) {
|
|
879
|
+
params.push({ name, in: "query", schema: { type: "string" } });
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
if (params.length > 0) {
|
|
883
|
+
operation.parameters = params;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (route.keys.length > 0) {
|
|
887
|
+
const pathParams = route.keys.map((key) => ({
|
|
888
|
+
name: key,
|
|
889
|
+
in: "path",
|
|
890
|
+
required: true,
|
|
891
|
+
schema: { type: "string" }
|
|
892
|
+
}));
|
|
893
|
+
const existingParams = operation.parameters || [];
|
|
894
|
+
const mergedParams = [...existingParams];
|
|
895
|
+
pathParams.forEach((p) => {
|
|
896
|
+
const idx = mergedParams.findIndex((ep) => ep.in === "path" && ep.name === p.name);
|
|
897
|
+
if (idx >= 0) {
|
|
898
|
+
mergedParams[idx] = deepMerge(mergedParams[idx], p);
|
|
899
|
+
} else {
|
|
900
|
+
mergedParams.push(p);
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
operation.parameters = mergedParams;
|
|
904
|
+
}
|
|
905
|
+
const { inferredSpec } = analyzeHandler(route.handler);
|
|
906
|
+
if (inferredSpec) {
|
|
907
|
+
if (inferredSpec.parameters) {
|
|
908
|
+
const existingParams = operation.parameters || [];
|
|
909
|
+
const mergedParams = [...existingParams];
|
|
910
|
+
for (const p of inferredSpec.parameters) {
|
|
911
|
+
const idx = mergedParams.findIndex((ep) => ep.name === p.name && ep.in === p.in);
|
|
912
|
+
if (idx >= 0) {
|
|
913
|
+
mergedParams[idx] = deepMerge(mergedParams[idx], p);
|
|
914
|
+
} else {
|
|
915
|
+
mergedParams.push(p);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
operation.parameters = mergedParams;
|
|
919
|
+
delete inferredSpec.parameters;
|
|
920
|
+
}
|
|
921
|
+
deepMerge(operation, inferredSpec);
|
|
922
|
+
}
|
|
923
|
+
if (route.handlerSpec) {
|
|
924
|
+
const spec = route.handlerSpec;
|
|
925
|
+
if (spec.summary) operation.summary = spec.summary;
|
|
926
|
+
if (spec.description) operation.description = spec.description;
|
|
927
|
+
if (spec.operationId) operation.operationId = spec.operationId;
|
|
928
|
+
if (spec.tags) operation.tags = spec.tags;
|
|
929
|
+
if (spec.security) operation.security = spec.security;
|
|
930
|
+
if (spec.responses) {
|
|
931
|
+
operation.responses = { ...operation.responses, ...spec.responses };
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (!operation.tags || operation.tags.length === 0) operation.tags = [tag];
|
|
935
|
+
if (operation.tags) {
|
|
936
|
+
operation.tags = Array.from(new Set(operation.tags));
|
|
937
|
+
for (const t of operation.tags) {
|
|
938
|
+
if (!tagGroups.has(routeGroup)) tagGroups.set(routeGroup, /* @__PURE__ */ new Set());
|
|
939
|
+
tagGroups.get(routeGroup)?.add(t);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const methodLower = route.method.toLowerCase();
|
|
943
|
+
if (methodLower === "all") {
|
|
944
|
+
["get", "post", "put", "delete", "patch"].forEach((m) => {
|
|
945
|
+
if (!paths[fullPath][m]) paths[fullPath][m] = { ...operation };
|
|
946
|
+
});
|
|
947
|
+
} else {
|
|
948
|
+
paths[fullPath][methodLower] = operation;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
for (const controller of router[$childControllers]) {
|
|
952
|
+
const controllerName = controller.constructor.name || "UnknownController";
|
|
953
|
+
tagGroups.get(group)?.add(controllerName);
|
|
954
|
+
}
|
|
955
|
+
for (const child of router[$childRouters]) {
|
|
956
|
+
const mountPath = child[$mountPath];
|
|
957
|
+
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
958
|
+
const cleanMount = mountPath.startsWith("/") ? mountPath : "/" + mountPath;
|
|
959
|
+
const nextPrefix = cleanPrefix + cleanMount || "/";
|
|
960
|
+
collect(child, nextPrefix, group, tag);
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
collect(rootRouter);
|
|
964
|
+
const xTagGroups = [];
|
|
965
|
+
for (const [name, tags] of tagGroups) {
|
|
966
|
+
xTagGroups.push({ name, tags: Array.from(tags).sort() });
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
openapi: "3.1.0",
|
|
970
|
+
info: { title: "Shokupan API", version: "1.0.0", ...options.info },
|
|
971
|
+
paths,
|
|
972
|
+
components: options.components,
|
|
973
|
+
servers: options.servers,
|
|
974
|
+
tags: options.tags,
|
|
975
|
+
externalDocs: options.externalDocs,
|
|
976
|
+
"x-tagGroups": xTagGroups
|
|
977
|
+
};
|
|
978
|
+
}
|
|
474
979
|
const eta$1 = new Eta();
|
|
980
|
+
function serveStatic(ctx, config, prefix) {
|
|
981
|
+
const rootPath = resolve(config.root || ".");
|
|
982
|
+
const normalizedPrefix = prefix.endsWith("/") && prefix !== "/" ? prefix.slice(0, -1) : prefix;
|
|
983
|
+
return async () => {
|
|
984
|
+
let relative = ctx.path.slice(normalizedPrefix.length);
|
|
985
|
+
if (!relative.startsWith("/") && relative.length > 0) relative = "/" + relative;
|
|
986
|
+
if (relative.length === 0) relative = "/";
|
|
987
|
+
relative = decodeURIComponent(relative);
|
|
988
|
+
const requestPath = join(rootPath, relative);
|
|
989
|
+
if (!requestPath.startsWith(rootPath)) {
|
|
990
|
+
return ctx.json({ error: "Forbidden" }, 403);
|
|
991
|
+
}
|
|
992
|
+
if (requestPath.includes("\0")) {
|
|
993
|
+
return ctx.json({ error: "Forbidden" }, 403);
|
|
994
|
+
}
|
|
995
|
+
if (config.hooks?.onRequest) {
|
|
996
|
+
const res = await config.hooks.onRequest(ctx);
|
|
997
|
+
if (res) return res;
|
|
998
|
+
}
|
|
999
|
+
if (config.exclude) {
|
|
1000
|
+
for (const pattern of config.exclude) {
|
|
1001
|
+
if (pattern instanceof RegExp) {
|
|
1002
|
+
if (pattern.test(relative)) return ctx.json({ error: "Forbidden" }, 403);
|
|
1003
|
+
} else if (typeof pattern === "string") {
|
|
1004
|
+
if (relative.includes(pattern)) return ctx.json({ error: "Forbidden" }, 403);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (basename(requestPath).startsWith(".")) {
|
|
1009
|
+
const behavior = config.dotfiles || "ignore";
|
|
1010
|
+
if (behavior === "deny") return ctx.json({ error: "Forbidden" }, 403);
|
|
1011
|
+
if (behavior === "ignore") return ctx.json({ error: "Not Found" }, 404);
|
|
1012
|
+
}
|
|
1013
|
+
let finalPath = requestPath;
|
|
1014
|
+
let stats;
|
|
1015
|
+
try {
|
|
1016
|
+
stats = await stat(requestPath);
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
if (config.extensions) {
|
|
1019
|
+
for (const ext of config.extensions) {
|
|
1020
|
+
const p = requestPath + (ext.startsWith(".") ? ext : "." + ext);
|
|
1021
|
+
try {
|
|
1022
|
+
const s = await stat(p);
|
|
1023
|
+
if (s.isFile()) {
|
|
1024
|
+
finalPath = p;
|
|
1025
|
+
stats = s;
|
|
1026
|
+
break;
|
|
1027
|
+
}
|
|
1028
|
+
} catch {
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (!stats) return ctx.json({ error: "Not Found" }, 404);
|
|
1033
|
+
}
|
|
1034
|
+
if (stats.isDirectory()) {
|
|
1035
|
+
if (!ctx.path.endsWith("/")) {
|
|
1036
|
+
const query = ctx.url.search;
|
|
1037
|
+
return ctx.redirect(ctx.path + "/" + query, 302);
|
|
1038
|
+
}
|
|
1039
|
+
let indexes = [];
|
|
1040
|
+
if (config.index === void 0) {
|
|
1041
|
+
indexes = ["index.html", "index.htm"];
|
|
1042
|
+
} else if (Array.isArray(config.index)) {
|
|
1043
|
+
indexes = config.index;
|
|
1044
|
+
} else if (config.index) {
|
|
1045
|
+
indexes = [config.index];
|
|
1046
|
+
}
|
|
1047
|
+
let foundIndex = false;
|
|
1048
|
+
for (const idx of indexes) {
|
|
1049
|
+
const idxPath = join(finalPath, idx);
|
|
1050
|
+
try {
|
|
1051
|
+
const idxStats = await stat(idxPath);
|
|
1052
|
+
if (idxStats.isFile()) {
|
|
1053
|
+
finalPath = idxPath;
|
|
1054
|
+
foundIndex = true;
|
|
1055
|
+
break;
|
|
1056
|
+
}
|
|
1057
|
+
} catch {
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
if (!foundIndex) {
|
|
1061
|
+
if (config.listDirectory) {
|
|
1062
|
+
try {
|
|
1063
|
+
const files = await readdir(requestPath);
|
|
1064
|
+
const listing = eta$1.renderString(`
|
|
1065
|
+
<!DOCTYPE html>
|
|
1066
|
+
<html>
|
|
1067
|
+
<head>
|
|
1068
|
+
<title>Index of <%= it.relative %></title>
|
|
1069
|
+
<style>
|
|
1070
|
+
body { font-family: system-ui, -apple-system, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
|
1071
|
+
ul { list-style: none; padding: 0; }
|
|
1072
|
+
li { padding: 0.5rem 0; border-bottom: 1px solid #eee; }
|
|
1073
|
+
a { text-decoration: none; color: #0066cc; }
|
|
1074
|
+
a:hover { text-decoration: underline; }
|
|
1075
|
+
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
|
|
1076
|
+
</style>
|
|
1077
|
+
</head>
|
|
1078
|
+
<body>
|
|
1079
|
+
<h1>Index of <%= it.relative %></h1>
|
|
1080
|
+
<ul>
|
|
1081
|
+
<% if (it.relative !== '/') { %>
|
|
1082
|
+
<li><a href="../">../</a></li>
|
|
1083
|
+
<% } %>
|
|
1084
|
+
<% it.files.forEach(function(f) { %>
|
|
1085
|
+
<li><a href="<%= f %>"><%= f %></a></li>
|
|
1086
|
+
<% }) %>
|
|
1087
|
+
</ul>
|
|
1088
|
+
</body>
|
|
1089
|
+
</html>
|
|
1090
|
+
`, { relative, files, join });
|
|
1091
|
+
return new Response(listing, { headers: { "Content-Type": "text/html" } });
|
|
1092
|
+
} catch (e) {
|
|
1093
|
+
return ctx.json({ error: "Internal Server Error" }, 500);
|
|
1094
|
+
}
|
|
1095
|
+
} else {
|
|
1096
|
+
return ctx.json({ error: "Forbidden" }, 403);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
const file = Bun.file(finalPath);
|
|
1101
|
+
let response = new Response(file);
|
|
1102
|
+
if (config.hooks?.onResponse) {
|
|
1103
|
+
const hooked = await config.hooks.onResponse(ctx, response);
|
|
1104
|
+
if (hooked) response = hooked;
|
|
1105
|
+
}
|
|
1106
|
+
return response;
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
const asyncContext = new AsyncLocalStorage();
|
|
475
1110
|
const RouterRegistry = /* @__PURE__ */ new Map();
|
|
476
1111
|
const ShokupanApplicationTree = {};
|
|
477
1112
|
class ShokupanRouter {
|
|
478
1113
|
constructor(config) {
|
|
479
1114
|
this.config = config;
|
|
1115
|
+
if (config?.requestTimeout) {
|
|
1116
|
+
this.requestTimeout = config.requestTimeout;
|
|
1117
|
+
}
|
|
480
1118
|
}
|
|
481
1119
|
// Internal marker to identify Router vs. Application
|
|
482
1120
|
[$isApplication] = false;
|
|
@@ -484,6 +1122,7 @@ class ShokupanRouter {
|
|
|
484
1122
|
[$isRouter] = true;
|
|
485
1123
|
[$appRoot];
|
|
486
1124
|
[$mountPath] = "/";
|
|
1125
|
+
// Public via Symbol for OpenAPI generator
|
|
487
1126
|
[$parent] = null;
|
|
488
1127
|
[$childRouters] = [];
|
|
489
1128
|
[$childControllers] = [];
|
|
@@ -493,7 +1132,8 @@ class ShokupanRouter {
|
|
|
493
1132
|
get root() {
|
|
494
1133
|
return this[$appRoot];
|
|
495
1134
|
}
|
|
496
|
-
routes = [];
|
|
1135
|
+
[$routes] = [];
|
|
1136
|
+
// Public via Symbol for OpenAPI generator
|
|
497
1137
|
currentGuards = [];
|
|
498
1138
|
isRouterInstance(target) {
|
|
499
1139
|
return typeof target === "object" && target !== null && $isRouter in target;
|
|
@@ -509,6 +1149,12 @@ class ShokupanRouter {
|
|
|
509
1149
|
* - postCreate(ctx) -> POST /prefix/create
|
|
510
1150
|
*/
|
|
511
1151
|
mount(prefix, controller) {
|
|
1152
|
+
const isRouter = this.isRouterInstance(controller);
|
|
1153
|
+
const isFunction = typeof controller === "function";
|
|
1154
|
+
const controllersOnly = this.config?.controllersOnly ?? this.rootConfig?.controllersOnly ?? false;
|
|
1155
|
+
if (controllersOnly && !isFunction && !isRouter) {
|
|
1156
|
+
throw new Error(`[Shokupan] strict controller check failed: ${controller.constructor.name || typeof controller} is not a class constructor.`);
|
|
1157
|
+
}
|
|
512
1158
|
if (this.isRouterInstance(controller)) {
|
|
513
1159
|
if (controller[$isMounted]) {
|
|
514
1160
|
throw new Error("Router is already mounted");
|
|
@@ -535,6 +1181,15 @@ class ShokupanRouter {
|
|
|
535
1181
|
prefix = p1 + p2;
|
|
536
1182
|
if (!prefix) prefix = "/";
|
|
537
1183
|
}
|
|
1184
|
+
} else {
|
|
1185
|
+
const ctor = instance.constructor;
|
|
1186
|
+
const controllerPath = ctor[$controllerPath];
|
|
1187
|
+
if (controllerPath) {
|
|
1188
|
+
const p1 = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1189
|
+
const p2 = controllerPath.startsWith("/") ? controllerPath : "/" + controllerPath;
|
|
1190
|
+
prefix = p1 + p2;
|
|
1191
|
+
if (!prefix) prefix = "/";
|
|
1192
|
+
}
|
|
538
1193
|
}
|
|
539
1194
|
instance[$mountPath] = prefix;
|
|
540
1195
|
this[$childControllers].push(instance);
|
|
@@ -652,8 +1307,14 @@ class ShokupanRouter {
|
|
|
652
1307
|
return composed(ctx, () => wrappedHandler(ctx));
|
|
653
1308
|
};
|
|
654
1309
|
}
|
|
1310
|
+
finalHandler.originalHandler = originalHandler;
|
|
1311
|
+
if (finalHandler !== wrappedHandler) {
|
|
1312
|
+
wrappedHandler.originalHandler = originalHandler;
|
|
1313
|
+
}
|
|
655
1314
|
const tagName = instance.constructor.name;
|
|
656
|
-
const
|
|
1315
|
+
const decoratedSpecs = instance[$routeSpec] || proto && proto[$routeSpec];
|
|
1316
|
+
const userSpec = decoratedSpecs && decoratedSpecs.get(name);
|
|
1317
|
+
const spec = { tags: [tagName], ...userSpec };
|
|
657
1318
|
this.add({ method, path: normalizedPath, handler: finalHandler, spec });
|
|
658
1319
|
}
|
|
659
1320
|
}
|
|
@@ -668,7 +1329,7 @@ class ShokupanRouter {
|
|
|
668
1329
|
* Returns all routes attached to this router and its descendants.
|
|
669
1330
|
*/
|
|
670
1331
|
getRoutes() {
|
|
671
|
-
const routes = this
|
|
1332
|
+
const routes = this[$routes].map((r) => ({
|
|
672
1333
|
method: r.method,
|
|
673
1334
|
path: r.path,
|
|
674
1335
|
handler: r.handler
|
|
@@ -769,6 +1430,30 @@ class ShokupanRouter {
|
|
|
769
1430
|
data: result
|
|
770
1431
|
};
|
|
771
1432
|
}
|
|
1433
|
+
applyHooks(match) {
|
|
1434
|
+
if (!this.config?.hooks) return match;
|
|
1435
|
+
const hooks = this.config.hooks;
|
|
1436
|
+
const originalHandler = match.handler;
|
|
1437
|
+
match.handler = async (ctx) => {
|
|
1438
|
+
if (hooks.onRequestStart) await hooks.onRequestStart(ctx);
|
|
1439
|
+
try {
|
|
1440
|
+
const result = await originalHandler(ctx);
|
|
1441
|
+
if (hooks.onRequestEnd) await hooks.onRequestEnd(ctx);
|
|
1442
|
+
return result;
|
|
1443
|
+
} catch (err) {
|
|
1444
|
+
if (hooks.onError) {
|
|
1445
|
+
try {
|
|
1446
|
+
await hooks.onError(err, ctx);
|
|
1447
|
+
} catch (e) {
|
|
1448
|
+
console.error("Error in router onError hook:", e);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
throw err;
|
|
1452
|
+
}
|
|
1453
|
+
};
|
|
1454
|
+
match.handler.originalHandler = originalHandler.originalHandler || originalHandler;
|
|
1455
|
+
return match;
|
|
1456
|
+
}
|
|
772
1457
|
/**
|
|
773
1458
|
* Find a route matching the given method and path.
|
|
774
1459
|
* @param method HTTP method
|
|
@@ -776,7 +1461,7 @@ class ShokupanRouter {
|
|
|
776
1461
|
* @returns Route handler and parameters if found, otherwise null
|
|
777
1462
|
*/
|
|
778
1463
|
find(method, path) {
|
|
779
|
-
for (const route of this
|
|
1464
|
+
for (const route of this[$routes]) {
|
|
780
1465
|
if (route.method !== "ALL" && route.method !== method) continue;
|
|
781
1466
|
const match = route.regex.exec(path);
|
|
782
1467
|
if (match) {
|
|
@@ -784,7 +1469,7 @@ class ShokupanRouter {
|
|
|
784
1469
|
route.keys.forEach((key, index) => {
|
|
785
1470
|
params[key] = match[index + 1];
|
|
786
1471
|
});
|
|
787
|
-
return { handler: route.handler, params };
|
|
1472
|
+
return this.applyHooks({ handler: route.handler, params });
|
|
788
1473
|
}
|
|
789
1474
|
}
|
|
790
1475
|
for (const child of this[$childRouters]) {
|
|
@@ -792,13 +1477,13 @@ class ShokupanRouter {
|
|
|
792
1477
|
if (path === prefix || path.startsWith(prefix + "/")) {
|
|
793
1478
|
const subPath = path.slice(prefix.length) || "/";
|
|
794
1479
|
const match = child.find(method, subPath);
|
|
795
|
-
if (match) return match;
|
|
1480
|
+
if (match) return this.applyHooks(match);
|
|
796
1481
|
}
|
|
797
1482
|
if (prefix.endsWith("/")) {
|
|
798
1483
|
if (path.startsWith(prefix)) {
|
|
799
1484
|
const subPath = path.slice(prefix.length) || "/";
|
|
800
1485
|
const match = child.find(method, subPath);
|
|
801
|
-
if (match) return match;
|
|
1486
|
+
if (match) return this.applyHooks(match);
|
|
802
1487
|
}
|
|
803
1488
|
}
|
|
804
1489
|
}
|
|
@@ -816,6 +1501,7 @@ class ShokupanRouter {
|
|
|
816
1501
|
};
|
|
817
1502
|
}
|
|
818
1503
|
// --- Functional Routing ---
|
|
1504
|
+
requestTimeout;
|
|
819
1505
|
/**
|
|
820
1506
|
* Adds a route to the router.
|
|
821
1507
|
*
|
|
@@ -823,12 +1509,25 @@ class ShokupanRouter {
|
|
|
823
1509
|
* @param path - URL path
|
|
824
1510
|
* @param spec - OpenAPI specification for the route
|
|
825
1511
|
* @param handler - Route handler function
|
|
1512
|
+
* @param requestTimeout - Timeout for this route in milliseconds
|
|
826
1513
|
*/
|
|
827
|
-
add({ method, path, spec, handler, regex: customRegex, group }) {
|
|
1514
|
+
add({ method, path, spec, handler, regex: customRegex, group, requestTimeout, renderer }) {
|
|
828
1515
|
const { regex, keys } = customRegex ? { regex: customRegex, keys: [] } : this.parsePath(path);
|
|
829
1516
|
let wrappedHandler = handler;
|
|
830
1517
|
const routeGuards = [...this.currentGuards];
|
|
1518
|
+
const effectiveTimeout = requestTimeout ?? this.requestTimeout ?? this.rootConfig?.requestTimeout;
|
|
1519
|
+
if (effectiveTimeout !== void 0 && effectiveTimeout > 0) {
|
|
1520
|
+
const originalHandler = wrappedHandler;
|
|
1521
|
+
wrappedHandler = async (ctx) => {
|
|
1522
|
+
if (ctx.server) {
|
|
1523
|
+
ctx.server.timeout(ctx.req, effectiveTimeout / 1e3);
|
|
1524
|
+
}
|
|
1525
|
+
return originalHandler(ctx);
|
|
1526
|
+
};
|
|
1527
|
+
wrappedHandler.originalHandler = originalHandler.originalHandler || originalHandler;
|
|
1528
|
+
}
|
|
831
1529
|
if (routeGuards.length > 0) {
|
|
1530
|
+
const innerHandler = wrappedHandler;
|
|
832
1531
|
wrappedHandler = async (ctx) => {
|
|
833
1532
|
for (const guard of routeGuards) {
|
|
834
1533
|
let guardPassed = false;
|
|
@@ -853,10 +1552,18 @@ class ShokupanRouter {
|
|
|
853
1552
|
return ctx.json({ error: "Forbidden" }, 403);
|
|
854
1553
|
}
|
|
855
1554
|
}
|
|
856
|
-
return
|
|
1555
|
+
return innerHandler(ctx);
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
const effectiveRenderer = renderer ?? this.config?.renderer ?? this.rootConfig?.renderer;
|
|
1559
|
+
if (effectiveRenderer) {
|
|
1560
|
+
const innerHandler = wrappedHandler;
|
|
1561
|
+
wrappedHandler = async (ctx) => {
|
|
1562
|
+
ctx.renderer = effectiveRenderer;
|
|
1563
|
+
return innerHandler(ctx);
|
|
857
1564
|
};
|
|
858
1565
|
}
|
|
859
|
-
this
|
|
1566
|
+
this[$routes].push({
|
|
860
1567
|
method,
|
|
861
1568
|
path,
|
|
862
1569
|
regex,
|
|
@@ -864,7 +1571,9 @@ class ShokupanRouter {
|
|
|
864
1571
|
handler: wrappedHandler,
|
|
865
1572
|
handlerSpec: spec,
|
|
866
1573
|
group,
|
|
867
|
-
guards: routeGuards.length > 0 ? routeGuards : void 0
|
|
1574
|
+
guards: routeGuards.length > 0 ? routeGuards : void 0,
|
|
1575
|
+
requestTimeout: effectiveTimeout
|
|
1576
|
+
// Save for inspection? Or just relying on closure
|
|
868
1577
|
});
|
|
869
1578
|
return this;
|
|
870
1579
|
}
|
|
@@ -909,133 +1618,12 @@ class ShokupanRouter {
|
|
|
909
1618
|
*/
|
|
910
1619
|
static(uriPath, options) {
|
|
911
1620
|
const config = typeof options === "string" ? { root: options } : options;
|
|
912
|
-
const rootPath = resolve(config.root || ".");
|
|
913
1621
|
const prefix = uriPath.startsWith("/") ? uriPath : "/" + uriPath;
|
|
914
1622
|
const normalizedPrefix = prefix.endsWith("/") && prefix !== "/" ? prefix.slice(0, -1) : prefix;
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
relative = decodeURIComponent(relative);
|
|
920
|
-
const requestPath = join(rootPath, relative);
|
|
921
|
-
if (!requestPath.startsWith(rootPath)) {
|
|
922
|
-
return ctx.json({ error: "Forbidden" }, 403);
|
|
923
|
-
}
|
|
924
|
-
if (requestPath.includes("\0")) {
|
|
925
|
-
return ctx.json({ error: "Forbidden" }, 403);
|
|
926
|
-
}
|
|
927
|
-
if (config.hooks?.onRequest) {
|
|
928
|
-
const res = await config.hooks.onRequest(ctx);
|
|
929
|
-
if (res) return res;
|
|
930
|
-
}
|
|
931
|
-
if (config.exclude) {
|
|
932
|
-
for (const pattern2 of config.exclude) {
|
|
933
|
-
if (pattern2 instanceof RegExp) {
|
|
934
|
-
if (pattern2.test(relative)) return ctx.json({ error: "Forbidden" }, 403);
|
|
935
|
-
} else if (typeof pattern2 === "string") {
|
|
936
|
-
if (relative.includes(pattern2)) return ctx.json({ error: "Forbidden" }, 403);
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
if (basename(requestPath).startsWith(".")) {
|
|
941
|
-
const behavior = config.dotfiles || "ignore";
|
|
942
|
-
if (behavior === "deny") return ctx.json({ error: "Forbidden" }, 403);
|
|
943
|
-
if (behavior === "ignore") return ctx.json({ error: "Not Found" }, 404);
|
|
944
|
-
}
|
|
945
|
-
let finalPath = requestPath;
|
|
946
|
-
let stats;
|
|
947
|
-
try {
|
|
948
|
-
stats = await stat(requestPath);
|
|
949
|
-
} catch (e) {
|
|
950
|
-
if (config.extensions) {
|
|
951
|
-
for (const ext of config.extensions) {
|
|
952
|
-
const p = requestPath + (ext.startsWith(".") ? ext : "." + ext);
|
|
953
|
-
try {
|
|
954
|
-
const s = await stat(p);
|
|
955
|
-
if (s.isFile()) {
|
|
956
|
-
finalPath = p;
|
|
957
|
-
stats = s;
|
|
958
|
-
break;
|
|
959
|
-
}
|
|
960
|
-
} catch {
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
if (!stats) return ctx.json({ error: "Not Found" }, 404);
|
|
965
|
-
}
|
|
966
|
-
if (stats.isDirectory()) {
|
|
967
|
-
if (!ctx.path.endsWith("/")) {
|
|
968
|
-
const query = ctx.url.search;
|
|
969
|
-
return ctx.redirect(ctx.path + "/" + query, 302);
|
|
970
|
-
}
|
|
971
|
-
let indexes = [];
|
|
972
|
-
if (config.index === void 0) {
|
|
973
|
-
indexes = ["index.html", "index.htm"];
|
|
974
|
-
} else if (Array.isArray(config.index)) {
|
|
975
|
-
indexes = config.index;
|
|
976
|
-
} else if (config.index) {
|
|
977
|
-
indexes = [config.index];
|
|
978
|
-
}
|
|
979
|
-
let foundIndex = false;
|
|
980
|
-
for (const idx of indexes) {
|
|
981
|
-
const idxPath = join(finalPath, idx);
|
|
982
|
-
try {
|
|
983
|
-
const idxStats = await stat(idxPath);
|
|
984
|
-
if (idxStats.isFile()) {
|
|
985
|
-
finalPath = idxPath;
|
|
986
|
-
foundIndex = true;
|
|
987
|
-
break;
|
|
988
|
-
}
|
|
989
|
-
} catch {
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
if (!foundIndex) {
|
|
993
|
-
if (config.listDirectory) {
|
|
994
|
-
try {
|
|
995
|
-
const files = await readdir(requestPath);
|
|
996
|
-
const listing = eta$1.renderString(`
|
|
997
|
-
<!DOCTYPE html>
|
|
998
|
-
<html>
|
|
999
|
-
<head>
|
|
1000
|
-
<title>Index of <%= it.relative %></title>
|
|
1001
|
-
<style>
|
|
1002
|
-
body { font-family: system-ui, -apple-system, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
|
1003
|
-
ul { list-style: none; padding: 0; }
|
|
1004
|
-
li { padding: 0.5rem 0; border-bottom: 1px solid #eee; }
|
|
1005
|
-
a { text-decoration: none; color: #0066cc; }
|
|
1006
|
-
a:hover { text-decoration: underline; }
|
|
1007
|
-
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
|
|
1008
|
-
</style>
|
|
1009
|
-
</head>
|
|
1010
|
-
<body>
|
|
1011
|
-
<h1>Index of <%= it.relative %></h1>
|
|
1012
|
-
<ul>
|
|
1013
|
-
<% if (it.relative !== '/') { %>
|
|
1014
|
-
<li><a href="../">../</a></li>
|
|
1015
|
-
<% } %>
|
|
1016
|
-
<% it.files.forEach(function(f) { %>
|
|
1017
|
-
<li><a href="<%= f %>"><%= f %></a></li>
|
|
1018
|
-
<% }) %>
|
|
1019
|
-
</ul>
|
|
1020
|
-
</body>
|
|
1021
|
-
</html>
|
|
1022
|
-
`, { relative, files, join });
|
|
1023
|
-
return new Response(listing, { headers: { "Content-Type": "text/html" } });
|
|
1024
|
-
} catch (e) {
|
|
1025
|
-
return ctx.json({ error: "Internal Server Error" }, 500);
|
|
1026
|
-
}
|
|
1027
|
-
} else {
|
|
1028
|
-
return ctx.json({ error: "Forbidden" }, 403);
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
const file = Bun.file(finalPath);
|
|
1033
|
-
let response = new Response(file);
|
|
1034
|
-
if (config.hooks?.onResponse) {
|
|
1035
|
-
const hooked = await config.hooks.onResponse(ctx, response);
|
|
1036
|
-
if (hooked) response = hooked;
|
|
1037
|
-
}
|
|
1038
|
-
return response;
|
|
1623
|
+
serveStatic(null, config, prefix);
|
|
1624
|
+
const routeHandler = async (ctx) => {
|
|
1625
|
+
const runner = serveStatic(ctx, config, prefix);
|
|
1626
|
+
return runner();
|
|
1039
1627
|
};
|
|
1040
1628
|
let groupName = "Static";
|
|
1041
1629
|
const segments = normalizedPrefix.split("/").filter(Boolean);
|
|
@@ -1054,8 +1642,8 @@ class ShokupanRouter {
|
|
|
1054
1642
|
const pattern = `^${normalizedPrefix}(/.*)?$`;
|
|
1055
1643
|
const regex = new RegExp(pattern);
|
|
1056
1644
|
const displayPath = normalizedPrefix === "/" ? "/*" : normalizedPrefix + "/*";
|
|
1057
|
-
this.add({ method: "GET", path: displayPath, handler, spec, regex });
|
|
1058
|
-
this.add({ method: "HEAD", path: displayPath, handler, spec, regex });
|
|
1645
|
+
this.add({ method: "GET", path: displayPath, handler: routeHandler, spec, regex });
|
|
1646
|
+
this.add({ method: "HEAD", path: displayPath, handler: routeHandler, spec, regex });
|
|
1059
1647
|
return this;
|
|
1060
1648
|
}
|
|
1061
1649
|
/**
|
|
@@ -1090,137 +1678,23 @@ class ShokupanRouter {
|
|
|
1090
1678
|
}
|
|
1091
1679
|
/**
|
|
1092
1680
|
* Generates an OpenAPI 3.1 Document by recursing through the router and its descendants.
|
|
1681
|
+
* Now includes runtime analysis of handler functions to infer request/response types.
|
|
1093
1682
|
*/
|
|
1094
1683
|
generateApiSpec(options = {}) {
|
|
1095
|
-
|
|
1096
|
-
const tagGroups = /* @__PURE__ */ new Map();
|
|
1097
|
-
const defaultTagGroup = options.defaultTagGroup || "General";
|
|
1098
|
-
const defaultTagName = options.defaultTag || "Application";
|
|
1099
|
-
const collect = (router, prefix = "", currentGroup = defaultTagGroup, defaultTag = defaultTagName) => {
|
|
1100
|
-
let group = currentGroup;
|
|
1101
|
-
let tag = defaultTag;
|
|
1102
|
-
if (router.config?.group) {
|
|
1103
|
-
group = router.config.group;
|
|
1104
|
-
}
|
|
1105
|
-
if (router.config?.name) {
|
|
1106
|
-
tag = router.config.name;
|
|
1107
|
-
} else {
|
|
1108
|
-
const mountPath = router[$mountPath];
|
|
1109
|
-
if (mountPath && mountPath !== "/") {
|
|
1110
|
-
const segments = mountPath.split("/").filter(Boolean);
|
|
1111
|
-
if (segments.length > 0) {
|
|
1112
|
-
const lastSegment = segments[segments.length - 1];
|
|
1113
|
-
const humanized = lastSegment.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1114
|
-
tag = humanized;
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
if (!tagGroups.has(group)) {
|
|
1119
|
-
tagGroups.set(group, /* @__PURE__ */ new Set());
|
|
1120
|
-
}
|
|
1121
|
-
for (const route of router.routes) {
|
|
1122
|
-
const routeGroup = route.group || group;
|
|
1123
|
-
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1124
|
-
const cleanSubPath = route.path.startsWith("/") ? route.path : "/" + route.path;
|
|
1125
|
-
let fullPath = cleanPrefix + cleanSubPath || "/";
|
|
1126
|
-
fullPath = fullPath.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
|
|
1127
|
-
if (!paths[fullPath]) {
|
|
1128
|
-
paths[fullPath] = {};
|
|
1129
|
-
}
|
|
1130
|
-
const operation = {
|
|
1131
|
-
responses: {
|
|
1132
|
-
200: { description: "OK" }
|
|
1133
|
-
}
|
|
1134
|
-
};
|
|
1135
|
-
if (route.keys.length > 0) {
|
|
1136
|
-
operation.parameters = route.keys.map((key) => ({
|
|
1137
|
-
name: key,
|
|
1138
|
-
in: "path",
|
|
1139
|
-
required: true,
|
|
1140
|
-
schema: { type: "string" }
|
|
1141
|
-
}));
|
|
1142
|
-
}
|
|
1143
|
-
if (route.guards) {
|
|
1144
|
-
for (const guard of route.guards) {
|
|
1145
|
-
if (guard.spec) {
|
|
1146
|
-
deepMerge(operation, guard.spec);
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
if (route.handlerSpec) {
|
|
1151
|
-
deepMerge(operation, route.handlerSpec);
|
|
1152
|
-
}
|
|
1153
|
-
if (!operation.tags || operation.tags.length === 0) {
|
|
1154
|
-
operation.tags = [tag];
|
|
1155
|
-
}
|
|
1156
|
-
if (operation.tags) {
|
|
1157
|
-
operation.tags = Array.from(new Set(operation.tags));
|
|
1158
|
-
for (const t of operation.tags) {
|
|
1159
|
-
if (!tagGroups.has(routeGroup)) {
|
|
1160
|
-
tagGroups.set(routeGroup, /* @__PURE__ */ new Set());
|
|
1161
|
-
}
|
|
1162
|
-
tagGroups.get(routeGroup)?.add(t);
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
const methodLower = route.method.toLowerCase();
|
|
1166
|
-
if (methodLower === "all") {
|
|
1167
|
-
["get", "post", "put", "delete", "patch"].forEach((m) => {
|
|
1168
|
-
if (!paths[fullPath][m]) {
|
|
1169
|
-
paths[fullPath][m] = { ...operation };
|
|
1170
|
-
}
|
|
1171
|
-
});
|
|
1172
|
-
} else {
|
|
1173
|
-
paths[fullPath][methodLower] = operation;
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
for (const controller of router[$childControllers]) {
|
|
1177
|
-
const mountPath = controller[$mountPath] || "";
|
|
1178
|
-
prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1179
|
-
mountPath.startsWith("/") ? mountPath : "/" + mountPath;
|
|
1180
|
-
const controllerName = controller.constructor.name || "UnknownController";
|
|
1181
|
-
tagGroups.get(group)?.add(controllerName);
|
|
1182
|
-
}
|
|
1183
|
-
for (const child of router[$childRouters]) {
|
|
1184
|
-
const mountPath = child[$mountPath];
|
|
1185
|
-
const cleanPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1186
|
-
const cleanMount = mountPath.startsWith("/") ? mountPath : "/" + mountPath;
|
|
1187
|
-
const nextPrefix = cleanPrefix + cleanMount || "/";
|
|
1188
|
-
collect(child, nextPrefix, group, tag);
|
|
1189
|
-
}
|
|
1190
|
-
};
|
|
1191
|
-
collect(this);
|
|
1192
|
-
const xTagGroups = [];
|
|
1193
|
-
for (const [name, tags] of tagGroups) {
|
|
1194
|
-
xTagGroups.push({
|
|
1195
|
-
name,
|
|
1196
|
-
tags: Array.from(tags).sort()
|
|
1197
|
-
});
|
|
1198
|
-
}
|
|
1199
|
-
return {
|
|
1200
|
-
openapi: "3.1.0",
|
|
1201
|
-
info: {
|
|
1202
|
-
title: "Shokupan API",
|
|
1203
|
-
version: "1.0.0",
|
|
1204
|
-
...options.info
|
|
1205
|
-
},
|
|
1206
|
-
paths,
|
|
1207
|
-
components: options.components,
|
|
1208
|
-
servers: options.servers,
|
|
1209
|
-
tags: options.tags,
|
|
1210
|
-
externalDocs: options.externalDocs,
|
|
1211
|
-
"x-tagGroups": xTagGroups
|
|
1212
|
-
};
|
|
1684
|
+
return generateOpenApi(this, options);
|
|
1213
1685
|
}
|
|
1214
1686
|
}
|
|
1215
1687
|
const defaults = {
|
|
1216
1688
|
port: 3e3,
|
|
1217
1689
|
hostname: "localhost",
|
|
1218
1690
|
development: process.env.NODE_ENV !== "production",
|
|
1219
|
-
enableAsyncLocalStorage: false
|
|
1691
|
+
enableAsyncLocalStorage: false,
|
|
1692
|
+
reusePort: false
|
|
1220
1693
|
};
|
|
1221
1694
|
trace.getTracer("shokupan.application");
|
|
1222
1695
|
class Shokupan extends ShokupanRouter {
|
|
1223
1696
|
applicationConfig = {};
|
|
1697
|
+
openApiSpec;
|
|
1224
1698
|
middleware = [];
|
|
1225
1699
|
get logger() {
|
|
1226
1700
|
return this.applicationConfig.logger;
|
|
@@ -1238,24 +1712,41 @@ class Shokupan extends ShokupanRouter {
|
|
|
1238
1712
|
this.middleware.push(middleware);
|
|
1239
1713
|
return this;
|
|
1240
1714
|
}
|
|
1715
|
+
startupHooks = [];
|
|
1716
|
+
/**
|
|
1717
|
+
* Registers a callback to be executed before the server starts listening.
|
|
1718
|
+
*/
|
|
1719
|
+
onStart(callback) {
|
|
1720
|
+
this.startupHooks.push(callback);
|
|
1721
|
+
return this;
|
|
1722
|
+
}
|
|
1241
1723
|
/**
|
|
1242
1724
|
* Starts the application server.
|
|
1243
1725
|
*
|
|
1244
1726
|
* @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.
|
|
1245
1727
|
* @returns The server instance.
|
|
1246
1728
|
*/
|
|
1247
|
-
listen(port) {
|
|
1729
|
+
async listen(port) {
|
|
1248
1730
|
const finalPort = port ?? this.applicationConfig.port ?? 3e3;
|
|
1249
1731
|
if (finalPort < 0 || finalPort > 65535) {
|
|
1250
1732
|
throw new Error("Invalid port number");
|
|
1251
1733
|
}
|
|
1734
|
+
for (const hook of this.startupHooks) {
|
|
1735
|
+
await hook();
|
|
1736
|
+
}
|
|
1737
|
+
if (this.applicationConfig.enableOpenApiGen) {
|
|
1738
|
+
this.openApiSpec = await generateOpenApi(this);
|
|
1739
|
+
}
|
|
1252
1740
|
if (port === 0 && process.platform === "linux") ;
|
|
1253
|
-
const
|
|
1741
|
+
const serveOptions = {
|
|
1254
1742
|
port: finalPort,
|
|
1255
1743
|
hostname: this.applicationConfig.hostname,
|
|
1256
1744
|
development: this.applicationConfig.development,
|
|
1257
|
-
fetch: this.fetch.bind(this)
|
|
1258
|
-
|
|
1745
|
+
fetch: this.fetch.bind(this),
|
|
1746
|
+
reusePort: this.applicationConfig.reusePort,
|
|
1747
|
+
idleTimeout: this.applicationConfig.readTimeout ? this.applicationConfig.readTimeout / 1e3 : void 0
|
|
1748
|
+
};
|
|
1749
|
+
const server = this.applicationConfig.serverFactory ? await this.applicationConfig.serverFactory(serveOptions) : Bun.serve(serveOptions);
|
|
1259
1750
|
console.log(`Shokupan server listening on http://${server.hostname}:${server.port}`);
|
|
1260
1751
|
return server;
|
|
1261
1752
|
}
|
|
@@ -1306,9 +1797,10 @@ class Shokupan extends ShokupanRouter {
|
|
|
1306
1797
|
* This logic contains the middleware chain and router dispatch.
|
|
1307
1798
|
*
|
|
1308
1799
|
* @param req - The request to handle.
|
|
1800
|
+
* @param server - The server instance.
|
|
1309
1801
|
* @returns The response to send.
|
|
1310
1802
|
*/
|
|
1311
|
-
async fetch(req) {
|
|
1803
|
+
async fetch(req, server) {
|
|
1312
1804
|
const tracer2 = trace.getTracer("shokupan.application");
|
|
1313
1805
|
const store = asyncContext.getStore();
|
|
1314
1806
|
const attrs = {
|
|
@@ -1325,10 +1817,13 @@ class Shokupan extends ShokupanRouter {
|
|
|
1325
1817
|
ctxMap.set("request", req);
|
|
1326
1818
|
const runCallback = () => {
|
|
1327
1819
|
const request = req;
|
|
1820
|
+
const ctx2 = new ShokupanContext(request, server, void 0, this);
|
|
1328
1821
|
const handle = async () => {
|
|
1329
|
-
const ctx2 = new ShokupanContext(request);
|
|
1330
|
-
const fn = compose(this.middleware);
|
|
1331
1822
|
try {
|
|
1823
|
+
if (this.applicationConfig.hooks?.onRequestStart) {
|
|
1824
|
+
await this.applicationConfig.hooks.onRequestStart(ctx2);
|
|
1825
|
+
}
|
|
1826
|
+
const fn = compose(this.middleware);
|
|
1332
1827
|
const result = await fn(ctx2, async () => {
|
|
1333
1828
|
const match = this.find(req.method, ctx2.path);
|
|
1334
1829
|
if (match) {
|
|
@@ -1337,17 +1832,24 @@ class Shokupan extends ShokupanRouter {
|
|
|
1337
1832
|
}
|
|
1338
1833
|
return null;
|
|
1339
1834
|
});
|
|
1835
|
+
let response;
|
|
1340
1836
|
if (result instanceof Response) {
|
|
1341
|
-
|
|
1342
|
-
}
|
|
1343
|
-
if (result === null || result === void 0) {
|
|
1837
|
+
response = result;
|
|
1838
|
+
} else if (result === null || result === void 0) {
|
|
1344
1839
|
span.setAttribute("http.status_code", 404);
|
|
1345
|
-
|
|
1840
|
+
response = ctx2.text("Not Found", 404);
|
|
1841
|
+
} else if (typeof result === "object") {
|
|
1842
|
+
response = ctx2.json(result);
|
|
1843
|
+
} else {
|
|
1844
|
+
response = ctx2.text(String(result));
|
|
1845
|
+
}
|
|
1846
|
+
if (this.applicationConfig.hooks?.onRequestEnd) {
|
|
1847
|
+
await this.applicationConfig.hooks.onRequestEnd(ctx2);
|
|
1346
1848
|
}
|
|
1347
|
-
if (
|
|
1348
|
-
|
|
1849
|
+
if (this.applicationConfig.hooks?.onResponseStart) {
|
|
1850
|
+
await this.applicationConfig.hooks.onResponseStart(ctx2, response);
|
|
1349
1851
|
}
|
|
1350
|
-
return
|
|
1852
|
+
return response;
|
|
1351
1853
|
} catch (err) {
|
|
1352
1854
|
console.error(err);
|
|
1353
1855
|
span.recordException(err);
|
|
@@ -1355,10 +1857,46 @@ class Shokupan extends ShokupanRouter {
|
|
|
1355
1857
|
const status = err.status || err.statusCode || 500;
|
|
1356
1858
|
const body = { error: err.message || "Internal Server Error" };
|
|
1357
1859
|
if (err.errors) body.errors = err.errors;
|
|
1860
|
+
if (this.applicationConfig.hooks?.onError) {
|
|
1861
|
+
try {
|
|
1862
|
+
await this.applicationConfig.hooks.onError(err, ctx2);
|
|
1863
|
+
} catch (hookErr) {
|
|
1864
|
+
console.error("Error in onError hook:", hookErr);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1358
1867
|
return ctx2.json(body, status);
|
|
1359
1868
|
}
|
|
1360
1869
|
};
|
|
1361
|
-
|
|
1870
|
+
let executionPromise = handle();
|
|
1871
|
+
const timeoutMs = this.applicationConfig.requestTimeout;
|
|
1872
|
+
if (timeoutMs && timeoutMs > 0 && this.applicationConfig.hooks?.onRequestTimeout) {
|
|
1873
|
+
let timeoutId;
|
|
1874
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1875
|
+
timeoutId = setTimeout(async () => {
|
|
1876
|
+
try {
|
|
1877
|
+
if (this.applicationConfig.hooks?.onRequestTimeout) {
|
|
1878
|
+
await this.applicationConfig.hooks.onRequestTimeout(ctx2);
|
|
1879
|
+
}
|
|
1880
|
+
} catch (e) {
|
|
1881
|
+
console.error("Error in onRequestTimeout hook:", e);
|
|
1882
|
+
}
|
|
1883
|
+
reject(new Error("Request Timeout"));
|
|
1884
|
+
}, timeoutMs);
|
|
1885
|
+
});
|
|
1886
|
+
executionPromise = Promise.race([executionPromise, timeoutPromise]).finally(() => clearTimeout(timeoutId));
|
|
1887
|
+
}
|
|
1888
|
+
return executionPromise.catch((err) => {
|
|
1889
|
+
if (err.message === "Request Timeout") {
|
|
1890
|
+
return ctx2.text("Request Timeout", 408);
|
|
1891
|
+
}
|
|
1892
|
+
console.error("Unexpected error in request execution:", err);
|
|
1893
|
+
return ctx2.text("Internal Server Error", 500);
|
|
1894
|
+
}).then(async (res) => {
|
|
1895
|
+
if (this.applicationConfig.hooks?.onResponseEnd) {
|
|
1896
|
+
await this.applicationConfig.hooks.onResponseEnd(ctx2, res);
|
|
1897
|
+
}
|
|
1898
|
+
return res;
|
|
1899
|
+
}).finally(() => span.end());
|
|
1362
1900
|
};
|
|
1363
1901
|
if (this.applicationConfig.enableAsyncLocalStorage) {
|
|
1364
1902
|
return asyncContext.run(ctxMap, runCallback);
|
|
@@ -1416,8 +1954,8 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1416
1954
|
init() {
|
|
1417
1955
|
for (const [providerName, providerConfig] of Object.entries(this.authConfig.providers)) {
|
|
1418
1956
|
if (!providerConfig) continue;
|
|
1419
|
-
const
|
|
1420
|
-
if (!
|
|
1957
|
+
const provider = this.getProviderInstance(providerName, providerConfig);
|
|
1958
|
+
if (!provider) {
|
|
1421
1959
|
continue;
|
|
1422
1960
|
}
|
|
1423
1961
|
this.get(`/auth/${providerName}/login`, async (ctx) => {
|
|
@@ -1425,15 +1963,15 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1425
1963
|
const codeVerifier = providerName === "google" || providerName === "microsoft" || providerName === "auth0" || providerName === "okta" ? generateCodeVerifier() : void 0;
|
|
1426
1964
|
const scopes = providerConfig.scopes || [];
|
|
1427
1965
|
let url;
|
|
1428
|
-
if (
|
|
1429
|
-
url = await
|
|
1430
|
-
} else if (
|
|
1431
|
-
url = await
|
|
1432
|
-
} else if (
|
|
1433
|
-
url = await
|
|
1434
|
-
} else if (
|
|
1966
|
+
if (provider instanceof GitHub) {
|
|
1967
|
+
url = await provider.createAuthorizationURL(state, scopes);
|
|
1968
|
+
} else if (provider instanceof Google || provider instanceof MicrosoftEntraId || provider instanceof Auth0 || provider instanceof Okta) {
|
|
1969
|
+
url = await provider.createAuthorizationURL(state, codeVerifier, scopes);
|
|
1970
|
+
} else if (provider instanceof Apple) {
|
|
1971
|
+
url = await provider.createAuthorizationURL(state, scopes);
|
|
1972
|
+
} else if (provider instanceof OAuth2Client) {
|
|
1435
1973
|
if (!providerConfig.authUrl) return ctx.text("Config error: authUrl required for oauth2", 500);
|
|
1436
|
-
url = await
|
|
1974
|
+
url = await provider.createAuthorizationURL(providerConfig.authUrl, state, scopes);
|
|
1437
1975
|
} else {
|
|
1438
1976
|
return ctx.text("Provider config error", 500);
|
|
1439
1977
|
}
|
|
@@ -1456,19 +1994,19 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1456
1994
|
try {
|
|
1457
1995
|
let tokens;
|
|
1458
1996
|
let idToken;
|
|
1459
|
-
if (
|
|
1460
|
-
tokens = await
|
|
1461
|
-
} else if (
|
|
1997
|
+
if (provider instanceof GitHub) {
|
|
1998
|
+
tokens = await provider.validateAuthorizationCode(code);
|
|
1999
|
+
} else if (provider instanceof Google || provider instanceof MicrosoftEntraId) {
|
|
1462
2000
|
if (!storedVerifier) return ctx.text("Missing verifier", 400);
|
|
1463
|
-
tokens = await
|
|
1464
|
-
} else if (
|
|
1465
|
-
tokens = await
|
|
1466
|
-
} else if (
|
|
1467
|
-
tokens = await
|
|
2001
|
+
tokens = await provider.validateAuthorizationCode(code, storedVerifier);
|
|
2002
|
+
} else if (provider instanceof Auth0 || provider instanceof Okta) {
|
|
2003
|
+
tokens = await provider.validateAuthorizationCode(code, storedVerifier || "");
|
|
2004
|
+
} else if (provider instanceof Apple) {
|
|
2005
|
+
tokens = await provider.validateAuthorizationCode(code);
|
|
1468
2006
|
idToken = tokens.idToken;
|
|
1469
|
-
} else if (
|
|
2007
|
+
} else if (provider instanceof OAuth2Client) {
|
|
1470
2008
|
if (!providerConfig.tokenUrl) return ctx.text("Config error: tokenUrl required for oauth2", 500);
|
|
1471
|
-
tokens = await
|
|
2009
|
+
tokens = await provider.validateAuthorizationCode(providerConfig.tokenUrl, code, null);
|
|
1472
2010
|
}
|
|
1473
2011
|
const accessToken = tokens.accessToken || tokens.access_token;
|
|
1474
2012
|
const user = await this.fetchUser(providerName, accessToken, providerConfig, idToken);
|
|
@@ -1485,9 +2023,9 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1485
2023
|
});
|
|
1486
2024
|
}
|
|
1487
2025
|
}
|
|
1488
|
-
async fetchUser(
|
|
1489
|
-
let user = { id: "unknown", provider
|
|
1490
|
-
if (
|
|
2026
|
+
async fetchUser(provider, token, config, idToken) {
|
|
2027
|
+
let user = { id: "unknown", provider };
|
|
2028
|
+
if (provider === "github") {
|
|
1491
2029
|
const res = await fetch("https://api.github.com/user", {
|
|
1492
2030
|
headers: { Authorization: `Bearer ${token}` }
|
|
1493
2031
|
});
|
|
@@ -1497,10 +2035,10 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1497
2035
|
name: data.name || data.login,
|
|
1498
2036
|
email: data.email,
|
|
1499
2037
|
picture: data.avatar_url,
|
|
1500
|
-
provider
|
|
2038
|
+
provider,
|
|
1501
2039
|
raw: data
|
|
1502
2040
|
};
|
|
1503
|
-
} else if (
|
|
2041
|
+
} else if (provider === "google") {
|
|
1504
2042
|
const res = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
|
|
1505
2043
|
headers: { Authorization: `Bearer ${token}` }
|
|
1506
2044
|
});
|
|
@@ -1510,10 +2048,10 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1510
2048
|
name: data.name,
|
|
1511
2049
|
email: data.email,
|
|
1512
2050
|
picture: data.picture,
|
|
1513
|
-
provider
|
|
2051
|
+
provider,
|
|
1514
2052
|
raw: data
|
|
1515
2053
|
};
|
|
1516
|
-
} else if (
|
|
2054
|
+
} else if (provider === "microsoft") {
|
|
1517
2055
|
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
|
|
1518
2056
|
headers: { Authorization: `Bearer ${token}` }
|
|
1519
2057
|
});
|
|
@@ -1522,12 +2060,12 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1522
2060
|
id: data.id,
|
|
1523
2061
|
name: data.displayName,
|
|
1524
2062
|
email: data.mail || data.userPrincipalName,
|
|
1525
|
-
provider
|
|
2063
|
+
provider,
|
|
1526
2064
|
raw: data
|
|
1527
2065
|
};
|
|
1528
|
-
} else if (
|
|
2066
|
+
} else if (provider === "auth0" || provider === "okta") {
|
|
1529
2067
|
const domain = config.domain.startsWith("http") ? config.domain : `https://${config.domain}`;
|
|
1530
|
-
const endpoint =
|
|
2068
|
+
const endpoint = provider === "auth0" ? `${domain}/userinfo` : `${domain}/oauth2/v1/userinfo`;
|
|
1531
2069
|
const res = await fetch(endpoint, {
|
|
1532
2070
|
headers: { Authorization: `Bearer ${token}` }
|
|
1533
2071
|
});
|
|
@@ -1537,20 +2075,20 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1537
2075
|
name: data.name,
|
|
1538
2076
|
email: data.email,
|
|
1539
2077
|
picture: data.picture,
|
|
1540
|
-
provider
|
|
2078
|
+
provider,
|
|
1541
2079
|
raw: data
|
|
1542
2080
|
};
|
|
1543
|
-
} else if (
|
|
2081
|
+
} else if (provider === "apple") {
|
|
1544
2082
|
if (idToken) {
|
|
1545
2083
|
const payload = jose.decodeJwt(idToken);
|
|
1546
2084
|
user = {
|
|
1547
2085
|
id: payload.sub,
|
|
1548
2086
|
email: payload["email"],
|
|
1549
|
-
provider
|
|
2087
|
+
provider,
|
|
1550
2088
|
raw: payload
|
|
1551
2089
|
};
|
|
1552
2090
|
}
|
|
1553
|
-
} else if (
|
|
2091
|
+
} else if (provider === "oauth2") {
|
|
1554
2092
|
if (config.userInfoUrl) {
|
|
1555
2093
|
const res = await fetch(config.userInfoUrl, {
|
|
1556
2094
|
headers: { Authorization: `Bearer ${token}` }
|
|
@@ -1561,7 +2099,7 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
1561
2099
|
name: data.name,
|
|
1562
2100
|
email: data.email,
|
|
1563
2101
|
picture: data.picture,
|
|
1564
|
-
provider
|
|
2102
|
+
provider,
|
|
1565
2103
|
raw: data
|
|
1566
2104
|
};
|
|
1567
2105
|
}
|
|
@@ -1702,6 +2240,66 @@ function Cors(options = {}) {
|
|
|
1702
2240
|
return response;
|
|
1703
2241
|
};
|
|
1704
2242
|
}
|
|
2243
|
+
function useExpress(expressMiddleware) {
|
|
2244
|
+
return async (ctx, next) => {
|
|
2245
|
+
return new Promise((resolve2, reject) => {
|
|
2246
|
+
const reqStore = {
|
|
2247
|
+
method: ctx.method,
|
|
2248
|
+
url: ctx.url.pathname + ctx.url.search,
|
|
2249
|
+
path: ctx.url.pathname,
|
|
2250
|
+
query: ctx.query,
|
|
2251
|
+
headers: ctx.headers,
|
|
2252
|
+
get: (name) => ctx.headers.get(name)
|
|
2253
|
+
};
|
|
2254
|
+
const req = new Proxy(ctx.request, {
|
|
2255
|
+
get(target, prop) {
|
|
2256
|
+
if (prop in reqStore) return reqStore[prop];
|
|
2257
|
+
const val = target[prop];
|
|
2258
|
+
if (typeof val === "function") return val.bind(target);
|
|
2259
|
+
return val;
|
|
2260
|
+
},
|
|
2261
|
+
set(target, prop, value) {
|
|
2262
|
+
reqStore[prop] = value;
|
|
2263
|
+
ctx.state[prop] = value;
|
|
2264
|
+
return true;
|
|
2265
|
+
}
|
|
2266
|
+
});
|
|
2267
|
+
const res = {
|
|
2268
|
+
locals: {},
|
|
2269
|
+
statusCode: 200,
|
|
2270
|
+
setHeader: (name, value) => {
|
|
2271
|
+
ctx.response.headers.set(name, value);
|
|
2272
|
+
},
|
|
2273
|
+
set: (name, value) => {
|
|
2274
|
+
ctx.response.headers.set(name, value);
|
|
2275
|
+
},
|
|
2276
|
+
end: (chunk) => {
|
|
2277
|
+
resolve2(new Response(chunk, { status: res.statusCode }));
|
|
2278
|
+
},
|
|
2279
|
+
status: (code) => {
|
|
2280
|
+
res.statusCode = code;
|
|
2281
|
+
return res;
|
|
2282
|
+
},
|
|
2283
|
+
send: (body) => {
|
|
2284
|
+
let content = body;
|
|
2285
|
+
if (typeof body === "object") content = JSON.stringify(body);
|
|
2286
|
+
resolve2(new Response(content, { status: res.statusCode }));
|
|
2287
|
+
},
|
|
2288
|
+
json: (body) => {
|
|
2289
|
+
resolve2(Response.json(body, { status: res.statusCode }));
|
|
2290
|
+
}
|
|
2291
|
+
};
|
|
2292
|
+
try {
|
|
2293
|
+
expressMiddleware(req, res, (err) => {
|
|
2294
|
+
if (err) return reject(err);
|
|
2295
|
+
resolve2(next());
|
|
2296
|
+
});
|
|
2297
|
+
} catch (err) {
|
|
2298
|
+
reject(err);
|
|
2299
|
+
}
|
|
2300
|
+
});
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
1705
2303
|
function RateLimit(options = {}) {
|
|
1706
2304
|
const windowMs = options.windowMs || 60 * 1e3;
|
|
1707
2305
|
const max = options.max || 5;
|
|
@@ -1792,10 +2390,43 @@ class ScalarPlugin extends ShokupanRouter {
|
|
|
1792
2390
|
this.get("/scalar.js", (ctx) => {
|
|
1793
2391
|
return ctx.file(__dirname + "/../../node_modules/@scalar/api-reference/dist/browser/standalone.js");
|
|
1794
2392
|
});
|
|
1795
|
-
this.get("/openapi.json", (ctx) => {
|
|
1796
|
-
|
|
2393
|
+
this.get("/openapi.json", async (ctx) => {
|
|
2394
|
+
let spec;
|
|
2395
|
+
if (this.root.openApiSpec) {
|
|
2396
|
+
try {
|
|
2397
|
+
spec = structuredClone(this.root.openApiSpec);
|
|
2398
|
+
} catch (e) {
|
|
2399
|
+
spec = Object.assign({}, this.root.openApiSpec);
|
|
2400
|
+
}
|
|
2401
|
+
} else {
|
|
2402
|
+
spec = await (this.root || this).generateApiSpec();
|
|
2403
|
+
}
|
|
2404
|
+
if (this.pluginOptions.baseDocument) {
|
|
2405
|
+
deepMerge(spec, this.pluginOptions.baseDocument);
|
|
2406
|
+
}
|
|
2407
|
+
return ctx.json(spec);
|
|
1797
2408
|
});
|
|
1798
2409
|
}
|
|
2410
|
+
// New lifecycle method to be called by router.mount
|
|
2411
|
+
onMount(parent) {
|
|
2412
|
+
if (parent.onStart) {
|
|
2413
|
+
parent.onStart(async () => {
|
|
2414
|
+
if (this.pluginOptions.enableStaticAnalysis) {
|
|
2415
|
+
try {
|
|
2416
|
+
const entrypoint = process.argv[1];
|
|
2417
|
+
console.log(`[ScalarPlugin] Running eager static analysis on entrypoint: ${entrypoint}`);
|
|
2418
|
+
const analyzer = new OpenAPIAnalyzer(process.cwd(), entrypoint);
|
|
2419
|
+
let staticSpec = await analyzer.analyze();
|
|
2420
|
+
if (!this.pluginOptions.baseDocument) this.pluginOptions.baseDocument = {};
|
|
2421
|
+
deepMerge(this.pluginOptions.baseDocument, staticSpec);
|
|
2422
|
+
console.log("[ScalarPlugin] Static analysis completed successfully.");
|
|
2423
|
+
} catch (err) {
|
|
2424
|
+
console.error("[ScalarPlugin] Failed to run static analysis:", err);
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
1799
2430
|
}
|
|
1800
2431
|
function SecurityHeaders(options = {}) {
|
|
1801
2432
|
return async (ctx, next) => {
|
|
@@ -2155,6 +2786,30 @@ async function validateValibotWrapper(wrapper, data) {
|
|
|
2155
2786
|
}
|
|
2156
2787
|
return result.output;
|
|
2157
2788
|
}
|
|
2789
|
+
function isClass(schema) {
|
|
2790
|
+
try {
|
|
2791
|
+
if (typeof schema === "function" && /^\s*class\s+/.test(schema.toString())) {
|
|
2792
|
+
return true;
|
|
2793
|
+
}
|
|
2794
|
+
return typeof schema === "function" && schema.prototype && schema.name;
|
|
2795
|
+
} catch {
|
|
2796
|
+
return false;
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
async function validateClassValidator(schema, data) {
|
|
2800
|
+
const object = plainToInstance(schema, data);
|
|
2801
|
+
try {
|
|
2802
|
+
await validateOrReject(object);
|
|
2803
|
+
return object;
|
|
2804
|
+
} catch (errors) {
|
|
2805
|
+
const formattedErrors = Array.isArray(errors) ? errors.map((err) => ({
|
|
2806
|
+
property: err.property,
|
|
2807
|
+
constraints: err.constraints,
|
|
2808
|
+
children: err.children
|
|
2809
|
+
})) : errors;
|
|
2810
|
+
throw new ValidationError(formattedErrors);
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2158
2813
|
const safelyGetBody = async (ctx) => {
|
|
2159
2814
|
const req = ctx.req;
|
|
2160
2815
|
if (req._bodyParsed) {
|
|
@@ -2186,21 +2841,38 @@ const safelyGetBody = async (ctx) => {
|
|
|
2186
2841
|
};
|
|
2187
2842
|
function validate(config) {
|
|
2188
2843
|
return async (ctx, next) => {
|
|
2844
|
+
const dataToValidate = {};
|
|
2845
|
+
if (config.params) dataToValidate.params = ctx.params;
|
|
2846
|
+
let queryObj;
|
|
2847
|
+
if (config.query) {
|
|
2848
|
+
const url = new URL(ctx.req.url);
|
|
2849
|
+
queryObj = Object.fromEntries(url.searchParams.entries());
|
|
2850
|
+
dataToValidate.query = queryObj;
|
|
2851
|
+
}
|
|
2852
|
+
if (config.headers) dataToValidate.headers = Object.fromEntries(ctx.req.headers.entries());
|
|
2853
|
+
let body;
|
|
2854
|
+
if (config.body) {
|
|
2855
|
+
body = await safelyGetBody(ctx);
|
|
2856
|
+
dataToValidate.body = body;
|
|
2857
|
+
}
|
|
2858
|
+
if (ctx.app?.applicationConfig.hooks?.beforeValidate) {
|
|
2859
|
+
await ctx.app.applicationConfig.hooks.beforeValidate(ctx, dataToValidate);
|
|
2860
|
+
}
|
|
2189
2861
|
if (config.params) {
|
|
2190
2862
|
ctx.params = await runValidation(config.params, ctx.params);
|
|
2191
2863
|
}
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
await runValidation(config.query, queryObj);
|
|
2864
|
+
let validQuery;
|
|
2865
|
+
if (config.query && queryObj) {
|
|
2866
|
+
validQuery = await runValidation(config.query, queryObj);
|
|
2196
2867
|
}
|
|
2197
2868
|
if (config.headers) {
|
|
2198
2869
|
const headersObj = Object.fromEntries(ctx.req.headers.entries());
|
|
2199
2870
|
await runValidation(config.headers, headersObj);
|
|
2200
2871
|
}
|
|
2872
|
+
let validBody;
|
|
2201
2873
|
if (config.body) {
|
|
2202
|
-
const
|
|
2203
|
-
|
|
2874
|
+
const b = body ?? await safelyGetBody(ctx);
|
|
2875
|
+
validBody = await runValidation(config.body, b);
|
|
2204
2876
|
const req = ctx.req;
|
|
2205
2877
|
req._bodyValue = validBody;
|
|
2206
2878
|
Object.defineProperty(req, "json", {
|
|
@@ -2209,6 +2881,13 @@ function validate(config) {
|
|
|
2209
2881
|
});
|
|
2210
2882
|
ctx.body = validBody;
|
|
2211
2883
|
}
|
|
2884
|
+
if (ctx.app?.applicationConfig.hooks?.afterValidate) {
|
|
2885
|
+
const validatedData = { ...dataToValidate };
|
|
2886
|
+
if (config.params) validatedData.params = ctx.params;
|
|
2887
|
+
if (config.query) validatedData.query = validQuery;
|
|
2888
|
+
if (config.body) validatedData.body = validBody;
|
|
2889
|
+
await ctx.app.applicationConfig.hooks.afterValidate(ctx, validatedData);
|
|
2890
|
+
}
|
|
2212
2891
|
return next();
|
|
2213
2892
|
};
|
|
2214
2893
|
}
|
|
@@ -2225,6 +2904,18 @@ async function runValidation(schema, data) {
|
|
|
2225
2904
|
if (isValibotWrapper(schema)) {
|
|
2226
2905
|
return validateValibotWrapper(schema, data);
|
|
2227
2906
|
}
|
|
2907
|
+
if (isClass(schema)) {
|
|
2908
|
+
return validateClassValidator(schema, data);
|
|
2909
|
+
}
|
|
2910
|
+
if (isTypeBox(schema)) {
|
|
2911
|
+
return validateTypeBox(schema, data);
|
|
2912
|
+
}
|
|
2913
|
+
if (isAjv(schema)) {
|
|
2914
|
+
return validateAjv(schema, data);
|
|
2915
|
+
}
|
|
2916
|
+
if (isValibotWrapper(schema)) {
|
|
2917
|
+
return validateValibotWrapper(schema, data);
|
|
2918
|
+
}
|
|
2228
2919
|
if (typeof schema === "function") {
|
|
2229
2920
|
return schema(data);
|
|
2230
2921
|
}
|
|
@@ -2244,6 +2935,8 @@ export {
|
|
|
2244
2935
|
$parent,
|
|
2245
2936
|
$routeArgs,
|
|
2246
2937
|
$routeMethods,
|
|
2938
|
+
$routeSpec,
|
|
2939
|
+
$routes,
|
|
2247
2940
|
All,
|
|
2248
2941
|
AuthPlugin,
|
|
2249
2942
|
Body,
|
|
@@ -2279,9 +2972,11 @@ export {
|
|
|
2279
2972
|
ShokupanRequest,
|
|
2280
2973
|
ShokupanResponse,
|
|
2281
2974
|
ShokupanRouter,
|
|
2975
|
+
Spec,
|
|
2282
2976
|
Use,
|
|
2283
2977
|
ValidationError,
|
|
2284
2978
|
compose,
|
|
2979
|
+
useExpress,
|
|
2285
2980
|
valibot,
|
|
2286
2981
|
validate
|
|
2287
2982
|
};
|