@mandujs/core 0.34.2 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/config/mandu.ts +28 -0
- package/src/config/validate.ts +27 -0
- package/src/openapi/generator.ts +264 -7
- package/src/openapi/openapi.test.ts +216 -1
- package/src/runtime/openapi-endpoint.ts +236 -0
- package/src/runtime/server.ts +65 -0
package/package.json
CHANGED
package/src/config/mandu.ts
CHANGED
|
@@ -525,6 +525,34 @@ export interface ManduConfig {
|
|
|
525
525
|
jobs?: CronDef[];
|
|
526
526
|
disabled?: boolean;
|
|
527
527
|
};
|
|
528
|
+
/**
|
|
529
|
+
* Production-grade OpenAPI endpoint.
|
|
530
|
+
*
|
|
531
|
+
* When enabled, the runtime serves the contracts-derived OpenAPI 3.0.3
|
|
532
|
+
* document at `<path>.json` / `<path>.yaml` (default base path
|
|
533
|
+
* `/__mandu/openapi`). The spec is materialized from `.mandu/openapi.json`
|
|
534
|
+
* (emitted by `mandu build`) on first request and cached for the
|
|
535
|
+
* lifetime of the server instance.
|
|
536
|
+
*
|
|
537
|
+
* - `enabled` — default `false`. Do NOT leak the API surface on
|
|
538
|
+
* every internet-facing deployment. Operators must opt in
|
|
539
|
+
* explicitly, or set `MANDU_OPENAPI_ENABLED=1` in the environment
|
|
540
|
+
* for a one-off probe without editing config.
|
|
541
|
+
* - `path` — base URL path (with or without `.json` suffix). The
|
|
542
|
+
* runtime appends `.json` / `.yaml` to serve each variant.
|
|
543
|
+
* Default `/__mandu/openapi`.
|
|
544
|
+
*
|
|
545
|
+
* The response stamps `Cache-Control: public, max-age=0,
|
|
546
|
+
* must-revalidate` plus a SHA-256 ETag over the JSON body so CDNs and
|
|
547
|
+
* browsers revalidate cheaply after every deploy without serving
|
|
548
|
+
* stale specs.
|
|
549
|
+
*
|
|
550
|
+
* @see `docs/runtime/openapi.md`
|
|
551
|
+
*/
|
|
552
|
+
openapi?: {
|
|
553
|
+
enabled?: boolean;
|
|
554
|
+
path?: string;
|
|
555
|
+
};
|
|
528
556
|
/**
|
|
529
557
|
* Phase 18.μ — first-class internationalization.
|
|
530
558
|
*
|
package/src/config/validate.ts
CHANGED
|
@@ -384,6 +384,28 @@ const ObservabilityConfigSchema = z
|
|
|
384
384
|
})
|
|
385
385
|
.strict();
|
|
386
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Production OpenAPI endpoint config (strict).
|
|
389
|
+
*
|
|
390
|
+
* Default `enabled: false` — we validate permissive so `openapi: {}`
|
|
391
|
+
* still loads (equivalent to disabled). Operators explicitly set
|
|
392
|
+
* `enabled: true` OR export `MANDU_OPENAPI_ENABLED=1` to turn the
|
|
393
|
+
* endpoint on.
|
|
394
|
+
*/
|
|
395
|
+
const OpenApiConfigSchema = z
|
|
396
|
+
.object({
|
|
397
|
+
enabled: z.boolean().optional(),
|
|
398
|
+
path: z
|
|
399
|
+
.string()
|
|
400
|
+
.min(1)
|
|
401
|
+
.refine(
|
|
402
|
+
(value) => value.startsWith("/"),
|
|
403
|
+
{ message: "openapi.path must start with '/'" }
|
|
404
|
+
)
|
|
405
|
+
.optional(),
|
|
406
|
+
})
|
|
407
|
+
.strict();
|
|
408
|
+
|
|
387
409
|
const AdapterConfigSchema = z.custom<ManduAdapter | undefined>(
|
|
388
410
|
(value) =>
|
|
389
411
|
value === undefined ||
|
|
@@ -591,6 +613,11 @@ export const ManduConfigSchema = z
|
|
|
591
613
|
seo: SeoConfigSchema.default({}),
|
|
592
614
|
test: TestConfigSchema.default({}),
|
|
593
615
|
observability: ObservabilityConfigSchema.default({}),
|
|
616
|
+
/**
|
|
617
|
+
* Production OpenAPI endpoint (default disabled). See
|
|
618
|
+
* {@link OpenApiConfigSchema} + `docs/runtime/openapi.md`.
|
|
619
|
+
*/
|
|
620
|
+
openapi: OpenApiConfigSchema.optional(),
|
|
594
621
|
/** Phase 18.ζ — ISR / tag-based cache invalidation. Optional. */
|
|
595
622
|
cache: CacheConfigSchema.optional(),
|
|
596
623
|
plugins: z.array(ManduPluginSchema).optional(),
|
package/src/openapi/generator.ts
CHANGED
|
@@ -542,16 +542,150 @@ export async function generateOpenAPIDocument(
|
|
|
542
542
|
};
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
// ============================================
|
|
546
|
+
// YAML Serialization
|
|
547
|
+
// ============================================
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Quote a string value for YAML if required by the spec.
|
|
551
|
+
*
|
|
552
|
+
* Follows a conservative subset of YAML 1.2 rules: any scalar that
|
|
553
|
+
* could be confused for a YAML reserved word, a number, a boolean, a
|
|
554
|
+
* null, or that contains indicator characters must be double-quoted.
|
|
555
|
+
* Everything else is emitted as a plain scalar for maximum
|
|
556
|
+
* readability.
|
|
557
|
+
*/
|
|
558
|
+
function yamlScalar(value: unknown): string {
|
|
559
|
+
if (value === null) return "null";
|
|
560
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
561
|
+
if (typeof value === "number") {
|
|
562
|
+
if (!Number.isFinite(value)) return ".nan";
|
|
563
|
+
return String(value);
|
|
564
|
+
}
|
|
565
|
+
if (typeof value === "string") {
|
|
566
|
+
// Quote strings that a YAML 1.2 parser would otherwise interpret
|
|
567
|
+
// as a non-string scalar (null/bool/number) or that contain indicator
|
|
568
|
+
// characters at positions where they are load-bearing.
|
|
569
|
+
//
|
|
570
|
+
// Conservative heuristic:
|
|
571
|
+
// - empty string
|
|
572
|
+
// - leading/trailing whitespace (would be silently trimmed)
|
|
573
|
+
// - reserved scalars: null / true / false / yes / no / on / off (+ ~)
|
|
574
|
+
// - parses cleanly as a YAML number (integer or float)
|
|
575
|
+
// - contains a flow indicator (`[ ]`, `{ }`, `,`) or a comment start `#`
|
|
576
|
+
// - contains `: ` (key separator) anywhere, or ends with `:`
|
|
577
|
+
// - starts with an indicator char that would collide with block syntax:
|
|
578
|
+
// `-` (sequence), `?` (mapping key), `&`/`*` (anchor/alias),
|
|
579
|
+
// `!` (tag), `|`/`>` (block scalar), `'`/`"` / backtick / `%`/`@`
|
|
580
|
+
// - embedded newline
|
|
581
|
+
const numericLike = /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(value);
|
|
582
|
+
const reservedScalar = /^(~|null|true|false|yes|no|on|off)$/i.test(value);
|
|
583
|
+
const leadingIndicator = /^[-?&*!|>'"%@`]/.test(value);
|
|
584
|
+
const hasFlowOrComment = /[\[\]{},#]/.test(value);
|
|
585
|
+
const hasKeyLike = /:\s/.test(value) || /:$/.test(value);
|
|
586
|
+
const needsQuote =
|
|
587
|
+
value === "" ||
|
|
588
|
+
/^[\s]|[\s]$/.test(value) ||
|
|
589
|
+
reservedScalar ||
|
|
590
|
+
numericLike ||
|
|
591
|
+
leadingIndicator ||
|
|
592
|
+
hasFlowOrComment ||
|
|
593
|
+
hasKeyLike ||
|
|
594
|
+
value.includes("\n");
|
|
595
|
+
if (!needsQuote) return value;
|
|
596
|
+
// Use double quotes with JSON-style escaping (valid YAML subset).
|
|
597
|
+
return JSON.stringify(value);
|
|
598
|
+
}
|
|
599
|
+
// Fallback: JSON-stringify anything exotic.
|
|
600
|
+
return JSON.stringify(value);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function emitYAML(value: unknown, indent: number, lines: string[]): void {
|
|
604
|
+
const pad = " ".repeat(indent);
|
|
605
|
+
|
|
606
|
+
if (value === null || typeof value !== "object") {
|
|
607
|
+
lines.push(`${pad}${yamlScalar(value)}`);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (Array.isArray(value)) {
|
|
612
|
+
if (value.length === 0) {
|
|
613
|
+
lines.push(`${pad}[]`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
for (const item of value) {
|
|
617
|
+
if (item !== null && typeof item === "object" && !Array.isArray(item)) {
|
|
618
|
+
const entries = Object.entries(item).filter(([, v]) => v !== undefined);
|
|
619
|
+
if (entries.length === 0) {
|
|
620
|
+
lines.push(`${pad}- {}`);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const [firstKey, firstVal] = entries[0];
|
|
624
|
+
lines.push(`${pad}- ${yamlKeyValue(firstKey, firstVal, indent + 1)}`);
|
|
625
|
+
for (let i = 1; i < entries.length; i++) {
|
|
626
|
+
const [k, v] = entries[i];
|
|
627
|
+
lines.push(`${pad} ${yamlKeyValue(k, v, indent + 1)}`);
|
|
628
|
+
}
|
|
629
|
+
} else {
|
|
630
|
+
lines.push(`${pad}- ${yamlScalar(item).trimStart()}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Plain object.
|
|
637
|
+
const entries = Object.entries(value as Record<string, unknown>).filter(
|
|
638
|
+
([, v]) => v !== undefined
|
|
639
|
+
);
|
|
640
|
+
if (entries.length === 0) {
|
|
641
|
+
lines.push(`${pad}{}`);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
for (const [k, v] of entries) {
|
|
645
|
+
if (v !== null && typeof v === "object") {
|
|
646
|
+
const isEmptyArray = Array.isArray(v) && v.length === 0;
|
|
647
|
+
const isEmptyObject = !Array.isArray(v) && Object.keys(v).length === 0;
|
|
648
|
+
if (isEmptyArray) {
|
|
649
|
+
lines.push(`${pad}${k}: []`);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (isEmptyObject) {
|
|
653
|
+
lines.push(`${pad}${k}: {}`);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
lines.push(`${pad}${k}:`);
|
|
657
|
+
emitYAML(v, indent + 1, lines);
|
|
658
|
+
} else {
|
|
659
|
+
lines.push(`${pad}${k}: ${yamlScalar(v)}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function yamlKeyValue(key: string, value: unknown, indent: number): string {
|
|
665
|
+
if (value === null || typeof value !== "object") {
|
|
666
|
+
return `${key}: ${yamlScalar(value)}`;
|
|
667
|
+
}
|
|
668
|
+
// Nested object / array: emit header + drop into recursion on subsequent lines.
|
|
669
|
+
// The caller pushes lines after this; we return the header only.
|
|
670
|
+
const tmp: string[] = [];
|
|
671
|
+
emitYAML(value, indent, tmp);
|
|
672
|
+
return `${key}:\n${tmp.join("\n")}`;
|
|
673
|
+
}
|
|
674
|
+
|
|
545
675
|
/**
|
|
546
|
-
* Convert OpenAPI document to YAML string
|
|
676
|
+
* Convert an OpenAPI document to a YAML string.
|
|
677
|
+
*
|
|
678
|
+
* Emits a conservative YAML 1.2 subset — two-space indentation,
|
|
679
|
+
* double-quoted scalars where required, literal `[]` / `{}` for empty
|
|
680
|
+
* collections. Produces output that round-trips cleanly through
|
|
681
|
+
* Swagger UI, openapi-generator, and `yq`.
|
|
547
682
|
*/
|
|
548
683
|
export function openAPIToYAML(doc: OpenAPIDocument): string {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
.replace(/\]/g, "");
|
|
684
|
+
const lines: string[] = [];
|
|
685
|
+
emitYAML(doc, 0, lines);
|
|
686
|
+
// Terminate with a newline per POSIX text-file convention so `cat`
|
|
687
|
+
// doesn't elide the final byte.
|
|
688
|
+
return lines.join("\n") + "\n";
|
|
555
689
|
}
|
|
556
690
|
|
|
557
691
|
/**
|
|
@@ -560,3 +694,126 @@ export function openAPIToYAML(doc: OpenAPIDocument): string {
|
|
|
560
694
|
export function openAPIToJSON(doc: OpenAPIDocument): string {
|
|
561
695
|
return JSON.stringify(doc, null, 2);
|
|
562
696
|
}
|
|
697
|
+
|
|
698
|
+
// ============================================
|
|
699
|
+
// Content hashing (for ETag)
|
|
700
|
+
// ============================================
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Compute a SHA-256 hex digest of the serialized OpenAPI document.
|
|
704
|
+
*
|
|
705
|
+
* Uses `Bun.CryptoHasher` when available (zero allocations beyond the
|
|
706
|
+
* input buffer) and falls back to the WebCrypto API so the helper works
|
|
707
|
+
* under edge runtimes (Cloudflare Workers, Deno Deploy).
|
|
708
|
+
*/
|
|
709
|
+
export async function hashOpenAPIJSON(json: string): Promise<string> {
|
|
710
|
+
const bunGlobal = (globalThis as { Bun?: { CryptoHasher?: new (algo: string) => { update(input: string | Uint8Array): void; digest(encoding: "hex"): string } } }).Bun;
|
|
711
|
+
if (bunGlobal?.CryptoHasher) {
|
|
712
|
+
const hasher = new bunGlobal.CryptoHasher("sha256");
|
|
713
|
+
hasher.update(json);
|
|
714
|
+
return hasher.digest("hex");
|
|
715
|
+
}
|
|
716
|
+
// WebCrypto fallback (browser / edge runtimes).
|
|
717
|
+
const subtle = (globalThis as { crypto?: { subtle?: SubtleCrypto } }).crypto?.subtle;
|
|
718
|
+
if (subtle) {
|
|
719
|
+
const data = new TextEncoder().encode(json);
|
|
720
|
+
const buf = await subtle.digest("SHA-256", data);
|
|
721
|
+
return Array.from(new Uint8Array(buf))
|
|
722
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
723
|
+
.join("");
|
|
724
|
+
}
|
|
725
|
+
// Last-resort Node fallback.
|
|
726
|
+
const nodeCrypto = await import("node:crypto");
|
|
727
|
+
return nodeCrypto.createHash("sha256").update(json).digest("hex");
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ============================================
|
|
731
|
+
// Build-time artifact emission
|
|
732
|
+
// ============================================
|
|
733
|
+
|
|
734
|
+
export interface OpenAPIArtifactPaths {
|
|
735
|
+
json: string;
|
|
736
|
+
yaml: string;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export interface OpenAPIArtifactResult {
|
|
740
|
+
/** OpenAPI 3.0.3 document serialized as JSON (two-space indented). */
|
|
741
|
+
json: string;
|
|
742
|
+
/** OpenAPI 3.0.3 document serialized as YAML. */
|
|
743
|
+
yaml: string;
|
|
744
|
+
/** SHA-256 hex digest of `json` — used as the runtime ETag. */
|
|
745
|
+
hash: string;
|
|
746
|
+
/** Absolute paths of the emitted artifacts (matches the dir argument). */
|
|
747
|
+
paths: OpenAPIArtifactPaths;
|
|
748
|
+
/** Number of route paths captured in the document. */
|
|
749
|
+
pathCount: number;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Generate and write both `openapi.json` and `openapi.yaml` under the
|
|
754
|
+
* given output directory. Returns the serialized bodies and the SHA-256
|
|
755
|
+
* hash so callers (e.g., `mandu build`) can log a stable artifact id
|
|
756
|
+
* and the runtime endpoint can skip re-hashing on boot.
|
|
757
|
+
*
|
|
758
|
+
* Safe to call repeatedly — the directory is created if absent and the
|
|
759
|
+
* existing files are overwritten atomically by `Bun.write`.
|
|
760
|
+
*/
|
|
761
|
+
export async function writeOpenAPIArtifacts(
|
|
762
|
+
manifest: RoutesManifest,
|
|
763
|
+
rootDir: string,
|
|
764
|
+
outDir: string,
|
|
765
|
+
options: Parameters<typeof generateOpenAPIDocument>[2] = {}
|
|
766
|
+
): Promise<OpenAPIArtifactResult> {
|
|
767
|
+
const { mkdir } = await import("node:fs/promises");
|
|
768
|
+
const pathMod = await import("node:path");
|
|
769
|
+
|
|
770
|
+
const doc = await generateOpenAPIDocument(manifest, rootDir, options);
|
|
771
|
+
const json = openAPIToJSON(doc);
|
|
772
|
+
const yaml = openAPIToYAML(doc);
|
|
773
|
+
const hash = await hashOpenAPIJSON(json);
|
|
774
|
+
|
|
775
|
+
const absoluteDir = pathMod.isAbsolute(outDir)
|
|
776
|
+
? outDir
|
|
777
|
+
: pathMod.join(rootDir, outDir);
|
|
778
|
+
await mkdir(absoluteDir, { recursive: true });
|
|
779
|
+
|
|
780
|
+
const jsonPath = pathMod.join(absoluteDir, "openapi.json");
|
|
781
|
+
const yamlPath = pathMod.join(absoluteDir, "openapi.yaml");
|
|
782
|
+
await Bun.write(jsonPath, json);
|
|
783
|
+
await Bun.write(yamlPath, yaml);
|
|
784
|
+
|
|
785
|
+
return {
|
|
786
|
+
json,
|
|
787
|
+
yaml,
|
|
788
|
+
hash,
|
|
789
|
+
paths: { json: jsonPath, yaml: yamlPath },
|
|
790
|
+
pathCount: Object.keys(doc.paths).length,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Read previously emitted OpenAPI artifacts from disk. Returns
|
|
796
|
+
* `null` when either file is missing so the caller can fall back to
|
|
797
|
+
* on-demand generation without logging a misleading error.
|
|
798
|
+
*/
|
|
799
|
+
export async function readOpenAPIArtifacts(
|
|
800
|
+
outDir: string,
|
|
801
|
+
rootDir: string
|
|
802
|
+
): Promise<{ json: string; yaml: string; hash: string } | null> {
|
|
803
|
+
const pathMod = await import("node:path");
|
|
804
|
+
const absoluteDir = pathMod.isAbsolute(outDir)
|
|
805
|
+
? outDir
|
|
806
|
+
: pathMod.join(rootDir, outDir);
|
|
807
|
+
const jsonPath = pathMod.join(absoluteDir, "openapi.json");
|
|
808
|
+
const yamlPath = pathMod.join(absoluteDir, "openapi.yaml");
|
|
809
|
+
|
|
810
|
+
const jsonFile = Bun.file(jsonPath);
|
|
811
|
+
const yamlFile = Bun.file(yamlPath);
|
|
812
|
+
if (!(await jsonFile.exists()) || !(await yamlFile.exists())) {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
const json = await jsonFile.text();
|
|
816
|
+
const yaml = await yamlFile.text();
|
|
817
|
+
const hash = await hashOpenAPIJSON(json);
|
|
818
|
+
return { json, yaml, hash };
|
|
819
|
+
}
|
|
@@ -4,7 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, test, expect } from "bun:test";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
generateOpenAPIDocument,
|
|
9
|
+
hashOpenAPIJSON,
|
|
10
|
+
openAPIToJSON,
|
|
11
|
+
openAPIToYAML,
|
|
12
|
+
readOpenAPIArtifacts,
|
|
13
|
+
writeOpenAPIArtifacts,
|
|
14
|
+
zodToOpenAPISchema,
|
|
15
|
+
} from "./generator";
|
|
16
|
+
import type { RoutesManifest } from "../spec/schema";
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
8
20
|
|
|
9
21
|
describe("zodToOpenAPISchema", () => {
|
|
10
22
|
describe("primitive types", () => {
|
|
@@ -275,3 +287,206 @@ describe("Real-world contract conversion", () => {
|
|
|
275
287
|
expect(result.properties!.pagination.properties!.page.minimum).toBe(1);
|
|
276
288
|
});
|
|
277
289
|
});
|
|
290
|
+
|
|
291
|
+
// ============================================
|
|
292
|
+
// End-to-end: manifest → OpenAPI → disk artifacts
|
|
293
|
+
// ============================================
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Create a scratch project on disk with one API contract so we can
|
|
297
|
+
* exercise the full build pipeline (generate + write + read) without
|
|
298
|
+
* faking the dynamic import path.
|
|
299
|
+
*/
|
|
300
|
+
async function buildFixture(): Promise<{ rootDir: string; manifest: RoutesManifest; cleanup: () => Promise<void> }> {
|
|
301
|
+
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-"));
|
|
302
|
+
const contractPath = "contracts/users.contract.ts";
|
|
303
|
+
const contractAbs = path.join(rootDir, contractPath);
|
|
304
|
+
await fs.mkdir(path.dirname(contractAbs), { recursive: true });
|
|
305
|
+
await fs.writeFile(
|
|
306
|
+
contractAbs,
|
|
307
|
+
`import { z } from "zod";
|
|
308
|
+
export default {
|
|
309
|
+
name: "users",
|
|
310
|
+
description: "List / create users",
|
|
311
|
+
tags: ["users"],
|
|
312
|
+
request: {
|
|
313
|
+
GET: {
|
|
314
|
+
query: z.object({ limit: z.number().int().min(1).max(100).optional() }),
|
|
315
|
+
},
|
|
316
|
+
POST: {
|
|
317
|
+
body: z.object({
|
|
318
|
+
name: z.string().min(2),
|
|
319
|
+
email: z.string().email(),
|
|
320
|
+
}),
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
response: {
|
|
324
|
+
200: z.object({
|
|
325
|
+
items: z.array(z.object({ id: z.string().uuid(), name: z.string() })),
|
|
326
|
+
}),
|
|
327
|
+
201: z.object({ id: z.string().uuid() }),
|
|
328
|
+
400: z.object({ error: z.string() }),
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
`,
|
|
332
|
+
"utf-8"
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const manifest: RoutesManifest = {
|
|
336
|
+
version: 1,
|
|
337
|
+
routes: [
|
|
338
|
+
{
|
|
339
|
+
id: "api/users",
|
|
340
|
+
pattern: "/api/users",
|
|
341
|
+
kind: "api",
|
|
342
|
+
module: contractPath,
|
|
343
|
+
contractModule: contractPath,
|
|
344
|
+
methods: ["GET", "POST"],
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
rootDir,
|
|
351
|
+
manifest,
|
|
352
|
+
cleanup: async () => {
|
|
353
|
+
await fs.rm(rootDir, { recursive: true, force: true });
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
describe("generateOpenAPIDocument", () => {
|
|
359
|
+
test("should produce a valid OpenAPI 3.0.3 document with paths + methods + schemas", async () => {
|
|
360
|
+
const { rootDir, manifest, cleanup } = await buildFixture();
|
|
361
|
+
try {
|
|
362
|
+
const doc = await generateOpenAPIDocument(manifest, rootDir, {
|
|
363
|
+
title: "Users API",
|
|
364
|
+
version: "2.1.0",
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
expect(doc.openapi).toBe("3.0.3");
|
|
368
|
+
expect(doc.info.title).toBe("Users API");
|
|
369
|
+
expect(doc.info.version).toBe("2.1.0");
|
|
370
|
+
|
|
371
|
+
// The `/api/users` path should carry both GET and POST operations.
|
|
372
|
+
expect(doc.paths["/api/users"]).toBeDefined();
|
|
373
|
+
expect(doc.paths["/api/users"].get).toBeDefined();
|
|
374
|
+
expect(doc.paths["/api/users"].post).toBeDefined();
|
|
375
|
+
|
|
376
|
+
// GET query param `limit` should be captured.
|
|
377
|
+
const getParams = doc.paths["/api/users"].get!.parameters ?? [];
|
|
378
|
+
const limitParam = getParams.find((p) => p.name === "limit");
|
|
379
|
+
expect(limitParam).toBeDefined();
|
|
380
|
+
expect(limitParam!.in).toBe("query");
|
|
381
|
+
|
|
382
|
+
// POST body should be captured with JSON content.
|
|
383
|
+
const postBody = doc.paths["/api/users"].post!.requestBody;
|
|
384
|
+
expect(postBody).toBeDefined();
|
|
385
|
+
expect(postBody!.content["application/json"]).toBeDefined();
|
|
386
|
+
|
|
387
|
+
// Response schemas should be present.
|
|
388
|
+
const okResponse = doc.paths["/api/users"].get!.responses["200"];
|
|
389
|
+
expect(okResponse).toBeDefined();
|
|
390
|
+
expect(okResponse.content!["application/json"]).toBeDefined();
|
|
391
|
+
} finally {
|
|
392
|
+
await cleanup();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe("hashOpenAPIJSON", () => {
|
|
398
|
+
test("should produce a deterministic 64-char hex digest", async () => {
|
|
399
|
+
const sample = JSON.stringify({ hello: "world" });
|
|
400
|
+
const hashA = await hashOpenAPIJSON(sample);
|
|
401
|
+
const hashB = await hashOpenAPIJSON(sample);
|
|
402
|
+
|
|
403
|
+
expect(hashA).toBe(hashB);
|
|
404
|
+
expect(hashA).toMatch(/^[0-9a-f]{64}$/);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("should change with input", async () => {
|
|
408
|
+
const hashA = await hashOpenAPIJSON(JSON.stringify({ v: 1 }));
|
|
409
|
+
const hashB = await hashOpenAPIJSON(JSON.stringify({ v: 2 }));
|
|
410
|
+
expect(hashA).not.toBe(hashB);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("openAPIToYAML", () => {
|
|
415
|
+
test("should emit a parseable two-space-indented YAML subset", () => {
|
|
416
|
+
const doc = {
|
|
417
|
+
openapi: "3.0.3" as const,
|
|
418
|
+
info: { title: "Test API", version: "1.0.0" },
|
|
419
|
+
paths: {
|
|
420
|
+
"/api/users": {
|
|
421
|
+
get: {
|
|
422
|
+
summary: "List",
|
|
423
|
+
responses: { "200": { description: "OK" } },
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const yaml = openAPIToYAML(doc);
|
|
430
|
+
|
|
431
|
+
// Must NOT contain raw curly braces from the old naive converter.
|
|
432
|
+
expect(yaml).toContain("openapi: 3.0.3");
|
|
433
|
+
expect(yaml).toContain("info:");
|
|
434
|
+
expect(yaml).toContain(" title: Test API");
|
|
435
|
+
expect(yaml).toContain(" version: 1.0.0");
|
|
436
|
+
expect(yaml).toContain("paths:");
|
|
437
|
+
// Path key contains `/` and `:` suffix is handled as block mapping,
|
|
438
|
+
// so the OpenAPI path appears as a block-mapping key — expect its
|
|
439
|
+
// presence without assuming quoting.
|
|
440
|
+
expect(yaml).toMatch(/\/api\/users:/);
|
|
441
|
+
expect(yaml).toContain(" summary: List");
|
|
442
|
+
expect(yaml).toMatch(/\n$/);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe("writeOpenAPIArtifacts + readOpenAPIArtifacts", () => {
|
|
447
|
+
test("should write openapi.json and openapi.yaml then read them back with matching hash", async () => {
|
|
448
|
+
const { rootDir, manifest, cleanup } = await buildFixture();
|
|
449
|
+
try {
|
|
450
|
+
const written = await writeOpenAPIArtifacts(manifest, rootDir, ".mandu", {
|
|
451
|
+
title: "Artifact Test",
|
|
452
|
+
version: "1.0.0",
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
expect(written.paths.json.endsWith("openapi.json")).toBe(true);
|
|
456
|
+
expect(written.paths.yaml.endsWith("openapi.yaml")).toBe(true);
|
|
457
|
+
expect(written.hash).toMatch(/^[0-9a-f]{64}$/);
|
|
458
|
+
expect(written.pathCount).toBeGreaterThan(0);
|
|
459
|
+
|
|
460
|
+
// Both files should exist on disk.
|
|
461
|
+
const jsonOnDisk = await fs.readFile(written.paths.json, "utf-8");
|
|
462
|
+
const yamlOnDisk = await fs.readFile(written.paths.yaml, "utf-8");
|
|
463
|
+
expect(jsonOnDisk).toBe(written.json);
|
|
464
|
+
expect(yamlOnDisk).toBe(written.yaml);
|
|
465
|
+
|
|
466
|
+
// JSON should parse.
|
|
467
|
+
const parsed = JSON.parse(jsonOnDisk);
|
|
468
|
+
expect(parsed.openapi).toBe("3.0.3");
|
|
469
|
+
expect(parsed.info.title).toBe("Artifact Test");
|
|
470
|
+
|
|
471
|
+
// readOpenAPIArtifacts should recover the same bodies and recompute
|
|
472
|
+
// the same hash.
|
|
473
|
+
const readBack = await readOpenAPIArtifacts(".mandu", rootDir);
|
|
474
|
+
expect(readBack).not.toBeNull();
|
|
475
|
+
expect(readBack!.hash).toBe(written.hash);
|
|
476
|
+
expect(readBack!.json).toBe(written.json);
|
|
477
|
+
expect(readBack!.yaml).toBe(written.yaml);
|
|
478
|
+
} finally {
|
|
479
|
+
await cleanup();
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("readOpenAPIArtifacts returns null when artifacts are missing", async () => {
|
|
484
|
+
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-miss-"));
|
|
485
|
+
try {
|
|
486
|
+
const result = await readOpenAPIArtifacts(".mandu", rootDir);
|
|
487
|
+
expect(result).toBeNull();
|
|
488
|
+
} finally {
|
|
489
|
+
await fs.rm(rootDir, { recursive: true, force: true });
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime OpenAPI endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Serves the build-time `.mandu/openapi.json` / `.mandu/openapi.yaml`
|
|
5
|
+
* artifacts at a stable URL (default `/__mandu/openapi.json` and
|
|
6
|
+
* `.yaml`) so API consumers (Postman, codegen, Swagger UI proxies) can
|
|
7
|
+
* fetch a canonical spec without reaching into the framework's dev
|
|
8
|
+
* Kitchen dashboard.
|
|
9
|
+
*
|
|
10
|
+
* Contract:
|
|
11
|
+
* - Disabled by default — the server dispatcher gates this handler
|
|
12
|
+
* behind `ManduConfig.openapi.enabled` or the
|
|
13
|
+
* `MANDU_OPENAPI_ENABLED=1` env var.
|
|
14
|
+
* - Lazy-load artifacts on first request; in-memory cache survives
|
|
15
|
+
* for the lifetime of the server instance. Re-deploy to invalidate.
|
|
16
|
+
* - If artifacts are missing on disk, fall back to live generation
|
|
17
|
+
* from the registered manifest so `mandu dev` users still get a
|
|
18
|
+
* valid response without running `mandu build` first.
|
|
19
|
+
* - ETag = SHA-256 of the JSON body. Supports `If-None-Match` 304
|
|
20
|
+
* short-circuiting so downstream caches (CDN, reverse proxy) behave
|
|
21
|
+
* correctly.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { RoutesManifest } from "../spec/schema";
|
|
25
|
+
import {
|
|
26
|
+
generateOpenAPIDocument,
|
|
27
|
+
hashOpenAPIJSON,
|
|
28
|
+
openAPIToJSON,
|
|
29
|
+
openAPIToYAML,
|
|
30
|
+
readOpenAPIArtifacts,
|
|
31
|
+
} from "../openapi/generator";
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_OPENAPI_BASE_PATH = "/__mandu/openapi";
|
|
34
|
+
const DEFAULT_ARTIFACT_DIR = ".mandu";
|
|
35
|
+
const CACHE_CONTROL = "public, max-age=0, must-revalidate";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Runtime-resolved OpenAPI endpoint configuration. Mirrors the shape
|
|
39
|
+
* the server threads through `ServerRegistrySettings` so the hot-path
|
|
40
|
+
* dispatch can stay allocation-free.
|
|
41
|
+
*/
|
|
42
|
+
export interface OpenAPIEndpointSettings {
|
|
43
|
+
/** Base path without the trailing `.json`/`.yaml`. Default `/__mandu/openapi`. */
|
|
44
|
+
basePath: string;
|
|
45
|
+
/** Absolute directory containing `openapi.json` / `openapi.yaml` artifacts. */
|
|
46
|
+
artifactDir: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface CacheEntry {
|
|
50
|
+
json: string;
|
|
51
|
+
yaml: string;
|
|
52
|
+
hash: string;
|
|
53
|
+
etag: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Module-scoped cache — invalidated by `invalidateOpenAPIEndpointCache()`. */
|
|
57
|
+
let cache: CacheEntry | null = null;
|
|
58
|
+
let pending: Promise<CacheEntry | null> | null = null;
|
|
59
|
+
|
|
60
|
+
/** Test / HMR hook: drop the cached spec so the next request recomputes. */
|
|
61
|
+
export function invalidateOpenAPIEndpointCache(): void {
|
|
62
|
+
cache = null;
|
|
63
|
+
pending = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the OpenAPI body, either from disk artifacts (preferred) or
|
|
68
|
+
* by generating live from the manifest. Concurrent callers share one
|
|
69
|
+
* in-flight load so we never rebuild the spec twice on a thundering
|
|
70
|
+
* herd.
|
|
71
|
+
*/
|
|
72
|
+
async function loadSpec(
|
|
73
|
+
manifest: RoutesManifest,
|
|
74
|
+
rootDir: string,
|
|
75
|
+
settings: OpenAPIEndpointSettings
|
|
76
|
+
): Promise<CacheEntry | null> {
|
|
77
|
+
if (cache) return cache;
|
|
78
|
+
if (pending) return pending;
|
|
79
|
+
|
|
80
|
+
pending = (async (): Promise<CacheEntry | null> => {
|
|
81
|
+
try {
|
|
82
|
+
// 1. Prefer on-disk artifacts (produced by `mandu build`). Keeps
|
|
83
|
+
// request-time cost at a single file read instead of walking
|
|
84
|
+
// every contract module again.
|
|
85
|
+
const fromDisk = await readOpenAPIArtifacts(settings.artifactDir, rootDir);
|
|
86
|
+
if (fromDisk) {
|
|
87
|
+
const entry: CacheEntry = {
|
|
88
|
+
json: fromDisk.json,
|
|
89
|
+
yaml: fromDisk.yaml,
|
|
90
|
+
hash: fromDisk.hash,
|
|
91
|
+
etag: `"${fromDisk.hash}"`,
|
|
92
|
+
};
|
|
93
|
+
cache = entry;
|
|
94
|
+
return entry;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 2. Fallback: generate live. Useful in `mandu dev` before the
|
|
98
|
+
// user has run `mandu build`, or in test harnesses that boot a
|
|
99
|
+
// server directly from a manifest fixture.
|
|
100
|
+
const doc = await generateOpenAPIDocument(manifest, rootDir);
|
|
101
|
+
const json = openAPIToJSON(doc);
|
|
102
|
+
const yaml = openAPIToYAML(doc);
|
|
103
|
+
const hash = await hashOpenAPIJSON(json);
|
|
104
|
+
const entry: CacheEntry = {
|
|
105
|
+
json,
|
|
106
|
+
yaml,
|
|
107
|
+
hash,
|
|
108
|
+
etag: `"${hash}"`,
|
|
109
|
+
};
|
|
110
|
+
cache = entry;
|
|
111
|
+
return entry;
|
|
112
|
+
} catch {
|
|
113
|
+
// Swallow the error — a 500 here would be worse DX than a 404.
|
|
114
|
+
// Invalidate so the next request retries.
|
|
115
|
+
cache = null;
|
|
116
|
+
return null;
|
|
117
|
+
} finally {
|
|
118
|
+
pending = null;
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
|
|
122
|
+
return pending;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Handle a GET request for the OpenAPI endpoint.
|
|
127
|
+
*
|
|
128
|
+
* Returns `null` when the pathname does not match — the dispatcher
|
|
129
|
+
* should fall through to the normal route resolution pipeline. Returns
|
|
130
|
+
* a `Response` for both hit (200 + spec) and miss (404 when the spec
|
|
131
|
+
* cannot be materialized). Only `GET` and `HEAD` are accepted; every
|
|
132
|
+
* other method gets a 405 with `Allow: GET, HEAD`.
|
|
133
|
+
*/
|
|
134
|
+
export async function handleOpenAPIRequest(
|
|
135
|
+
req: Request,
|
|
136
|
+
pathname: string,
|
|
137
|
+
manifest: RoutesManifest,
|
|
138
|
+
rootDir: string,
|
|
139
|
+
settings: OpenAPIEndpointSettings
|
|
140
|
+
): Promise<Response | null> {
|
|
141
|
+
const jsonPath = `${settings.basePath}.json`;
|
|
142
|
+
const yamlPath = `${settings.basePath}.yaml`;
|
|
143
|
+
|
|
144
|
+
let variant: "json" | "yaml";
|
|
145
|
+
if (pathname === jsonPath) variant = "json";
|
|
146
|
+
else if (pathname === yamlPath) variant = "yaml";
|
|
147
|
+
else return null;
|
|
148
|
+
|
|
149
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
150
|
+
return new Response("Method Not Allowed", {
|
|
151
|
+
status: 405,
|
|
152
|
+
headers: {
|
|
153
|
+
Allow: "GET, HEAD",
|
|
154
|
+
"Cache-Control": "no-store",
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const entry = await loadSpec(manifest, rootDir, settings);
|
|
160
|
+
if (!entry) {
|
|
161
|
+
return new Response("Not Found", { status: 404 });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Conditional-GET: honour `If-None-Match` for CDN / browser caches.
|
|
165
|
+
const ifNoneMatch = req.headers.get("if-none-match");
|
|
166
|
+
if (ifNoneMatch && ifNoneMatch === entry.etag) {
|
|
167
|
+
return new Response(null, {
|
|
168
|
+
status: 304,
|
|
169
|
+
headers: {
|
|
170
|
+
ETag: entry.etag,
|
|
171
|
+
"Cache-Control": CACHE_CONTROL,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const body = variant === "json" ? entry.json : entry.yaml;
|
|
177
|
+
const contentType =
|
|
178
|
+
variant === "json"
|
|
179
|
+
? "application/json; charset=utf-8"
|
|
180
|
+
: "application/yaml; charset=utf-8";
|
|
181
|
+
|
|
182
|
+
// HEAD responses carry the headers but drop the body.
|
|
183
|
+
const responseBody = req.method === "HEAD" ? null : body;
|
|
184
|
+
return new Response(responseBody, {
|
|
185
|
+
status: 200,
|
|
186
|
+
headers: {
|
|
187
|
+
"Content-Type": contentType,
|
|
188
|
+
"Cache-Control": CACHE_CONTROL,
|
|
189
|
+
ETag: entry.etag,
|
|
190
|
+
// Expose ETag to browser JS so API explorer UIs can display the
|
|
191
|
+
// deploy identifier without a round-trip.
|
|
192
|
+
"Access-Control-Expose-Headers": "ETag",
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resolve the effective endpoint settings for a server boot. Normalizes
|
|
199
|
+
* the `path` option (users may pass with or without leading slash, and
|
|
200
|
+
* with or without the `.json` suffix) and chooses the artifact
|
|
201
|
+
* directory default.
|
|
202
|
+
*/
|
|
203
|
+
export function resolveOpenAPIEndpointSettings(
|
|
204
|
+
rootDir: string,
|
|
205
|
+
path?: string
|
|
206
|
+
): OpenAPIEndpointSettings {
|
|
207
|
+
let basePath = path ?? DEFAULT_OPENAPI_BASE_PATH;
|
|
208
|
+
if (!basePath.startsWith("/")) basePath = `/${basePath}`;
|
|
209
|
+
// Strip trailing `.json` / `.yaml` / trailing slash so the handler can
|
|
210
|
+
// append suffixes uniformly.
|
|
211
|
+
basePath = basePath.replace(/\.(json|yaml|yml)$/i, "").replace(/\/+$/, "");
|
|
212
|
+
if (basePath === "") basePath = DEFAULT_OPENAPI_BASE_PATH;
|
|
213
|
+
|
|
214
|
+
// POSIX-style join — artifact paths are treated as absolute by
|
|
215
|
+
// `readOpenAPIArtifacts`, which itself uses `node:path` for portability.
|
|
216
|
+
const normalizedRoot = rootDir.replace(/[\\/]+$/, "");
|
|
217
|
+
const separator = normalizedRoot.includes("\\") ? "\\" : "/";
|
|
218
|
+
const artifactDir = `${normalizedRoot}${separator}${DEFAULT_ARTIFACT_DIR}`;
|
|
219
|
+
return { basePath, artifactDir };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Decide whether the OpenAPI endpoint should be active for this
|
|
224
|
+
* server instance. The config flag wins; an explicit `false` still
|
|
225
|
+
* disables the endpoint even when the env var is set (explicit > env).
|
|
226
|
+
* Absent config + truthy env var (`MANDU_OPENAPI_ENABLED=1`) opts in.
|
|
227
|
+
*/
|
|
228
|
+
export function isOpenAPIEndpointEnabled(
|
|
229
|
+
enabled: boolean | undefined,
|
|
230
|
+
env: NodeJS.ProcessEnv | Record<string, string | undefined> = process.env
|
|
231
|
+
): boolean {
|
|
232
|
+
if (enabled === true) return true;
|
|
233
|
+
if (enabled === false) return false;
|
|
234
|
+
const raw = env.MANDU_OPENAPI_ENABLED;
|
|
235
|
+
return raw === "1" || raw === "true";
|
|
236
|
+
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -62,6 +62,11 @@ import {
|
|
|
62
62
|
isObservabilityExposed,
|
|
63
63
|
recordHttpRequest,
|
|
64
64
|
} from "../observability/metrics";
|
|
65
|
+
import {
|
|
66
|
+
handleOpenAPIRequest,
|
|
67
|
+
isOpenAPIEndpointEnabled,
|
|
68
|
+
resolveOpenAPIEndpointSettings,
|
|
69
|
+
} from "./openapi-endpoint";
|
|
65
70
|
// Phase 18.ψ — user-facing perf marks dashboard append. We include a
|
|
66
71
|
// `perf` block on the `/_mandu/heap` payload when the feature is active
|
|
67
72
|
// so operators see a unified view (heap + caches + perf histogram).
|
|
@@ -499,6 +504,30 @@ export interface ServerOptions {
|
|
|
499
504
|
serviceName?: string;
|
|
500
505
|
};
|
|
501
506
|
};
|
|
507
|
+
/**
|
|
508
|
+
* Production-grade OpenAPI endpoint.
|
|
509
|
+
*
|
|
510
|
+
* When enabled, the runtime serves the contracts-derived OpenAPI 3.0.3
|
|
511
|
+
* document at `<path>.json` / `<path>.yaml` (default base path
|
|
512
|
+
* `/__mandu/openapi`). The body is loaded once from the build-time
|
|
513
|
+
* artifacts under `.mandu/openapi.{json,yaml}` (emitted by
|
|
514
|
+
* `mandu build`) and cached for the lifetime of the server.
|
|
515
|
+
*
|
|
516
|
+
* - `enabled` — default `false`. Security-conscious: production
|
|
517
|
+
* deployments must explicitly opt in, or set
|
|
518
|
+
* `MANDU_OPENAPI_ENABLED=1` in the environment.
|
|
519
|
+
* - `path` — base URL path (with or without `.json` suffix).
|
|
520
|
+
* Default `/__mandu/openapi`.
|
|
521
|
+
*
|
|
522
|
+
* Wired from `ManduConfig.openapi`. The response carries
|
|
523
|
+
* `Cache-Control: public, max-age=0, must-revalidate` + a SHA-256
|
|
524
|
+
* ETag so CDNs / browsers revalidate on every deploy without holding
|
|
525
|
+
* stale specs.
|
|
526
|
+
*/
|
|
527
|
+
openapi?: {
|
|
528
|
+
enabled?: boolean;
|
|
529
|
+
path?: string;
|
|
530
|
+
};
|
|
502
531
|
/**
|
|
503
532
|
* Phase 18 — prerendered HTML pass-through (SSG).
|
|
504
533
|
*
|
|
@@ -787,6 +816,14 @@ export interface ServerRegistrySettings {
|
|
|
787
816
|
* request opens a root span via `tracer.startSpanFromRequest()`.
|
|
788
817
|
*/
|
|
789
818
|
tracer?: import("../observability/tracing").Tracer;
|
|
819
|
+
/**
|
|
820
|
+
* Production OpenAPI endpoint — resolved from `ServerOptions.openapi`.
|
|
821
|
+
* `undefined` means the endpoint is disabled (hot path branch-free).
|
|
822
|
+
* When populated, every request whose pathname matches
|
|
823
|
+
* `<basePath>.json` / `<basePath>.yaml` short-circuits before route
|
|
824
|
+
* dispatch to serve the cached spec body.
|
|
825
|
+
*/
|
|
826
|
+
openapi?: import("./openapi-endpoint").OpenAPIEndpointSettings;
|
|
790
827
|
/**
|
|
791
828
|
* Phase 18 — resolved prerender pass-through state. `undefined`
|
|
792
829
|
* means the feature is disabled for this server instance.
|
|
@@ -3916,6 +3953,26 @@ async function handleRequestInternal(
|
|
|
3916
3953
|
}
|
|
3917
3954
|
}
|
|
3918
3955
|
|
|
3956
|
+
// Production OpenAPI endpoint — `/__mandu/openapi.json` + `.yaml`.
|
|
3957
|
+
// Gated behind `ManduConfig.openapi.enabled` (or MANDU_OPENAPI_ENABLED=1).
|
|
3958
|
+
// Every response carries an ETag so CDNs / browsers revalidate cheaply
|
|
3959
|
+
// after a deploy. Falls through to route dispatch if disabled OR the
|
|
3960
|
+
// pathname doesn't match the configured base path.
|
|
3961
|
+
if (settings.openapi) {
|
|
3962
|
+
const openapiManifest: RoutesManifest = {
|
|
3963
|
+
version: 1,
|
|
3964
|
+
routes: router.getRoutes(),
|
|
3965
|
+
};
|
|
3966
|
+
const openapiResponse = await handleOpenAPIRequest(
|
|
3967
|
+
req,
|
|
3968
|
+
pathname,
|
|
3969
|
+
openapiManifest,
|
|
3970
|
+
settings.rootDir,
|
|
3971
|
+
settings.openapi
|
|
3972
|
+
);
|
|
3973
|
+
if (openapiResponse) return ok(openapiResponse);
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3919
3976
|
// 2. Kitchen dev dashboard (dev mode only)
|
|
3920
3977
|
if (settings.isDev && pathname.startsWith(KITCHEN_PREFIX) && registry.kitchen) {
|
|
3921
3978
|
const kitchenResponse = await registry.kitchen.handle(req, pathname);
|
|
@@ -4252,6 +4309,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
4252
4309
|
spa,
|
|
4253
4310
|
devtools,
|
|
4254
4311
|
observability: observabilityOption,
|
|
4312
|
+
openapi: openapiOption,
|
|
4255
4313
|
prerender: prerenderOption,
|
|
4256
4314
|
middleware: middlewareOption,
|
|
4257
4315
|
rpc: rpcOption,
|
|
@@ -4373,6 +4431,13 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
4373
4431
|
heapEndpoint: observabilityOption?.heapEndpoint,
|
|
4374
4432
|
metricsEndpoint: observabilityOption?.metricsEndpoint,
|
|
4375
4433
|
tracer: tracerInstance.enabled ? tracerInstance : undefined,
|
|
4434
|
+
// Production OpenAPI endpoint — default OFF so an internet-facing
|
|
4435
|
+
// deployment does not leak its API surface without explicit opt-in.
|
|
4436
|
+
// `MANDU_OPENAPI_ENABLED=1` in the environment forces-on without a
|
|
4437
|
+
// config edit; explicit `enabled: false` still wins (explicit > env).
|
|
4438
|
+
openapi: isOpenAPIEndpointEnabled(openapiOption?.enabled)
|
|
4439
|
+
? resolveOpenAPIEndpointSettings(rootDir, openapiOption?.path)
|
|
4440
|
+
: undefined,
|
|
4376
4441
|
prerender: prerenderSettings,
|
|
4377
4442
|
middlewareChain,
|
|
4378
4443
|
i18n: i18nOption,
|