@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 +1 -1
- package/src/openapi/generator.ts +360 -5
- package/src/openapi/openapi.test.ts +456 -2
package/package.json
CHANGED
package/src/openapi/generator.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|