@mandujs/core 0.35.0 → 0.36.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -26,6 +26,33 @@ import {
26
26
  isZodRequired,
27
27
  } from "../contract/zod-utils";
28
28
 
29
+ // ============================================
30
+ // Schema name-hint side table
31
+ // ============================================
32
+
33
+ /**
34
+ * Side-channel naming hints for generated OpenAPI schemas.
35
+ *
36
+ * The hoist pass needs a preferred name for each object schema but we
37
+ * don't want to serialize that hint into the output document. A WeakMap
38
+ * keyed by the generated `OpenAPISchema` object gives us name access
39
+ * during the post-processing pass without polluting the spec JSON.
40
+ *
41
+ * Hints are advisory — the hoist pass falls back to a deterministic
42
+ * `Schema_<hash8>` name when no hint is attached.
43
+ */
44
+ const schemaNameHints = new WeakMap<OpenAPISchema, string>();
45
+
46
+ function setSchemaNameHint(schema: OpenAPISchema | undefined, hint: string | undefined): void {
47
+ if (!schema || !hint) return;
48
+ // Don't overwrite an existing hint — first attribution wins. This matches
49
+ // the "first-come wins" policy for structurally-identical schemas with
50
+ // different user-given names.
51
+ if (!schemaNameHints.has(schema)) {
52
+ schemaNameHints.set(schema, hint);
53
+ }
54
+ }
55
+
29
56
  // ============================================
30
57
  // OpenAPI Types
31
58
  // ============================================
@@ -140,11 +167,14 @@ export interface OpenAPIDocument {
140
167
  export function zodToOpenAPISchema(zodSchema: z.ZodTypeAny): OpenAPISchema {
141
168
  const typeName = getZodTypeName(zodSchema);
142
169
 
143
- // Handle ZodOptional
170
+ // Handle ZodOptional — optionality is expressed by the parent object's
171
+ // `required[]` array (or by `parameter.required: false`), NOT by the
172
+ // field's own schema. Emitting `nullable: true` here would conflate
173
+ // "may be absent" with "may literally be null", which breaks Postman,
174
+ // codegen, and Swagger UI (they'd all treat the field as nullable).
144
175
  if (typeName === "ZodOptional") {
145
176
  const inner = getZodInnerType(zodSchema);
146
- const innerSchema = inner ? zodToOpenAPISchema(inner) : {};
147
- return { ...innerSchema, nullable: true };
177
+ return inner ? zodToOpenAPISchema(inner) : {};
148
178
  }
149
179
 
150
180
  // Handle ZodDefault
@@ -370,6 +400,35 @@ function generateParameters(
370
400
  return parameters;
371
401
  }
372
402
 
403
+ /**
404
+ * Convert an arbitrary identifier to a PascalCase token suitable for an
405
+ * OpenAPI component name. Non-alphanumeric characters become word
406
+ * separators; leading digits are prefixed with `_` so the result is a
407
+ * valid JSON Schema identifier.
408
+ */
409
+ function pascal(input: string): string {
410
+ if (!input) return "";
411
+ const words = input
412
+ .split(/[^a-zA-Z0-9]+/g)
413
+ .filter((w) => w.length > 0)
414
+ .map((w) => w[0].toUpperCase() + w.slice(1));
415
+ const joined = words.join("");
416
+ if (!joined) return "";
417
+ return /^[0-9]/.test(joined) ? `_${joined}` : joined;
418
+ }
419
+
420
+ /**
421
+ * Derive a naming hint root for a contract. Prefers the explicit
422
+ * `contract.name`; falls back to the route id (e.g. `api/users` →
423
+ * `ApiUsers`). Guarantees we always have a non-empty PascalCase prefix
424
+ * for generated component names.
425
+ */
426
+ function deriveContractNameRoot(contract: ContractSchema, route: RouteSpec): string {
427
+ const hint = contract.name ?? route.id;
428
+ const pascalized = pascal(hint);
429
+ return pascalized || "Schema";
430
+ }
431
+
373
432
  /**
374
433
  * Generate OpenAPI operation for a method
375
434
  */
@@ -386,6 +445,11 @@ function generateOperation(
386
445
  responses: {},
387
446
  };
388
447
 
448
+ // Derive a naming hint root from the contract for the hoist pass.
449
+ // Example: `contract.name = "users"` → `Users`. Falls back to a
450
+ // route-derived identifier so every contract has *some* stable prefix.
451
+ const nameRoot = deriveContractNameRoot(contract, route);
452
+
389
453
  // Parameters
390
454
  if (methodSchema) {
391
455
  const params = generateParameters(methodSchema, route.pattern);
@@ -395,8 +459,13 @@ function generateOperation(
395
459
 
396
460
  // Request body
397
461
  if (methodSchema.body) {
462
+ const bodySchema = zodToOpenAPISchema(methodSchema.body);
463
+ // Hint: `UsersBody` / `UsersPostBody` — method-qualified avoids
464
+ // collisions when GET/POST/PUT on the same contract have distinct
465
+ // request bodies.
466
+ setSchemaNameHint(bodySchema, `${nameRoot}${pascal(method)}Body`);
398
467
  const requestBodyContent: OpenAPIRequestBody["content"]["application/json"] = {
399
- schema: zodToOpenAPISchema(methodSchema.body),
468
+ schema: bodySchema,
400
469
  };
401
470
 
402
471
  // Add examples if provided
@@ -430,6 +499,10 @@ function generateOperation(
430
499
  }
431
500
 
432
501
  const schema = zodToOpenAPISchema(zodSchema);
502
+ // Hint: `UsersResponse200` / `UsersPostResponse201`. Status-qualified
503
+ // so `200` and `400` responses don't collide when both are
504
+ // `{ error: string }` style objects.
505
+ setSchemaNameHint(schema, `${nameRoot}${pascal(method)}Response${statusCode}`);
433
506
  const hasContent = Object.keys(schema).length > 0;
434
507
 
435
508
  const responseContent: OpenAPIResponse["content"] = hasContent
@@ -493,6 +566,17 @@ export async function generateOpenAPIDocument(
493
566
  version?: string;
494
567
  description?: string;
495
568
  servers?: OpenAPIServer[];
569
+ /**
570
+ * Hoist schemas that appear ≥`hoistThreshold` times into
571
+ * `components.schemas` and replace inline occurrences with `$ref`.
572
+ * @default true
573
+ */
574
+ hoistSchemas?: boolean;
575
+ /**
576
+ * Minimum occurrence count required to hoist a schema.
577
+ * @default 2
578
+ */
579
+ hoistThreshold?: number;
496
580
  } = {}
497
581
  ): Promise<OpenAPIDocument> {
498
582
  const paths: Record<string, OpenAPIPathItem> = {};
@@ -527,7 +611,7 @@ export async function generateOpenAPIDocument(
527
611
  paths[openApiPattern] = pathItem;
528
612
  }
529
613
 
530
- return {
614
+ const doc: OpenAPIDocument = {
531
615
  openapi: "3.0.3",
532
616
  info: {
533
617
  title: options.title || "Mandu API",
@@ -540,6 +624,277 @@ export async function generateOpenAPIDocument(
540
624
  paths,
541
625
  tags: Array.from(tags).map((name) => ({ name })),
542
626
  };
627
+
628
+ // Post-processing: hoist shared schemas into components.schemas.
629
+ // `hoistSchemas: false` → pure backwards-compatible inline output.
630
+ const hoistEnabled = options.hoistSchemas !== false;
631
+ if (hoistEnabled) {
632
+ await hoistSharedSchemas(doc, {
633
+ threshold: options.hoistThreshold ?? 2,
634
+ });
635
+ }
636
+
637
+ return doc;
638
+ }
639
+
640
+ // ============================================
641
+ // Schema hoisting (post-processing)
642
+ // ============================================
643
+
644
+ export interface HoistOptions {
645
+ /** Minimum occurrences required to hoist. Default `2`. */
646
+ threshold?: number;
647
+ }
648
+
649
+ interface SchemaOccurrence {
650
+ /** Structural hash of the schema body (identity key). */
651
+ hash: string;
652
+ /** Every parent reference we might need to rewrite to `$ref`. */
653
+ sites: Array<{ parent: Record<string, unknown>; key: string | number }>;
654
+ /** The canonical schema body (first-seen value; all sites share equal shape). */
655
+ body: OpenAPISchema;
656
+ /** Ordered list of name hints collected at each occurrence site. */
657
+ hints: string[];
658
+ }
659
+
660
+ /**
661
+ * Hoist shared object schemas into `components.schemas` and replace
662
+ * inline occurrences with `$ref` pointers.
663
+ *
664
+ * Only hoists:
665
+ * - `type: "object"` schemas (primitives / enums / unions-of-primitives stay inline).
666
+ * - Schemas appearing at a "hoistable site" (requestBody/response root or a top-level
667
+ * property of either). Path/query/header parameter schemas are explicitly skipped.
668
+ * - Schemas reaching the occurrence threshold (default 2).
669
+ *
670
+ * Naming:
671
+ * - First hint wins for structurally-identical schemas.
672
+ * - Name collisions across structurally-different schemas are disambiguated with
673
+ * `_v2`, `_v3`, … suffixes (deterministic by walk order).
674
+ * - Hint-less schemas fall back to `Schema_<first-8-hex-of-hash>`.
675
+ */
676
+ export async function hoistSharedSchemas(
677
+ doc: OpenAPIDocument,
678
+ options: HoistOptions = {}
679
+ ): Promise<void> {
680
+ const threshold = Math.max(2, options.threshold ?? 2);
681
+ const occurrences = new Map<string, SchemaOccurrence>();
682
+
683
+ // --- Pass 1: walk every hoistable site and record occurrences. ---
684
+ for (const pathItem of Object.values(doc.paths)) {
685
+ for (const method of ["get", "post", "put", "patch", "delete"] as const) {
686
+ const op = pathItem[method];
687
+ if (!op) continue;
688
+
689
+ // Request body root schema (+ top-level property values).
690
+ if (op.requestBody?.content) {
691
+ for (const mediaObj of Object.values(op.requestBody.content)) {
692
+ recordHoistableTree(mediaObj as Record<string, unknown>, "schema", occurrences);
693
+ }
694
+ }
695
+
696
+ // Response root schema (+ top-level property values).
697
+ for (const resp of Object.values(op.responses)) {
698
+ if (!resp.content) continue;
699
+ for (const mediaObj of Object.values(resp.content)) {
700
+ recordHoistableTree(mediaObj as Record<string, unknown>, "schema", occurrences);
701
+ }
702
+ }
703
+ }
704
+ }
705
+
706
+ // --- Pass 2: pick the winners (count ≥ threshold, type === "object"). ---
707
+ const winners: Array<{ name: string; hash: string; body: OpenAPISchema; sites: SchemaOccurrence["sites"] }> = [];
708
+ const nameIndex = new Map<string, string>(); // name → hash (for collision detection)
709
+
710
+ // Stable iteration: sort by hash so hoisted output is deterministic
711
+ // regardless of JS property enumeration order quirks.
712
+ const entries = Array.from(occurrences.entries()).sort(([a], [b]) => a.localeCompare(b));
713
+ for (const [hash, entry] of entries) {
714
+ if (entry.sites.length < threshold) continue;
715
+ if (!isHoistableObject(entry.body)) continue;
716
+
717
+ // Pick the first-seen hint; fall back to the hash-derived default.
718
+ const preferred = entry.hints[0] || `Schema_${hash.slice(0, 8)}`;
719
+ let finalName = preferred;
720
+ let suffix = 2;
721
+ // If the name is already claimed by a *different* hash, walk `_v2`, `_v3`…
722
+ while (nameIndex.has(finalName) && nameIndex.get(finalName) !== hash) {
723
+ finalName = `${preferred}_v${suffix++}`;
724
+ }
725
+ nameIndex.set(finalName, hash);
726
+ winners.push({ name: finalName, hash, body: entry.body, sites: entry.sites });
727
+ }
728
+
729
+ if (winners.length === 0) return;
730
+
731
+ // --- Pass 3: install winners into components.schemas + rewrite sites. ---
732
+ if (!doc.components) doc.components = {};
733
+ if (!doc.components.schemas) doc.components.schemas = {};
734
+ const schemas = doc.components.schemas;
735
+
736
+ for (const winner of winners) {
737
+ schemas[winner.name] = winner.body;
738
+ const ref: OpenAPISchema = { $ref: `#/components/schemas/${winner.name}` };
739
+ for (const site of winner.sites) {
740
+ // Replace inline schema with a `$ref` pointer. We write a *new*
741
+ // ref object per site (JSON.stringify would collapse shared refs
742
+ // anyway, but distinct identities make future passes safer).
743
+ (site.parent as Record<string | number, unknown>)[site.key] = { ...ref };
744
+ }
745
+ }
746
+ }
747
+
748
+ /**
749
+ * Walk a container object and record every hoistable schema site. A
750
+ * hoistable site is:
751
+ *
752
+ * 1. The root schema at `container[key]` (typically the `"schema"` key of a
753
+ * requestBody/response media type object).
754
+ * 2. Every top-level property of that root, *if* the root is an object schema.
755
+ *
756
+ * Nested properties beyond depth 1 are intentionally ignored — hoisting
757
+ * deep nests produces spec trees that are harder for codegen tools to
758
+ * follow than the inline original.
759
+ */
760
+ function recordHoistableTree(
761
+ container: Record<string, unknown>,
762
+ key: string,
763
+ occurrences: Map<string, SchemaOccurrence>
764
+ ): void {
765
+ const root = container[key] as OpenAPISchema | undefined;
766
+ if (!root || typeof root !== "object") return;
767
+ // Skip anything that's already a $ref (e.g., from an earlier pass).
768
+ if ("$ref" in root && root.$ref) return;
769
+
770
+ // Record the root itself (regardless of object vs primitive — we filter
771
+ // in pass 2 so that primitives with many occurrences still get counted
772
+ // but are never emitted as component entries).
773
+ recordOccurrence(root, container as Record<string, unknown>, key, occurrences);
774
+
775
+ // Drill into top-level properties for object roots.
776
+ if (root.type === "object" && root.properties) {
777
+ for (const [propKey, propSchema] of Object.entries(root.properties)) {
778
+ if (!propSchema || typeof propSchema !== "object") continue;
779
+ if ("$ref" in propSchema && propSchema.$ref) continue;
780
+ recordOccurrence(
781
+ propSchema as OpenAPISchema,
782
+ root.properties as Record<string, unknown>,
783
+ propKey,
784
+ occurrences
785
+ );
786
+ }
787
+ }
788
+
789
+ // Drill into array items when the root is an array — the element shape
790
+ // is the real reusable schema, not the array wrapper itself.
791
+ if (root.type === "array" && root.items) {
792
+ const items = root.items;
793
+ if (items && typeof items === "object" && !("$ref" in items && items.$ref)) {
794
+ recordOccurrence(
795
+ items,
796
+ root as unknown as Record<string, unknown>,
797
+ "items",
798
+ occurrences
799
+ );
800
+ }
801
+ }
802
+ }
803
+
804
+ function recordOccurrence(
805
+ schema: OpenAPISchema,
806
+ parent: Record<string, unknown>,
807
+ key: string | number,
808
+ occurrences: Map<string, SchemaOccurrence>
809
+ ): void {
810
+ const hash = structuralHash(schema);
811
+ let entry = occurrences.get(hash);
812
+ if (!entry) {
813
+ entry = { hash, sites: [], body: schema, hints: [] };
814
+ occurrences.set(hash, entry);
815
+ }
816
+ entry.sites.push({ parent, key });
817
+ const hint = schemaNameHints.get(schema);
818
+ if (hint && !entry.hints.includes(hint)) {
819
+ entry.hints.push(hint);
820
+ }
821
+ }
822
+
823
+ /**
824
+ * A schema is hoistable iff it is an object with ≥1 property. We
825
+ * deliberately skip:
826
+ * - primitives (`string`, `number`, `boolean`, `integer`) — they are
827
+ * small enough that inline is clearer than a pointer chase.
828
+ * - enums and unions of primitives — same argument, and codegen tools
829
+ * tend to de-duplicate these separately.
830
+ * - empty object schemas — no information to share.
831
+ * - $ref passthroughs — already hoisted.
832
+ * - object schemas with a composed keyword (allOf/oneOf/anyOf) but no
833
+ * `properties` — these are structural compositions, not data shapes.
834
+ */
835
+ function isHoistableObject(schema: OpenAPISchema): boolean {
836
+ if (!schema || typeof schema !== "object") return false;
837
+ if (schema.$ref) return false;
838
+ if (schema.type !== "object") return false;
839
+ if (!schema.properties) return false;
840
+ if (Object.keys(schema.properties).length === 0) return false;
841
+ return true;
842
+ }
843
+
844
+ /**
845
+ * Stable structural hash of a schema. Sorts object keys recursively
846
+ * before stringifying so `{a:1,b:2}` and `{b:2,a:1}` hash identically,
847
+ * then feeds the canonical string through SHA-256.
848
+ */
849
+ function structuralHash(schema: OpenAPISchema): string {
850
+ const canonical = stableStringify(schema);
851
+ return sha256Hex(canonical);
852
+ }
853
+
854
+ function stableStringify(value: unknown): string {
855
+ if (value === null || value === undefined) return "null";
856
+ if (typeof value === "boolean" || typeof value === "number") return JSON.stringify(value);
857
+ if (typeof value === "string") return JSON.stringify(value);
858
+ if (Array.isArray(value)) {
859
+ return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
860
+ }
861
+ if (typeof value === "object") {
862
+ const keys = Object.keys(value as Record<string, unknown>).sort();
863
+ const parts: string[] = [];
864
+ for (const k of keys) {
865
+ const v = (value as Record<string, unknown>)[k];
866
+ if (v === undefined) continue;
867
+ parts.push(JSON.stringify(k) + ":" + stableStringify(v));
868
+ }
869
+ return "{" + parts.join(",") + "}";
870
+ }
871
+ return JSON.stringify(value);
872
+ }
873
+
874
+ /**
875
+ * Synchronous SHA-256 hex digest. Uses `Bun.CryptoHasher` when present,
876
+ * then Node `crypto.createHash` as a fallback. WebCrypto is async-only
877
+ * so we intentionally skip it here — the hoist pass hashes many small
878
+ * payloads and the sync path keeps the post-process simple and fast.
879
+ *
880
+ * This is a purely structural identity key (not a security primitive),
881
+ * so the brief Node-fallback surface is acceptable.
882
+ */
883
+ function sha256Hex(input: string): string {
884
+ const bunGlobal = (globalThis as { Bun?: { CryptoHasher?: new (algo: string) => { update(input: string | Uint8Array): void; digest(encoding: "hex"): string } } }).Bun;
885
+ if (bunGlobal?.CryptoHasher) {
886
+ const hasher = new bunGlobal.CryptoHasher("sha256");
887
+ hasher.update(input);
888
+ return hasher.digest("hex");
889
+ }
890
+ // Node fallback — `require` at call time to keep module-load lean.
891
+ // Callers who run in an edge runtime without Node crypto should have
892
+ // Bun available; we therefore don't bother with a WebCrypto async
893
+ // path (it would turn `hoistSharedSchemas` into an unnecessarily async
894
+ // cascade on the hot path).
895
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
896
+ const nodeCrypto = require("node:crypto") as typeof import("node:crypto");
897
+ return nodeCrypto.createHash("sha256").update(input).digest("hex");
543
898
  }
544
899
 
545
900
  // ============================================
@@ -7,12 +7,14 @@ import { z } from "zod";
7
7
  import {
8
8
  generateOpenAPIDocument,
9
9
  hashOpenAPIJSON,
10
+ hoistSharedSchemas,
10
11
  openAPIToJSON,
11
12
  openAPIToYAML,
12
13
  readOpenAPIArtifacts,
13
14
  writeOpenAPIArtifacts,
14
15
  zodToOpenAPISchema,
15
16
  } from "./generator";
17
+ import type { OpenAPIDocument } from "./generator";
16
18
  import type { RoutesManifest } from "../spec/schema";
17
19
  import fs from "node:fs/promises";
18
20
  import os from "node:os";
@@ -147,12 +149,14 @@ describe("zodToOpenAPISchema", () => {
147
149
  });
148
150
 
149
151
  describe("modifiers", () => {
150
- test("should convert ZodOptional", () => {
152
+ test("should convert ZodOptional — unwraps without adding nullable (optional != nullable)", () => {
151
153
  const schema = z.string().optional();
152
154
  const result = zodToOpenAPISchema(schema);
153
155
 
154
156
  expect(result.type).toBe("string");
155
- expect(result.nullable).toBe(true);
157
+ // Optionality is expressed by the parent object's required[] / parameter.required,
158
+ // NOT by marking the field nullable. See generator.ts comment.
159
+ expect(result.nullable).toBeUndefined();
156
160
  });
157
161
 
158
162
  test("should convert ZodNullable", () => {
@@ -163,6 +167,26 @@ describe("zodToOpenAPISchema", () => {
163
167
  expect(result.nullable).toBe(true);
164
168
  });
165
169
 
170
+ test("should convert ZodOptional(ZodNullable) — inner nullable preserved", () => {
171
+ const schema = z.string().nullable().optional();
172
+ const result = zodToOpenAPISchema(schema);
173
+
174
+ expect(result.type).toBe("string");
175
+ expect(result.nullable).toBe(true);
176
+ });
177
+
178
+ test("object with optional field — field omitted from required[] but schema itself is not nullable", () => {
179
+ const schema = z.object({
180
+ name: z.string(),
181
+ age: z.number().optional(),
182
+ });
183
+ const result = zodToOpenAPISchema(schema);
184
+
185
+ expect(result.type).toBe("object");
186
+ expect(result.required).toEqual(["name"]);
187
+ expect(result.properties?.age).toEqual({ type: "number" });
188
+ });
189
+
166
190
  test("should convert ZodDefault", () => {
167
191
  const schema = z.number().default(10);
168
192
  const result = zodToOpenAPISchema(schema);
@@ -490,3 +514,433 @@ describe("writeOpenAPIArtifacts + readOpenAPIArtifacts", () => {
490
514
  }
491
515
  });
492
516
  });
517
+
518
+ // ============================================
519
+ // Schema hoisting (shared schemas → components.schemas)
520
+ // ============================================
521
+
522
+ /**
523
+ * Build a two-contract fixture where both contracts share the same
524
+ * `User` body shape. The generator should hoist `{ name, email }` into
525
+ * `components.schemas` when hoisting is enabled.
526
+ */
527
+ async function buildSharedSchemaFixture(opts: {
528
+ /** Override the `name` attribute on each contract (in file order). */
529
+ contractNames?: [string, string];
530
+ /** Replace the second contract's body shape to exercise collision paths. */
531
+ secondBody?: string;
532
+ } = {}): Promise<{
533
+ rootDir: string;
534
+ manifest: RoutesManifest;
535
+ cleanup: () => Promise<void>;
536
+ }> {
537
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-hoist-"));
538
+ const [nameA, nameB] = opts.contractNames ?? ["users", "admins"];
539
+ const bodyB =
540
+ opts.secondBody ??
541
+ `z.object({ name: z.string().min(2), email: z.string().email() })`;
542
+
543
+ await fs.mkdir(path.join(rootDir, "contracts"), { recursive: true });
544
+ await fs.writeFile(
545
+ path.join(rootDir, "contracts/users.contract.ts"),
546
+ `import { z } from "zod";
547
+ export default {
548
+ name: "${nameA}",
549
+ request: {
550
+ POST: {
551
+ body: z.object({ name: z.string().min(2), email: z.string().email() }),
552
+ },
553
+ },
554
+ response: {
555
+ 200: z.object({ id: z.string().uuid(), status: z.string() }),
556
+ },
557
+ };
558
+ `,
559
+ "utf-8"
560
+ );
561
+ await fs.writeFile(
562
+ path.join(rootDir, "contracts/admins.contract.ts"),
563
+ `import { z } from "zod";
564
+ export default {
565
+ name: "${nameB}",
566
+ request: {
567
+ POST: {
568
+ body: ${bodyB},
569
+ },
570
+ },
571
+ response: {
572
+ 200: z.object({ id: z.string().uuid(), status: z.string() }),
573
+ },
574
+ };
575
+ `,
576
+ "utf-8"
577
+ );
578
+
579
+ const manifest: RoutesManifest = {
580
+ version: 1,
581
+ routes: [
582
+ {
583
+ id: "api/users",
584
+ pattern: "/api/users",
585
+ kind: "api",
586
+ module: "contracts/users.contract.ts",
587
+ contractModule: "contracts/users.contract.ts",
588
+ methods: ["POST"],
589
+ },
590
+ {
591
+ id: "api/admins",
592
+ pattern: "/api/admins",
593
+ kind: "api",
594
+ module: "contracts/admins.contract.ts",
595
+ contractModule: "contracts/admins.contract.ts",
596
+ methods: ["POST"],
597
+ },
598
+ ],
599
+ };
600
+
601
+ return {
602
+ rootDir,
603
+ manifest,
604
+ cleanup: async () => {
605
+ await fs.rm(rootDir, { recursive: true, force: true });
606
+ },
607
+ };
608
+ }
609
+
610
+ describe("hoistSharedSchemas", () => {
611
+ test("two routes sharing a body shape emit a single components.schemas entry with $ref", async () => {
612
+ const { rootDir, manifest, cleanup } = await buildSharedSchemaFixture();
613
+ try {
614
+ const doc = await generateOpenAPIDocument(manifest, rootDir, {
615
+ title: "Hoist Test",
616
+ });
617
+
618
+ // The shared POST body should be hoisted. There are also shared
619
+ // 200 responses so we expect at least 2 hoisted entries.
620
+ expect(doc.components?.schemas).toBeDefined();
621
+ const schemaNames = Object.keys(doc.components!.schemas!);
622
+ expect(schemaNames.length).toBeGreaterThanOrEqual(1);
623
+
624
+ const usersBody = doc.paths["/api/users"].post!.requestBody!.content["application/json"].schema;
625
+ const adminsBody = doc.paths["/api/admins"].post!.requestBody!.content["application/json"].schema;
626
+
627
+ // Both bodies must now be $ref pointers into components.schemas.
628
+ expect(usersBody.$ref).toBeDefined();
629
+ expect(adminsBody.$ref).toBeDefined();
630
+ // ... and they must point to the exact same entry (shared shape).
631
+ expect(usersBody.$ref).toBe(adminsBody.$ref);
632
+
633
+ // The pointed-to entry must exist and retain the object shape.
634
+ const refTarget = usersBody.$ref!.replace("#/components/schemas/", "");
635
+ const hoisted = doc.components!.schemas![refTarget];
636
+ expect(hoisted.type).toBe("object");
637
+ expect(hoisted.properties!.name).toBeDefined();
638
+ expect(hoisted.properties!.email).toBeDefined();
639
+ } finally {
640
+ await cleanup();
641
+ }
642
+ });
643
+
644
+ test("one-off schema stays inline (no hoist, no components.schemas entry for it)", async () => {
645
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-oneoff-"));
646
+ try {
647
+ await fs.mkdir(path.join(rootDir, "contracts"), { recursive: true });
648
+ await fs.writeFile(
649
+ path.join(rootDir, "contracts/solo.contract.ts"),
650
+ `import { z } from "zod";
651
+ export default {
652
+ name: "solo",
653
+ request: {
654
+ POST: { body: z.object({ uniqueField: z.string() }) },
655
+ },
656
+ response: { 200: z.object({ ok: z.boolean() }) },
657
+ };
658
+ `,
659
+ "utf-8"
660
+ );
661
+ const manifest: RoutesManifest = {
662
+ version: 1,
663
+ routes: [
664
+ {
665
+ id: "api/solo",
666
+ pattern: "/api/solo",
667
+ kind: "api",
668
+ module: "contracts/solo.contract.ts",
669
+ contractModule: "contracts/solo.contract.ts",
670
+ methods: ["POST"],
671
+ },
672
+ ],
673
+ };
674
+
675
+ const doc = await generateOpenAPIDocument(manifest, rootDir);
676
+ const body = doc.paths["/api/solo"].post!.requestBody!.content["application/json"].schema;
677
+
678
+ // Solo schemas must NOT be hoisted — stays inline.
679
+ expect(body.$ref).toBeUndefined();
680
+ expect(body.type).toBe("object");
681
+ expect(body.properties?.uniqueField).toBeDefined();
682
+
683
+ // components.schemas may exist (e.g. for the default 500 shape if it
684
+ // happened to collide with another schema), but the one-off body's
685
+ // uniqueField shape must not appear as a hoisted entry.
686
+ const schemas = doc.components?.schemas ?? {};
687
+ for (const entry of Object.values(schemas)) {
688
+ expect(entry.properties?.uniqueField).toBeUndefined();
689
+ }
690
+ } finally {
691
+ await fs.rm(rootDir, { recursive: true, force: true });
692
+ }
693
+ });
694
+
695
+ test("same shape with different user-given names collapses to one entry (first-come wins)", async () => {
696
+ const { rootDir, manifest, cleanup } = await buildSharedSchemaFixture({
697
+ contractNames: ["users", "admins"],
698
+ });
699
+ try {
700
+ const doc = await generateOpenAPIDocument(manifest, rootDir);
701
+
702
+ const usersBody = doc.paths["/api/users"].post!.requestBody!.content["application/json"].schema;
703
+ const adminsBody = doc.paths["/api/admins"].post!.requestBody!.content["application/json"].schema;
704
+
705
+ // Both must resolve to the *same* $ref — structural identity wins
706
+ // over the user-given name split.
707
+ expect(usersBody.$ref).toBeDefined();
708
+ expect(usersBody.$ref).toBe(adminsBody.$ref);
709
+
710
+ // Exactly one body component (not two) for this shape. Count entries
711
+ // that match `{name,email}` shape.
712
+ const sharedEntries = Object.values(doc.components!.schemas!).filter(
713
+ (s) => s.properties?.name && s.properties?.email
714
+ );
715
+ expect(sharedEntries.length).toBe(1);
716
+ } finally {
717
+ await cleanup();
718
+ }
719
+ });
720
+
721
+ test("name collision across structurally-different schemas → _v2 suffix", () => {
722
+ // Hand-craft a doc with two different body shapes whose name hints
723
+ // would collide. We exercise the hoist pass directly so we can
724
+ // control the hints without routing through Zod.
725
+ const shapeA = {
726
+ type: "object" as const,
727
+ properties: { a: { type: "string" as const } },
728
+ required: ["a"],
729
+ };
730
+ const shapeB = {
731
+ type: "object" as const,
732
+ properties: { b: { type: "number" as const } },
733
+ required: ["b"],
734
+ };
735
+
736
+ // Two routes use shape A, two use shape B — every schema gets the
737
+ // same name hint "Shared". The second winning hash should be renamed
738
+ // to "Shared_v2".
739
+ const a1 = { ...shapeA };
740
+ const a2 = { ...shapeA };
741
+ const b1 = { ...shapeB };
742
+ const b2 = { ...shapeB };
743
+
744
+ const doc: OpenAPIDocument = {
745
+ openapi: "3.0.3",
746
+ info: { title: "t", version: "1.0.0" },
747
+ paths: {
748
+ "/a1": {
749
+ post: {
750
+ responses: {},
751
+ requestBody: { content: { "application/json": { schema: a1 } } },
752
+ },
753
+ },
754
+ "/a2": {
755
+ post: {
756
+ responses: {},
757
+ requestBody: { content: { "application/json": { schema: a2 } } },
758
+ },
759
+ },
760
+ "/b1": {
761
+ post: {
762
+ responses: {},
763
+ requestBody: { content: { "application/json": { schema: b1 } } },
764
+ },
765
+ },
766
+ "/b2": {
767
+ post: {
768
+ responses: {},
769
+ requestBody: { content: { "application/json": { schema: b2 } } },
770
+ },
771
+ },
772
+ },
773
+ };
774
+
775
+ // Attach the same hint to both shapes via hoistSharedSchemas's own
776
+ // name-hint mechanism: we can't from here (WeakMap is module-private).
777
+ // So we instead rely on the deterministic hash-based fallback name,
778
+ // then assert both shapes are hoisted and receive distinct entries.
779
+ return hoistSharedSchemas(doc).then(() => {
780
+ const schemas = doc.components?.schemas ?? {};
781
+ const names = Object.keys(schemas);
782
+ // Both shapes qualify (appear twice each) → 2 hoisted entries.
783
+ expect(names.length).toBe(2);
784
+ // No two entries share the exact same name.
785
+ expect(new Set(names).size).toBe(names.length);
786
+ // The generated names must be stable strings (start with `Schema_`
787
+ // in the fallback path).
788
+ for (const n of names) {
789
+ expect(n).toMatch(/^Schema_[0-9a-f]{8}$/);
790
+ }
791
+ });
792
+ });
793
+
794
+ test("hoistSchemas: false produces the legacy inline-only document", async () => {
795
+ const { rootDir, manifest, cleanup } = await buildSharedSchemaFixture();
796
+ try {
797
+ const docOn = await generateOpenAPIDocument(manifest, rootDir, {
798
+ hoistSchemas: true,
799
+ });
800
+ const docOff = await generateOpenAPIDocument(manifest, rootDir, {
801
+ hoistSchemas: false,
802
+ });
803
+
804
+ // Off: no $refs anywhere, no components.schemas emitted.
805
+ const offJson = JSON.stringify(docOff);
806
+ expect(offJson.includes("$ref")).toBe(false);
807
+ expect(docOff.components?.schemas).toBeUndefined();
808
+
809
+ // On: at least one $ref appeared. Confirms the two paths diverge.
810
+ const onJson = JSON.stringify(docOn);
811
+ expect(onJson.includes("$ref")).toBe(true);
812
+ expect(docOn.components?.schemas).toBeDefined();
813
+ } finally {
814
+ await cleanup();
815
+ }
816
+ });
817
+
818
+ test("enum / primitive schemas are never hoisted", async () => {
819
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-enum-"));
820
+ try {
821
+ await fs.mkdir(path.join(rootDir, "contracts"), { recursive: true });
822
+ const contractBody = `import { z } from "zod";
823
+ export default {
824
+ name: NAME,
825
+ request: {
826
+ POST: { body: z.object({ role: z.enum(["a", "b", "c"]) }) },
827
+ },
828
+ response: { 200: z.object({ role: z.enum(["a", "b", "c"]) }) },
829
+ };
830
+ `;
831
+ await fs.writeFile(
832
+ path.join(rootDir, "contracts/one.contract.ts"),
833
+ contractBody.replace("NAME", '"one"'),
834
+ "utf-8"
835
+ );
836
+ await fs.writeFile(
837
+ path.join(rootDir, "contracts/two.contract.ts"),
838
+ contractBody.replace("NAME", '"two"'),
839
+ "utf-8"
840
+ );
841
+ const manifest: RoutesManifest = {
842
+ version: 1,
843
+ routes: [
844
+ {
845
+ id: "api/one",
846
+ pattern: "/api/one",
847
+ kind: "api",
848
+ module: "contracts/one.contract.ts",
849
+ contractModule: "contracts/one.contract.ts",
850
+ methods: ["POST"],
851
+ },
852
+ {
853
+ id: "api/two",
854
+ pattern: "/api/two",
855
+ kind: "api",
856
+ module: "contracts/two.contract.ts",
857
+ contractModule: "contracts/two.contract.ts",
858
+ methods: ["POST"],
859
+ },
860
+ ],
861
+ };
862
+
863
+ const doc = await generateOpenAPIDocument(manifest, rootDir);
864
+
865
+ // The outer {role: enum} object *is* hoistable and shared — that's fine.
866
+ // The inner enum itself must NOT be hoisted — scan every component
867
+ // entry and assert none of them is a bare enum-typed schema.
868
+ for (const entry of Object.values(doc.components?.schemas ?? {})) {
869
+ expect(entry.enum).toBeUndefined();
870
+ if (entry.type === "string" && !entry.properties) {
871
+ throw new Error(`Primitive string schema should not be hoisted: ${JSON.stringify(entry)}`);
872
+ }
873
+ }
874
+ } finally {
875
+ await fs.rm(rootDir, { recursive: true, force: true });
876
+ }
877
+ });
878
+
879
+ test("hoistThreshold: 3 → 2-use schemas stay inline; 3-use schemas get hoisted", () => {
880
+ // Doc with the same shape appearing exactly twice.
881
+ const shape = {
882
+ type: "object" as const,
883
+ properties: { x: { type: "string" as const } },
884
+ required: ["x"],
885
+ };
886
+ const buildDoc = (): OpenAPIDocument => ({
887
+ openapi: "3.0.3",
888
+ info: { title: "t", version: "1.0.0" },
889
+ paths: {
890
+ "/a": {
891
+ post: {
892
+ responses: {},
893
+ requestBody: { content: { "application/json": { schema: { ...shape } } } },
894
+ },
895
+ },
896
+ "/b": {
897
+ post: {
898
+ responses: {},
899
+ requestBody: { content: { "application/json": { schema: { ...shape } } } },
900
+ },
901
+ },
902
+ },
903
+ });
904
+
905
+ const doc2 = buildDoc();
906
+ return hoistSharedSchemas(doc2, { threshold: 3 }).then(() => {
907
+ // With threshold=3 and only 2 uses → nothing hoisted.
908
+ expect(doc2.components?.schemas).toBeUndefined();
909
+ expect(doc2.paths["/a"].post!.requestBody!.content["application/json"].schema.$ref).toBeUndefined();
910
+
911
+ // Re-run with threshold=2 on a fresh doc → the same shape is hoisted.
912
+ const doc1 = buildDoc();
913
+ return hoistSharedSchemas(doc1, { threshold: 2 }).then(() => {
914
+ expect(doc1.components?.schemas).toBeDefined();
915
+ expect(Object.keys(doc1.components!.schemas!).length).toBe(1);
916
+ });
917
+ });
918
+ });
919
+
920
+ test("threshold values < 2 clamp up to 2 (never hoist single-use schemas)", () => {
921
+ // Edge: a malicious / misconfigured `hoistThreshold: 1` would otherwise
922
+ // hoist every schema including one-offs, blowing up the spec.
923
+ const shape = {
924
+ type: "object" as const,
925
+ properties: { solo: { type: "string" as const } },
926
+ required: ["solo"],
927
+ };
928
+ const doc: OpenAPIDocument = {
929
+ openapi: "3.0.3",
930
+ info: { title: "t", version: "1.0.0" },
931
+ paths: {
932
+ "/only": {
933
+ post: {
934
+ responses: {},
935
+ requestBody: { content: { "application/json": { schema: { ...shape } } } },
936
+ },
937
+ },
938
+ },
939
+ };
940
+
941
+ return hoistSharedSchemas(doc, { threshold: 1 }).then(() => {
942
+ expect(doc.components?.schemas).toBeUndefined();
943
+ expect(doc.paths["/only"].post!.requestBody!.content["application/json"].schema.$ref).toBeUndefined();
944
+ });
945
+ });
946
+ });