Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@amritk/helpers

Package Overview
Dependencies
Maintainers
1
Versions
14
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@amritk/helpers - npm Package Compare versions

Comparing version
0.8.0
to
0.9.0
+0
-1
dist/build-dynamic-ref-map.d.ts

@@ -18,2 +18,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

export declare const buildDynamicRefMap: (rootSchema: JSONSchema) => Record<string, string>;
//# sourceMappingURL=build-dynamic-ref-map.d.ts.map

@@ -21,2 +21,1 @@ /**

export declare const deriveRootTypeName: (schema: unknown) => string;
//# sourceMappingURL=derive-root-type-name.d.ts.map

@@ -17,2 +17,1 @@ /**

export declare const escapeRegexPattern: (pattern: string) => string;
//# sourceMappingURL=escape-regex-pattern.d.ts.map

@@ -20,2 +20,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

export declare const extractDynamicAnchorDefs: (schema: JSONSchema) => string[];
//# sourceMappingURL=extract-dynamic-anchor-defs.d.ts.map

@@ -24,2 +24,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

export declare const extractRefs: (schema: JSONSchema) => Set<string>;
//# sourceMappingURL=extract-refs.d.ts.map

@@ -29,2 +29,1 @@ /** A generated file: its name (with extension) and TypeScript source. */

export declare const generateIndexBarrel: (files: IndexBarrelFile[], options?: GenerateIndexBarrelOptions) => string;
//# sourceMappingURL=generate-index-barrel.d.ts.map

@@ -18,2 +18,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

export declare const generateTypeDefinition: (schema: JSONSchema, typeName: string, options?: TypeOptions) => string;
//# sourceMappingURL=generate-type-definition.d.ts.map

@@ -5,2 +5,1 @@ /** Type guard to check if a value is a non-null, non-array object with a string $ref property */

} & Record<string, unknown>;
//# sourceMappingURL=has-ref.d.ts.map

@@ -15,2 +15,1 @@ /**

export declare const isObject: (value: unknown) => value is Record<string, unknown>;
//# sourceMappingURL=is-object.d.ts.map

@@ -44,2 +44,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

export declare const getMjstBrand: (schema: JSONSchema) => string | undefined;
//# sourceMappingURL=mjst-extension.d.ts.map

@@ -18,2 +18,1 @@ type PropertyDocumentation = {

export {};
//# sourceMappingURL=parse-documentation.d.ts.map

@@ -37,2 +37,1 @@ /**

export declare const refToFilename: (ref: string) => string;
//# sourceMappingURL=ref-to-filename.d.ts.map

@@ -22,2 +22,1 @@ /**

export declare const refToName: (ref: string, suffix?: string) => string;
//# sourceMappingURL=ref-to-name.d.ts.map

@@ -15,2 +15,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

export declare const resolveDynamicRefs: (schema: JSONSchema, dynamicRefMap: Record<string, string>) => JSONSchema;
//# sourceMappingURL=resolve-dynamic-refs.d.ts.map

@@ -27,2 +27,1 @@ /**

export declare const resolveRef: (ref: string, rootSchema: Record<string, unknown>) => Record<string, unknown> | undefined;
//# sourceMappingURL=resolve-ref.d.ts.map

@@ -25,2 +25,1 @@ /**

export declare const safeKey: (key: string) => string;
//# sourceMappingURL=safe-accessor.d.ts.map
+8
-1

@@ -87,2 +87,10 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

};
/**
* Draft-04 expressed a strict lower bound as a boolean `exclusiveMinimum: true`
* paired with `minimum`, where draft-06+ uses a standalone numeric keyword. True
* only for that legacy boolean form, so callers can tighten the `minimum` compare.
*/
export declare const hasStrictExclusiveMinimum: (schema: JSONSchema) => boolean;
/** Draft-04 boolean `exclusiveMaximum: true` paired with `maximum`. See {@link hasStrictExclusiveMinimum}. */
export declare const hasStrictExclusiveMaximum: (schema: JSONSchema) => boolean;
/** Type guard to check if schema has multipleOf */

@@ -121,2 +129,1 @@ export declare const hasMultipleOf: (schema: JSONSchema) => schema is SchemaObject & {

export { hasRef } from './has-ref.js';
//# sourceMappingURL=schema-guards.d.ts.map

@@ -96,2 +96,20 @@ /** Type guard to check if schema is not false */

};
/**
* Draft-04 expressed a strict lower bound as a boolean `exclusiveMinimum: true`
* paired with `minimum`, where draft-06+ uses a standalone numeric keyword. True
* only for that legacy boolean form, so callers can tighten the `minimum` compare.
*/
export const hasStrictExclusiveMinimum = (schema) => {
if (!isSchemaObject(schema))
return false;
const flag = schema.exclusiveMinimum;
return flag === true;
};
/** Draft-04 boolean `exclusiveMaximum: true` paired with `maximum`. See {@link hasStrictExclusiveMinimum}. */
export const hasStrictExclusiveMaximum = (schema) => {
if (!isSchemaObject(schema))
return false;
const flag = schema.exclusiveMaximum;
return flag === true;
};
/** Type guard to check if schema has multipleOf */

@@ -98,0 +116,0 @@ export const hasMultipleOf = (schema) => {

@@ -34,2 +34,1 @@ /**

export declare const upgradeDraft07Schema: (schema: Record<string, unknown>) => Record<string, unknown>;
//# sourceMappingURL=upgrade-draft07-schema.d.ts.map
/** Parses the items of an array with a parser function */
export declare const validateArray: (input: unknown, parser: (input: unknown) => unknown) => any[];
//# sourceMappingURL=validate-array.d.ts.map

@@ -6,2 +6,1 @@ /**

export declare const validateRecord: (input: unknown, parser: (input: unknown) => unknown) => Record<string, unknown>;
//# sourceMappingURL=validate-record.d.ts.map

@@ -54,2 +54,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

export declare const walkRefGraph: (rootSchema: JSONSchema, rootTypeName: string, options: WalkRefGraphOptions, visit: (node: RefNode) => void) => void;
//# sourceMappingURL=walk-ref-graph.d.ts.map
{
"name": "@amritk/helpers",
"version": "0.8.0",
"version": "0.9.0",
"description": "Shared utilities for the mjst code generation ecosystem.",

@@ -23,4 +23,3 @@ "type": "module",

"files": [
"dist",
"src"
"dist"
],

@@ -41,3 +40,2 @@ "publishConfig": {

"./*": {
"development": "./src/*.ts",
"import": "./dist/*.js",

@@ -44,0 +42,0 @@ "types": "./dist/*.d.ts",

{"version":3,"file":"build-dynamic-ref-map.d.ts","sourceRoot":"","sources":["../src/build-dynamic-ref-map.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAIjE;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,kBAAkB,eAAgB,UAAU,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAsBhF,CAAA"}
{"version":3,"file":"derive-root-type-name.d.ts","sourceRoot":"","sources":["../src/derive-root-type-name.ts"],"names":[],"mappings":"AAeA;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,kBAAkB,WAAY,OAAO,KAAG,MAOpD,CAAA"}
{"version":3,"file":"escape-regex-pattern.d.ts","sourceRoot":"","sources":["../src/escape-regex-pattern.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,kBAAkB,YAAa,MAAM,KAAG,MAIwB,CAAA"}
{"version":3,"file":"extract-dynamic-anchor-defs.d.ts","sourceRoot":"","sources":["../src/extract-dynamic-anchor-defs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAEjE;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,wBAAwB,WAAY,UAAU,KAAG,MAAM,EAanE,CAAA"}
{"version":3,"file":"extract-refs.d.ts","sourceRoot":"","sources":["../src/extract-refs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAsBjE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,WAAW,WAAY,UAAU,KAAG,GAAG,CAAC,MAAM,CA6B1D,CAAA"}
{"version":3,"file":"generate-index-barrel.d.ts","sourceRoot":"","sources":["../src/generate-index-barrel.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,iEAAiE;AACjE,MAAM,MAAM,0BAA0B,GAAG;IACvC;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAC7B,CAAA;AAOD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,UAAW,eAAe,EAAE,YAAW,0BAA0B,KAAQ,MA2BxG,CAAA"}
{"version":3,"file":"generate-type-definition.d.ts","sourceRoot":"","sources":["../src/generate-type-definition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAYjE,iDAAiD;AACjD,MAAM,MAAM,WAAW,GAAG;IACxB,kGAAkG;IAClG,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B,CAAA;AAmZD;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,WAAY,UAAU,YAAY,MAAM,YAAW,WAAW,KAAQ,MAsJxG,CAAA"}
{"version":3,"file":"has-ref.d.ts","sourceRoot":"","sources":["../src/has-ref.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,eAAO,MAAM,MAAM,UAAW,OAAO,KAAG,KAAK,IAAI;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAQzF,CAAA"}
{"version":3,"file":"is-object.d.ts","sourceRoot":"","sources":["../src/is-object.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,QAAQ,UAAW,OAAO,KAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CACV,CAAA"}
{"version":3,"file":"mjst-extension.d.ts","sourceRoot":"","sources":["../src/mjst-extension.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAIjE;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,WAAW,CAAA;AAE1C;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAwBD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,WAAY,UAAU,KAAG,MAAM,GAAG,SAG/D,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,gBAAgB,WAAY,UAAU,KAAG,MAAM,GAAG,SAG9D,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,WAAY,UAAU,KAAG,MAAM,GAAG,SAG1D,CAAA"}
{"version":3,"file":"parse-documentation.d.ts","sourceRoot":"","sources":["../src/parse-documentation.ts"],"names":[],"mappings":"AAAA,KAAK,qBAAqB,GAAG;IAC3B,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,OAAO,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAA;CAClD,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,0BACN,MAAM,cACjB,MAAM,uBACG,MAAM,KAC1B,mBAAmB,GAAG,IAuJxB,CAAA"}
{"version":3,"file":"ref-to-filename.d.ts","sourceRoot":"","sources":["../src/ref-to-filename.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,UAAW,MAAM,KAAG,MAM1B,CAAA;AA0DlB;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,aAAa,QAAS,MAAM,KAAG,MAiB3C,CAAA"}
{"version":3,"file":"ref-to-name.d.ts","sourceRoot":"","sources":["../src/ref-to-name.ts"],"names":[],"mappings":"AAqBA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,SAAS,QAAS,MAAM,sBAAgB,MAAmD,CAAA"}
{"version":3,"file":"resolve-dynamic-refs.d.ts","sourceRoot":"","sources":["../src/resolve-dynamic-refs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAEjE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,WAAY,UAAU,iBAAiB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAG,UAuC9F,CAAA"}
{"version":3,"file":"resolve-ref.d.ts","sourceRoot":"","sources":["../src/resolve-ref.ts"],"names":[],"mappings":"AA0BA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,UAAU,QAAS,MAAM,cAAc,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SA8BvG,CAAA"}
{"version":3,"file":"safe-accessor.d.ts","sourceRoot":"","sources":["../src/safe-accessor.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,YAAY,aAAc,MAAM,OAAO,MAAM,KAAG,MAW5D,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,OAAO,QAAS,MAAM,KAAG,MAKrC,CAAA"}
{"version":3,"file":"schema-guards.d.ts","sourceRoot":"","sources":["../src/schema-guards.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAEjE,KAAK,YAAY,GAAG,OAAO,CAAC,UAAU,EAAE,KAAK,GAAG,OAAO,CAAC,CAAA;AAExD,iDAAiD;AACjD,eAAO,MAAM,cAAc,WAAY,UAAU,KAAG,MAAM,IAAI,YAE7D,CAAA;AAED,wDAAwD;AACxD,eAAO,MAAM,OAAO,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAEnF,CAAA;AAED,wDAAwD;AACxD,eAAO,MAAM,cAAc,WAAY,UAAU,KAAG,MAAM,IAAI,UAAU,CAAC,MAExE,CAAA;AAED,mDAAmD;AACnD,eAAO,MAAM,aAAa,WAChB,UAAU,KACjB,MAAM,IAAI,YAAY,GAAG;IAAE,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;CAOnE,CAAA;AAED,6CAA6C;AAC7C,eAAO,MAAM,OAAO,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,IAAI,EAAE,SAAS,OAAO,EAAE,CAAA;CAE/F,CAAA;AAED,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,KAAK,EAAE,OAAO,CAAA;CAEtF,CAAA;AAED,gDAAgD;AAChD,eAAO,MAAM,UAAU,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAEzF,CAAA;AAED,+CAA+C;AAC/C,eAAO,MAAM,SAAS,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAEvF,CAAA;AAED,gDAAgD;AAChD,eAAO,MAAM,UAAU,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,OAAO,EAAE,OAAO,CAAA;CAE1F,CAAA;AAED,iDAAiD;AACjD,eAAO,MAAM,WAAW,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAA;CAEvG,CAAA;AAED,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,KAAK,EAAE,SAAS,UAAU,EAAE,CAAA;CAEpG,CAAA;AAED,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,KAAK,EAAE,SAAS,UAAU,EAAE,CAAA;CAEpG,CAAA;AAED,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,KAAK,EAAE,SAAS,UAAU,EAAE,CAAA;CAEpG,CAAA;AAED,iDAAiD;AACjD,eAAO,MAAM,WAAW,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;CAEtG,CAAA;AAED,0EAA0E;AAC1E,eAAO,MAAM,QAAQ,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,KAAK,EAAE,YAAY,CAAA;CAQ3F,CAAA;AAED,6DAA6D;AAC7D,eAAO,MAAM,uBAAuB,WAC1B,UAAU,KACjB,MAAM,IAAI,YAAY,GAAG;IAAE,oBAAoB,EAAE,UAAU,GAAG,OAAO,CAAA;CAEvE,CAAA;AAED,kDAAkD;AAClD,eAAO,MAAM,YAAY,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,SAAS,EAAE,MAAM,CAAA;CAE7F,CAAA;AAED,kDAAkD;AAClD,eAAO,MAAM,YAAY,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,SAAS,EAAE,MAAM,CAAA;CAE7F,CAAA;AAED,gDAAgD;AAChD,eAAO,MAAM,UAAU,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAEzF,CAAA;AAED,gDAAgD;AAChD,eAAO,MAAM,UAAU,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAEzF,CAAA;AAED,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,gBAAgB,EAAE,MAAM,CAAA;CAE3G,CAAA;AAED,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,gBAAgB,EAAE,MAAM,CAAA;CAE3G,CAAA;AAED,mDAAmD;AACnD,eAAO,MAAM,aAAa,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,UAAU,EAAE,MAAM,CAAA;CAE/F,CAAA;AAED,iDAAiD;AACjD,eAAO,MAAM,WAAW,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAA;CAE3F,CAAA;AAED,iDAAiD;AACjD,eAAO,MAAM,WAAW,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAA;CAE3F,CAAA;AAED,oDAAoD;AACpD,eAAO,MAAM,cAAc,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,WAAW,EAAE,OAAO,CAAA;CAElG,CAAA;AAED,sDAAsD;AACtD,eAAO,MAAM,gBAAgB,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,aAAa,EAAE,MAAM,CAAA;CAErG,CAAA;AAED,sDAAsD;AACtD,eAAO,MAAM,gBAAgB,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,aAAa,EAAE,MAAM,CAAA;CAErG,CAAA;AAED,qEAAqE;AACrE,eAAO,MAAM,oBAAoB,WACvB,UAAU,KACjB,MAAM,IAAI,YAAY,GAAG;IAAE,iBAAiB,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAA;CAOjF,CAAA;AAED,mEAAmE;AACnE,eAAO,MAAM,gBAAgB,WAAY,UAAU,KAAG,MAAM,IAAI,YAAY,GAAG;IAAE,aAAa,EAAE,UAAU,CAAA;CAEzG,CAAA;AAED,OAAO,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA"}
{"version":3,"file":"upgrade-draft07-schema.d.ts","sourceRoot":"","sources":["../src/upgrade-draft07-schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH;;GAEG;AACH,eAAO,MAAM,eAAe,WAAY,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,OACe,CAAA;AAqFjF;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,WAAY,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAiC5F,CAAA"}
{"version":3,"file":"validate-array.d.ts","sourceRoot":"","sources":["../src/validate-array.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAC1D,eAAO,MAAM,aAAa,UAAW,OAAO,UAAU,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,UAahF,CAAA"}
{"version":3,"file":"validate-record.d.ts","sourceRoot":"","sources":["../src/validate-record.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,eAAO,MAAM,cAAc,UAAW,OAAO,UAAU,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,4BAYjF,CAAA"}
{"version":3,"file":"walk-ref-graph.d.ts","sourceRoot":"","sources":["../src/walk-ref-graph.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAWjE;;;;;GAKG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB,2FAA2F;IAC3F,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;IACvB,2FAA2F;IAC3F,QAAQ,EAAE,MAAM,CAAA;IAChB,sGAAsG;IACtG,QAAQ,EAAE,MAAM,CAAA;IAChB,qFAAqF;IACrF,MAAM,EAAE,UAAU,CAAA;IAClB,+EAA+E;IAC/E,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACnC,oEAAoE;IACpE,MAAM,EAAE,OAAO,CAAA;CAChB,CAAA;AAED,iEAAiE;AACjE,MAAM,MAAM,mBAAmB,GAAG;IAChC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B,CAAA;AA2DD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,YAAY,eACX,UAAU,gBACR,MAAM,WACX,mBAAmB,SACrB,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,KAC7B,IAyDF,CAAA"}
import { describe, expect, it } from 'vitest'
import { buildDynamicRefMap } from './build-dynamic-ref-map'
describe('build-dynamic-ref-map', () => {
it('maps $dynamicAnchor values to $ref paths', () => {
const schema = {
type: 'object' as const,
$defs: {
schema: {
$dynamicAnchor: 'meta',
type: 'object',
},
},
}
const result = buildDynamicRefMap(schema)
expect(result).toEqual({ '#meta': '#/$defs/schema' })
})
it('returns empty map when no $dynamicAnchor exists', () => {
const schema = {
type: 'object' as const,
$defs: {
info: { type: 'object' },
},
}
const result = buildDynamicRefMap(schema)
expect(result).toEqual({})
})
it('returns empty map for non-object schema', () => {
const result = buildDynamicRefMap(true)
expect(result).toEqual({})
})
it('returns empty map when no $defs exist', () => {
const schema = { type: 'object' as const }
const result = buildDynamicRefMap(schema)
expect(result).toEqual({})
})
it('handles multiple $dynamicAnchor definitions', () => {
const schema = {
type: 'object' as const,
$defs: {
schema: {
$dynamicAnchor: 'meta',
type: 'object',
},
other: {
$dynamicAnchor: 'other-anchor',
type: 'string',
},
},
}
const result = buildDynamicRefMap(schema)
expect(result).toEqual({
'#meta': '#/$defs/schema',
'#other-anchor': '#/$defs/other',
})
})
})
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
import { isSchemaObject } from './schema-guards'
/**
* Builds a map of $dynamicRef anchor values to their corresponding $ref paths.
*
* JSON Schema 2020-12 uses $dynamicAnchor and $dynamicRef for late-binding references.
* In the OpenAPI spec, $dynamicAnchor: "meta" on the schema definition allows properties
* like media-type.schema to reference it via $dynamicRef: "#meta".
*
* This function scans all $defs for entries with $dynamicAnchor and builds a lookup
* so we can convert $dynamicRef values to concrete $ref paths.
*
* @example
* // Given a schema with $defs.schema having $dynamicAnchor: "meta"
* buildDynamicRefMap(rootSchema)
* // Returns: { "#meta": "#/$defs/schema" }
*/
export const buildDynamicRefMap = (rootSchema: JSONSchema): Record<string, string> => {
const map: Record<string, string> = {}
if (!isSchemaObject(rootSchema) || !('$defs' in rootSchema)) {
return map
}
const defs = rootSchema.$defs as Record<string, unknown>
for (const [key, value] of Object.entries(defs)) {
if (
typeof value === 'object' &&
value !== null &&
'$dynamicAnchor' in value &&
typeof (value as Record<string, unknown>)['$dynamicAnchor'] === 'string'
) {
const anchor = (value as Record<string, unknown>)['$dynamicAnchor'] as string
map[`#${anchor}`] = `#/$defs/${key}`
}
}
return map
}
import { describe, expect, it } from 'vitest'
import { deriveRootTypeName } from './derive-root-type-name'
describe('derive-root-type-name', () => {
it('derives a PascalCase name from a single-word title', () => {
expect(deriveRootTypeName({ title: 'Document' })).toBe('Document')
})
it('joins multi-word titles into PascalCase', () => {
expect(deriveRootTypeName({ title: 'OpenAPI Document' })).toBe('OpenAPIDocument')
})
it('preserves acronyms intact', () => {
expect(deriveRootTypeName({ title: 'JSON Schema' })).toBe('JSONSchema')
})
it('splits on non-alphanumeric separators', () => {
expect(deriveRootTypeName({ title: 'my-config_file.spec' })).toBe('MyConfigFileSpec')
})
it('drops leading digits so the name is a valid identifier', () => {
expect(deriveRootTypeName({ title: '3 amigos' })).toBe('Amigos')
})
it('falls back to Document when the title is missing', () => {
expect(deriveRootTypeName({ type: 'object' })).toBe('Document')
})
it('falls back to Document when the title is not a string', () => {
expect(deriveRootTypeName({ title: 42 })).toBe('Document')
})
it('falls back to Document for a title with no usable characters', () => {
expect(deriveRootTypeName({ title: ' --- ' })).toBe('Document')
})
it('falls back to Document for boolean schemas', () => {
expect(deriveRootTypeName(true)).toBe('Document')
expect(deriveRootTypeName(false)).toBe('Document')
})
})
import { isObject } from './is-object'
/**
* Converts an arbitrary title string into a PascalCase TypeScript identifier.
* Splits on any run of non-alphanumeric characters, capitalizes the first
* letter of each word while preserving the rest (so acronyms like "API" or
* "JSON" survive intact), and drops leading digits since an identifier may not
* start with a number.
*/
const titleToPascalCase = (title: string): string => {
const words = title.split(/[^a-zA-Z0-9]+/).filter(Boolean)
const pascal = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join('')
return pascal.replace(/^\d+/, '')
}
/**
* Derives the root type name for a generated schema from its `title` keyword.
*
* We name the root after the schema itself instead of a generic "Document" so
* the generated types and parsers read naturally (e.g. an OpenAPI schema yields
* `OpenApi` / `parseOpenApi`). When the schema has no usable `title`, we fall
* back to "Document" to keep output deterministic.
*
* @param schema - The root JSON Schema. A boolean schema or one without a
* string `title` falls back to the default.
* @returns A PascalCase type name derived from the title, or "Document".
*
* @example
* ```ts
* deriveRootTypeName({ title: 'OpenAPI Document' }) // 'OpenAPIDocument'
* deriveRootTypeName({ title: 'my-config' }) // 'MyConfig'
* deriveRootTypeName({ type: 'object' }) // 'Document'
* ```
*/
export const deriveRootTypeName = (schema: unknown): string => {
if (!isObject(schema) || typeof schema['title'] !== 'string') {
return 'Document'
}
const name = titleToPascalCase(schema['title'])
return name || 'Document'
}
import { describe, expect, it } from 'vitest'
import { escapeRegexPattern } from './escape-regex-pattern'
describe('escape-regex-pattern', () => {
it('escapes bare forward slashes so the regex literal does not close early', () => {
expect(escapeRegexPattern('a/b')).toBe('a\\/b')
// Input \d{4}/\d{2}/\d{2} → \d{4}\/\d{2}\/\d{2}
expect(escapeRegexPattern('\\d{4}/\\d{2}/\\d{2}')).toBe('\\d{4}\\/\\d{2}\\/\\d{2}')
})
it('leaves backslash escape sequences exactly as-is (does not double them)', () => {
// \d must stay \d — doubling it would match a literal backslash, not a digit.
expect(escapeRegexPattern('\\d+')).toBe('\\d+')
expect(escapeRegexPattern('\\w\\s')).toBe('\\w\\s')
})
it('does not double-escape an already-escaped slash', () => {
// \/ (escaped slash) stays \/, not \\\/.
expect(escapeRegexPattern('\\/')).toBe('\\/')
})
it('leaves regex metacharacters that do not affect the literal untouched', () => {
expect(escapeRegexPattern('^[a-z]+$')).toBe('^[a-z]+$')
})
it('round-trips: the escaped body parses to a regex equivalent to the source pattern', () => {
for (const pattern of ['\\d{4}/\\d{2}', 'a/b/c', '^https?:\\/\\/', '\\w+@\\w+']) {
// Build the literal the generator emits and read its source back out.
const re = new RegExp(escapeRegexPattern(pattern))
// The RegExp's own source, with \/ normalized back to /, equals the input.
expect(re.source.replace(/\\\//g, '/')).toBe(pattern.replace(/\\\//g, '/'))
}
})
})
/**
* Escapes a JSON Schema `pattern` so it can be embedded between the slashes of
* a generated regex literal (`/…/`).
*
* A `pattern` is an ECMA-262 regex *body*, and the generated text goes into a
* regex literal — not a string literal — so backslashes are regex syntax and
* must be left exactly as-is (doubling `\d` to `\\d` would change it from "a
* digit" to "a literal backslash followed by d"). The only character that would
* corrupt the surrounding literal is an *unescaped* `/`, which would close it
* early. So we escape bare slashes to `\/` while leaving every existing escape
* sequence (including an already-escaped `\/`) untouched.
*
* @example
* escapeRegexPattern('\\d{4}/\\d{2}') // → '\\d{4}\\/\\d{2}' (i.e. \d{4}\/\d{2})
*/
export const escapeRegexPattern = (pattern: string): string =>
// Match either an escape sequence (`\` + any char, kept verbatim) or a bare
// `/` (escaped). Consuming escape pairs first means the slash in `\/` is never
// seen as bare, so it is not double-escaped.
pattern.replace(/\\[\s\S]|\//g, (match) => (match === '/' ? '\\/' : match))
import { describe, expect, it } from 'vitest'
import { extractDynamicAnchorDefs } from './extract-dynamic-anchor-defs'
describe('extract-dynamic-anchor-defs', () => {
it('collects refs for every $defs entry with a $dynamicAnchor', () => {
const schema = {
$defs: {
schema: { $dynamicAnchor: 'meta', type: 'object' },
contact: { type: 'object' },
node: { $dynamicAnchor: 'node', type: 'string' },
},
}
expect(extractDynamicAnchorDefs(schema)).toEqual(['#/$defs/schema', '#/$defs/node'])
})
it('returns an empty array when no $defs are present', () => {
expect(extractDynamicAnchorDefs({ type: 'object' })).toEqual([])
})
it('returns an empty array when no definition has a $dynamicAnchor', () => {
expect(extractDynamicAnchorDefs({ $defs: { a: { type: 'string' } } })).toEqual([])
})
it('ignores a non-object schema', () => {
expect(extractDynamicAnchorDefs(true)).toEqual([])
})
})
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
/**
* Collects `#/$defs/<key>` refs for every root definition that carries a
* `$dynamicAnchor`.
*
* These definitions are reachable only through `$dynamicRef`, which the ref
* walker does not follow directly (a `$dynamicRef` is rewritten to a concrete
* `$ref` by `resolveDynamicRefs` *after* the schema is extracted, so plain
* `extractRefs` never sees it). Seeding them explicitly guarantees a file is
* generated for each dynamic-anchor target — without this, a generator would
* emit code that imports a type whose file was never produced.
*
* @example
* ```ts
* // $defs.schema has $dynamicAnchor: "meta"
* extractDynamicAnchorDefs(rootSchema) // ['#/$defs/schema']
* ```
*/
export const extractDynamicAnchorDefs = (schema: JSONSchema): string[] => {
const refs: string[] = []
if (typeof schema !== 'object' || schema === null) return refs
if (!('$defs' in schema) || typeof schema['$defs'] !== 'object' || schema['$defs'] === null) return refs
for (const [key, value] of Object.entries(schema['$defs'])) {
if (typeof value === 'object' && value !== null && '$dynamicAnchor' in value) {
refs.push(`#/$defs/${key}`)
}
}
return refs
}
import { describe, expect, it } from 'vitest'
import { extractRefs } from './extract-refs'
describe('extract-refs', () => {
it('extracts refs from a simple schema', () => {
const schema = {
type: 'object',
properties: {
contact: { $ref: '#/$defs/contact' },
server: { $ref: '#/$defs/server' },
},
}
const refs = extractRefs(schema)
expect(refs).toEqual(new Set(['#/$defs/contact', '#/$defs/server']))
})
it('extracts refs from nested objects', () => {
const schema = {
type: 'object',
properties: {
info: {
type: 'object',
properties: {
contact: { $ref: '#/$defs/contact' },
},
},
},
}
const refs = extractRefs(schema)
expect(refs).toEqual(new Set(['#/$defs/contact']))
})
it('extracts refs from arrays', () => {
const schema = {
type: 'object',
properties: {
servers: {
type: 'array',
items: { $ref: '#/$defs/server' },
},
},
}
const refs = extractRefs(schema)
expect(refs).toEqual(new Set(['#/$defs/server']))
})
it('extracts refs from allOf, anyOf, oneOf', () => {
const schema = {
allOf: [{ $ref: '#/$defs/base' }],
anyOf: [{ $ref: '#/$defs/option1' }, { $ref: '#/$defs/option2' }],
oneOf: [{ $ref: '#/$defs/choice1' }],
}
const refs = extractRefs(schema)
expect(refs).toEqual(new Set(['#/$defs/base', '#/$defs/option1', '#/$defs/option2', '#/$defs/choice1']))
})
it('extracts refs from additionalProperties', () => {
const schema = {
type: 'object',
additionalProperties: { $ref: '#/$defs/value' },
}
const refs = extractRefs(schema)
expect(refs).toEqual(new Set(['#/$defs/value']))
})
it('includes URI refs (http/https)', () => {
const schema = {
type: 'object',
properties: {
internal: { $ref: '#/$defs/internal' },
external: { $ref: 'https://example.com/schema.json' },
},
}
const refs = extractRefs(schema)
expect(refs).toEqual(new Set(['#/$defs/internal', 'https://example.com/schema.json']))
})
it('includes URI refs with fragments', () => {
const schema = {
type: 'object',
properties: {
queue: { $ref: 'http://example.com/channel.json#/definitions/queue' },
},
}
const refs = extractRefs(schema)
expect(refs).toEqual(new Set(['http://example.com/channel.json#/definitions/queue']))
})
it('ignores bare # self-references', () => {
const schema = {
type: 'object',
properties: {
self: { $ref: '#' },
contact: { $ref: '#/$defs/contact' },
},
}
const refs = extractRefs(schema)
expect(refs).toEqual(new Set(['#/$defs/contact']))
})
it('ignores relative path refs', () => {
const schema = {
type: 'object',
properties: {
msg: { $ref: '/components/messages/userSignedUp' },
contact: { $ref: '#/$defs/contact' },
},
}
const refs = extractRefs(schema)
expect(refs).toEqual(new Set(['#/$defs/contact']))
})
it('returns empty set when no refs exist', () => {
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
}
const refs = extractRefs(schema)
expect(refs).toEqual(new Set())
})
it('handles deeply nested refs', () => {
const schema = {
type: 'object',
properties: {
level1: {
type: 'object',
properties: {
level2: {
type: 'object',
properties: {
level3: { $ref: '#/$defs/deep' },
},
},
},
},
},
}
const refs = extractRefs(schema)
expect(refs).toEqual(new Set(['#/$defs/deep']))
})
it('deduplicates refs', () => {
const schema = {
type: 'object',
properties: {
contact1: { $ref: '#/$defs/contact' },
contact2: { $ref: '#/$defs/contact' },
contact3: { $ref: '#/$defs/contact' },
},
}
const refs = extractRefs(schema)
expect(refs).toEqual(new Set(['#/$defs/contact']))
})
})
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
/**
* Returns true if a $ref value should be queued for processing.
*
* Accepted forms:
* - Internal: `#/$defs/foo` or `#/definitions/foo`
* - URI key: `http://example.com/foo.json` (no fragment, or empty fragment)
* - URI with fragment: `http://example.com/foo.json#/definitions/bar`
*
* Excluded:
* - `#` alone (self-reference, not a standalone definition)
* - Relative path refs (e.g. `/components/messages/foo`) — these point into
* example data in the schema document, not into type definitions
*/
const isResolvableRef = (ref: string): boolean => {
if (ref === '#') return false
if (ref.startsWith('#')) return true
if (ref.startsWith('http://') || ref.startsWith('https://')) return true
return false
}
/**
* Extracts all $ref values from a JSON Schema recursively.
* Returns both internal (`#`-prefixed) and URI refs so the build pipeline
* can resolve and generate files for all referenced definitions.
*
* @param schema - The JSON Schema to extract refs from
* @returns A Set of unique ref strings found in the schema
*
* @example
* ```ts
* const schema = {
* type: 'object',
* properties: {
* contact: { $ref: '#/$defs/contact' },
* channel: { $ref: 'http://example.com/channel.json' },
* }
* }
* const refs = extractRefs(schema)
* // refs = Set(['#/$defs/contact', 'http://example.com/channel.json'])
* ```
*/
export const extractRefs = (schema: JSONSchema): Set<string> => {
const refs = new Set<string>()
const traverse = (obj: unknown): void => {
if (typeof obj !== 'object' || obj === null) {
return
}
if (Array.isArray(obj)) {
for (const item of obj) {
traverse(item)
}
return
}
const record = obj as Record<string, unknown>
if ('$ref' in record && typeof record['$ref'] === 'string' && isResolvableRef(record['$ref'] as string)) {
refs.add(record['$ref'] as string)
}
// Recursively traverse all properties using for...in to avoid intermediate array allocation
for (const key in record) {
traverse(record[key])
}
}
traverse(schema)
return refs
}
import { describe, expect, it } from 'vitest'
import { generateIndexBarrel } from './generate-index-barrel'
describe('generate-index-barrel', () => {
it('re-exports types and consts, sorted by filename', () => {
const files = [
{ filename: 'contact.ts', content: 'export type Contact = {}\nexport const parseContact = () => {}\n' },
{ filename: 'address.ts', content: 'export type Address = {}\nexport const parseAddress = () => {}\n' },
]
expect(generateIndexBarrel(files)).toBe(
"export { type Address, parseAddress } from './address';\n" +
"export { type Contact, parseContact } from './contact';\n",
)
})
it('emits type-only re-exports when typesOnly is set', () => {
const files = [{ filename: 'contact.ts', content: 'export type Contact = {}\n' }]
expect(generateIndexBarrel(files, { typesOnly: true })).toBe("export type { Contact } from './contact';\n")
})
it('never re-exports internal _helpers modules', () => {
const files = [
{ filename: 'document.ts', content: 'export type Document = {}\n' },
{ filename: '_helpers/is-object.ts', content: 'export const isObject = () => {}\n' },
]
expect(generateIndexBarrel(files)).toBe("export { type Document } from './document';\n")
})
it('skips files that export nothing', () => {
const files = [
{ filename: 'document.ts', content: 'export type Document = {}\n' },
{ filename: 'empty.ts', content: '// nothing here\n' },
]
expect(generateIndexBarrel(files)).toBe("export { type Document } from './document';\n")
})
})
/** A generated file: its name (with extension) and TypeScript source. */
export type IndexBarrelFile = {
filename: string
content: string
}
/** Options controlling how the barrel re-exports each module. */
export type GenerateIndexBarrelOptions = {
/**
* When true, every re-export is type-only (`export type { ... }`). Used by the
* types-only parser output, where no runtime values exist to re-export.
* Defaults to `false`.
*/
readonly typesOnly?: boolean
}
// Generated files declare their public surface with these two forms, so we can
// recover the export names from the source text without parsing it.
const TYPE_EXPORT_RE = /^export type (\w+)/gm
const CONST_EXPORT_RE = /^export const (\w+)/gm
/**
* Builds the `index.ts` barrel that re-exports every generated module. This is
* the shared version of the near-identical barrel each generator used to build
* inline: it scans each file's source for `export type` / `export const`
* declarations and emits one re-export line per module, sorted by filename.
*
* Files under `_helpers/` are internal runtime helpers (embedded-mode output)
* and are never re-exported. Modules that expose nothing are skipped.
*
* @param files - The generated files to barrel (the `index.ts` itself excluded).
* @param options - See {@link GenerateIndexBarrelOptions}.
* @returns The `index.ts` file content.
*/
export const generateIndexBarrel = (files: IndexBarrelFile[], options: GenerateIndexBarrelOptions = {}): string => {
const typesOnly = options.typesOnly ?? false
const sortedFiles = files
.filter((file) => !file.filename.startsWith('_helpers/'))
.sort((a, b) => a.filename.localeCompare(b.filename))
let indexContent = ''
for (const file of sortedFiles) {
const moduleName = file.filename.replace(/\.ts$/, '')
const typeNames: string[] = []
const constNames: string[] = []
for (const match of file.content.matchAll(TYPE_EXPORT_RE)) typeNames.push(match[1] as string)
for (const match of file.content.matchAll(CONST_EXPORT_RE)) constNames.push(match[1] as string)
if (typeNames.length === 0 && constNames.length === 0) continue
if (typesOnly) {
indexContent += `export type { ${typeNames.join(', ')} } from './${moduleName}';\n`
} else {
const typeExports = typeNames.map((name) => `type ${name}`)
indexContent += `export { ${[...typeExports, ...constNames].join(', ')} } from './${moduleName}';\n`
}
}
return indexContent
}
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
import { describe, expect, it } from 'vitest'
import { generateTypeDefinition } from './generate-type-definition'
describe('generateTypeDefinition', () => {
it('generates type for deeply nested objects', () => {
const schema = {
type: 'object',
properties: {
user: {
type: 'object',
properties: {
profile: {
type: 'object',
properties: {
address: {
type: 'object',
properties: {
street: { type: 'string' },
city: { type: 'string' },
zipCode: { type: 'string' },
},
required: ['street', 'city'],
},
},
},
},
},
},
}
const result = generateTypeDefinition(schema, 'DeeplyNested')
expect(result).toStrictEqual(
'export type DeeplyNested = {\n' +
' user?: { profile?: { address?: { street: string; city: string; zipCode?: string } } };\n' +
'};',
)
})
it('generates type for array of nested objects', () => {
const schema = {
type: 'object',
properties: {
users: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
email: { type: 'string' },
},
required: ['id', 'name'],
},
},
},
required: ['users'],
}
const result = generateTypeDefinition(schema, 'UserList')
expect(result).toStrictEqual(
'export type UserList = {\n' + ' users: { id: number; name: string; email?: string }[];\n' + '};',
)
})
it('generates type for nested arrays', () => {
const schema = {
type: 'object',
properties: {
matrix: {
type: 'array',
items: {
type: 'array',
items: {
type: 'number',
},
},
},
},
}
const result = generateTypeDefinition(schema, 'Matrix')
expect(result).toStrictEqual('export type Matrix = {\n' + ' matrix?: number[][];\n' + '};')
})
it('generates type for mixed required and optional fields', () => {
const schema = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' },
age: { type: 'number' },
active: { type: 'boolean' },
tags: { type: 'array', items: { type: 'string' } },
},
required: ['id', 'name', 'email'],
}
const result = generateTypeDefinition(schema, 'MixedFields')
expect(result).toStrictEqual(
'export type MixedFields = {\n' +
' id: string;\n' +
' name: string;\n' +
' email: string;\n' +
' age?: number;\n' +
' active?: boolean;\n' +
' tags?: string[];\n' +
'};',
)
})
it('generates type for object with all primitive types', () => {
const schema = {
type: 'object',
properties: {
stringField: { type: 'string' },
numberField: { type: 'number' },
integerField: { type: 'integer' },
booleanField: { type: 'boolean' },
},
required: ['stringField', 'numberField', 'integerField', 'booleanField'],
}
const result = generateTypeDefinition(schema, 'AllPrimitives')
expect(result).toStrictEqual(
'export type AllPrimitives = {\n' +
' stringField: string;\n' +
' numberField: number;\n' +
' integerField: number;\n' +
' booleanField: boolean;\n' +
'};',
)
})
it('generates type for array without items definition', () => {
const schema = {
type: 'object',
properties: {
data: {
type: 'array',
},
},
}
const result = generateTypeDefinition(schema, 'UnknownArray')
expect(result).toStrictEqual('export type UnknownArray = {\n' + ' data?: unknown[];\n' + '};')
})
it('generates type for object without properties', () => {
const schema = {
type: 'object',
properties: {
metadata: {
type: 'object',
},
},
}
const result = generateTypeDefinition(schema, 'Generic')
expect(result).toStrictEqual('export type Generic = {\n' + ' metadata?: object;\n' + '};')
})
it('generates type for complex nested structure with arrays and objects', () => {
const schema = {
type: 'object',
properties: {
company: {
type: 'object',
properties: {
name: { type: 'string' },
departments: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
employees: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
},
required: ['id'],
},
},
},
required: ['name'],
},
},
},
required: ['name'],
},
},
required: ['company'],
}
const result = generateTypeDefinition(schema, 'Company')
expect(result).toStrictEqual(
'export type Company = {\n' +
' company: { name: string; departments?: { name: string; employees?: { id: number; name?: string }[] }[] };\n' +
'};',
)
})
it('generates type for empty object schema', () => {
const schema = {
type: 'object',
properties: {},
}
const result = generateTypeDefinition(schema, 'Empty')
expect(result).toStrictEqual('export type Empty = {\n' + '\n' + '};')
})
it('generates type for object with no type specified', () => {
const schema = {
properties: {
field: { type: 'string' },
},
}
const result = generateTypeDefinition(schema, 'NoType')
expect(result).toStrictEqual('export type NoType = {\n' + ' field?: string;\n' + '};')
})
it('generates type for boolean schema true (any value valid)', () => {
const schema = true
const result = generateTypeDefinition(schema, 'BooleanSchema')
expect(result).toStrictEqual('export type BooleanSchema = unknown;')
})
it('generates type for boolean schema false (no value valid)', () => {
const schema = false
const result = generateTypeDefinition(schema, 'NeverSchema')
expect(result).toStrictEqual('export type NeverSchema = never;')
})
it('generates type for array of arrays of objects', () => {
const schema = {
type: 'object',
properties: {
grid: {
type: 'array',
items: {
type: 'array',
items: {
type: 'object',
properties: {
x: { type: 'number' },
y: { type: 'number' },
value: { type: 'string' },
},
required: ['x', 'y'],
},
},
},
},
}
const result = generateTypeDefinition(schema, 'Grid')
expect(result).toStrictEqual(
'export type Grid = {\n' + ' grid?: { x: number; y: number; value?: string }[][];\n' + '};',
)
})
it('generates type for object with all fields required', () => {
const schema = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' },
age: { type: 'number' },
},
required: ['id', 'name', 'email', 'age'],
}
const result = generateTypeDefinition(schema, 'AllRequired')
expect(result).toStrictEqual(
'export type AllRequired = {\n' +
' id: string;\n' +
' name: string;\n' +
' email: string;\n' +
' age: number;\n' +
'};',
)
})
it('generates type for object with no fields required', () => {
const schema = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' },
},
}
const result = generateTypeDefinition(schema, 'AllOptional')
expect(result).toStrictEqual(
'export type AllOptional = {\n' + ' id?: string;\n' + ' name?: string;\n' + ' email?: string;\n' + '};',
)
})
it('generates type for complex API response structure', () => {
const schema = {
type: 'object',
properties: {
status: { type: 'string' },
data: {
type: 'object',
properties: {
users: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number' },
username: { type: 'string' },
profile: {
type: 'object',
properties: {
avatar: { type: 'string' },
bio: { type: 'string' },
},
},
},
required: ['id', 'username'],
},
},
pagination: {
type: 'object',
properties: {
page: { type: 'number' },
perPage: { type: 'number' },
total: { type: 'number' },
},
required: ['page', 'perPage', 'total'],
},
},
required: ['users'],
},
error: {
type: 'object',
properties: {
code: { type: 'string' },
message: { type: 'string' },
},
},
},
required: ['status'],
}
const result = generateTypeDefinition(schema, 'APIResponse')
expect(result).toStrictEqual(
'export type APIResponse = {\n' +
' status: string;\n' +
' data?: { users: { id: number; username: string; profile?: { avatar?: string; bio?: string } }[]; pagination?: { page: number; perPage: number; total: number } };\n' +
' error?: { code?: string; message?: string };\n' +
'};',
)
})
it('generates type for array of different primitive types', () => {
const schema = {
type: 'object',
properties: {
strings: {
type: 'array',
items: { type: 'string' },
},
numbers: {
type: 'array',
items: { type: 'number' },
},
booleans: {
type: 'array',
items: { type: 'boolean' },
},
},
}
const result = generateTypeDefinition(schema, 'ArrayTypes')
expect(result).toStrictEqual(
'export type ArrayTypes = {\n' +
' strings?: string[];\n' +
' numbers?: number[];\n' +
' booleans?: boolean[];\n' +
'};',
)
})
it('generates type for recursive-like structure', () => {
const schema = {
type: 'object',
properties: {
id: { type: 'string' },
children: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
children: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
},
required: ['id'],
},
},
},
required: ['id'],
},
},
},
required: ['id'],
}
const result = generateTypeDefinition(schema, 'TreeNode')
expect(result).toStrictEqual(
'export type TreeNode = {\n' +
' id: string;\n' +
' children?: { id: string; children?: { id: string }[] }[];\n' +
'};',
)
})
it('generates type for schema with mixed nested structures', () => {
const schema = {
type: 'object',
properties: {
config: {
type: 'object',
properties: {
settings: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
options: {
type: 'array',
items: { type: 'string' },
},
},
required: ['enabled'],
},
metadata: {
type: 'array',
items: {
type: 'object',
properties: {
key: { type: 'string' },
value: { type: 'string' },
},
required: ['key', 'value'],
},
},
},
},
},
}
const result = generateTypeDefinition(schema, 'Configuration')
expect(result).toStrictEqual(
'export type Configuration = {\n' +
' config?: { settings?: { enabled: boolean; options?: string[] }; metadata?: { key: string; value: string }[] };\n' +
'};',
)
})
it('generates type for schema with property without type', () => {
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
unknownField: {},
age: { type: 'number' },
},
required: ['name'],
}
const result = generateTypeDefinition(schema, 'UnknownField')
expect(result).toStrictEqual(
'export type UnknownField = {\n' +
' name: string;\n' +
' unknownField?: unknown;\n' +
' age?: number;\n' +
'};',
)
})
it('generates type for deeply nested array structures with mixed types', () => {
const schema: JSONSchema.Object = {
type: 'object',
properties: {
data: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
tags: {
type: 'array',
items: { type: 'string' },
},
metadata: {
type: 'array',
items: {
type: 'object',
properties: {
key: { type: 'string' },
values: {
type: 'array',
items: { type: 'number' },
},
},
required: ['key'],
},
},
},
required: ['id'],
},
},
},
required: ['data'],
}
const result = generateTypeDefinition(schema, 'ComplexNestedArrays')
expect(result).toStrictEqual(
'export type ComplexNestedArrays = {\n' +
' data: { id: string; tags?: string[]; metadata?: { key: string; values?: number[] }[] }[];\n' +
'};',
)
})
it('generates type for info-like object schema with URL $comment as JSDoc description', () => {
const info: JSONSchema.Object = {
$comment: 'https://spec.openapis.org/oas/v3.1#info-object',
type: 'object',
properties: {
title: { type: 'string' },
summary: { type: 'string' },
contact: { $ref: '#/$defs/contact' },
version: { type: 'string' },
},
required: ['title', 'version'],
}
const result = generateTypeDefinition(info, 'Info')
expect(result).toStrictEqual(
'/**\n' +
'* Info\n' +
'*\n' +
'* https://spec.openapis.org/oas/v3.1#info-object\n' +
'*/\n' +
'export type Info = {\n' +
' title: string;\n' +
' summary?: string;\n' +
' contact?: Contact;\n' +
' version: string;\n' +
'};',
)
})
it('generates type for object with additionalProperties refs as Record type', () => {
const components: JSONSchema.Object = {
type: 'object',
properties: {
responses: {
type: 'object',
additionalProperties: { $ref: '#/$defs/response' },
},
parameters: {
type: 'object',
additionalProperties: { $ref: '#/$defs/parameter' },
},
pathItems: {
type: 'object',
additionalProperties: { $ref: '#/$defs/path-item' },
},
},
}
const result = generateTypeDefinition(components, 'Components')
expect(result).toStrictEqual(
'export type Components = {\n' +
' responses?: Record<string, Response>;\n' +
' parameters?: Record<string, Parameter>;\n' +
' pathItems?: Record<string, PathItem>;\n' +
'};',
)
})
it('generates type for object with paths property as Record<string, PathItem>', () => {
const document: JSONSchema.Object = {
$comment: 'https://spec.openapis.org/oas/v3.1#openapi-object',
type: 'object',
properties: {
openapi: { type: 'string' },
info: { $ref: '#/$defs/info' },
paths: {
type: 'object',
additionalProperties: { $ref: '#/$defs/path-item' },
},
},
required: ['openapi', 'info'],
}
const result = generateTypeDefinition(document, 'Document')
expect(result).toStrictEqual(
'/**\n' +
'* Document\n' +
'*\n' +
'* https://spec.openapis.org/oas/v3.1#openapi-object\n' +
'*/\n' +
'export type Document = {\n' +
' openapi: string;\n' +
' info: Info;\n' +
' paths?: Record<string, PathItem>;\n' +
'};',
)
})
it('generates type for Document with paths and webhooks as Record types', () => {
const document: JSONSchema.Object = {
$comment: 'https://spec.openapis.org/oas/v3.1#openapi-object',
type: 'object',
properties: {
openapi: {
type: 'string',
},
info: {
$ref: '#/$defs/info',
},
jsonSchemaDialect: {
type: 'string',
},
servers: {
type: 'array',
items: {
$ref: '#/$defs/server',
},
},
paths: {
type: 'object',
additionalProperties: {
$ref: '#/$defs/path-item',
},
},
webhooks: {
type: 'object',
additionalProperties: {
$ref: '#/$defs/path-item',
},
},
components: {
$ref: '#/$defs/components',
},
},
required: ['openapi', 'info'],
}
const result = generateTypeDefinition(document, 'Document')
expect(result).toStrictEqual(
'/**\n' +
'* Document\n' +
'*\n' +
'* https://spec.openapis.org/oas/v3.1#openapi-object\n' +
'*/\n' +
'export type Document = {\n' +
' openapi: string;\n' +
' info: Info;\n' +
' jsonSchemaDialect?: string;\n' +
' servers?: Server[];\n' +
' paths?: Record<string, PathItem>;\n' +
' webhooks?: Record<string, PathItem>;\n' +
' components?: Components;\n' +
'};',
)
})
it('generates type for object with patternProperties as Record type', () => {
const paths: JSONSchema.Object = {
$comment: 'https://spec.openapis.org/oas/v3.1#paths-object',
type: 'object',
patternProperties: {
'^/': {
$ref: '#/$defs/path-item',
},
},
}
const result = generateTypeDefinition(paths, 'Paths')
expect(result).toStrictEqual(
'/**\n' +
'* Paths\n' +
'*\n' +
'* https://spec.openapis.org/oas/v3.1#paths-object\n' +
'*/\n' +
'export type Paths = Record<string, PathItem>;',
)
})
it('quotes hyphenated property names in type definitions', () => {
const schema = {
type: 'object' as const,
properties: {
'x-linkedin': { type: 'string' as const },
name: { type: 'string' as const },
},
}
const result = generateTypeDefinition(schema, 'InfoExtensions')
expect(result).toContain("'x-linkedin'?: string;")
expect(result).toContain('name?: string;')
})
it('generates type from conditional if/then object fragments', () => {
const schema: JSONSchema = {
if: {
properties: {
type: {
const: 'http',
},
},
},
then: {
properties: {
scheme: {
type: 'string',
},
},
required: ['scheme'],
},
}
const result = generateTypeDefinition(schema, 'TypeHttp')
expect(result).toStrictEqual('export type TypeHttp = {\n' + ' type: "http";\n' + ' scheme: string;\n' + '};')
})
it('generates required property from then properties without explicit required', () => {
const schema: JSONSchema = {
if: {
properties: {
type: {
const: 'http',
},
},
},
then: {
properties: {
bearerFormat: {
type: 'string',
},
},
},
}
const result = generateTypeDefinition(schema, 'TypeHttp')
expect(result).toStrictEqual(
'export type TypeHttp = {\n' + ' type: "http";\n' + ' bearerFormat: string;\n' + '};',
)
})
it('generates required discriminator for conditional type-http schema with $comment JSDoc', () => {
const schema: JSONSchema = {
$comment: 'https://spec.openapis.org/oas/v3.1#security-scheme-object',
if: {
properties: {
type: {
const: 'http',
},
scheme: {
type: 'string',
pattern: '^[Bb][Ee][Aa][Rr][Ee][Rr]$',
},
},
required: ['type', 'scheme'],
},
then: {
properties: {
bearerFormat: {
type: 'string',
},
},
},
}
const result = generateTypeDefinition(schema, 'TypeHttp')
expect(result).toContain('* TypeHttp')
expect(result).toContain('* https://spec.openapis.org/oas/v3.1#security-scheme-object')
expect(result).toContain('type: "http";')
expect(result).toContain('scheme: string;')
expect(result).toContain('bearerFormat: string;')
})
it('generates intersection type for schema with allOf $ref entries', () => {
const securityScheme: JSONSchema.Object = {
type: 'object',
properties: {
type: {
enum: ['apiKey', 'http', 'oauth2'],
},
description: {
type: 'string',
},
},
required: ['type'],
allOf: [{ $ref: '#/$defs/type-apikey' }, { $ref: '#/$defs/type-http' }, { $ref: '#/$defs/type-oauth2' }],
}
const result = generateTypeDefinition(securityScheme, 'SecurityScheme')
expect(result).toStrictEqual(
'export type SecurityScheme = {\n' +
' type: "apiKey" | "http" | "oauth2";\n' +
' description?: string;\n' +
'} & TypeApikey & TypeHttp & TypeOauth2;',
)
})
it('generates JSDoc from $comment URL for schema with allOf intersections', () => {
const securityScheme: JSONSchema.Object = {
$comment: 'https://spec.openapis.org/oas/v3.1#security-scheme-object',
type: 'object',
properties: {
type: {
enum: ['apiKey', 'http', 'oauth2'],
},
},
required: ['type'],
allOf: [{ $ref: '#/$defs/type-apikey' }, { $ref: '#/$defs/type-http' }],
}
const result = generateTypeDefinition(securityScheme, 'SecurityScheme')
expect(result).toContain('* SecurityScheme')
expect(result).toContain('* https://spec.openapis.org/oas/v3.1#security-scheme-object')
expect(result).toContain('type: "apiKey" | "http" | "oauth2";')
expect(result).toContain('} & TypeApikey & TypeHttp;')
})
it('preserves property descriptions as JSDoc comments when allOf contains an inline object schema', () => {
const schema: JSONSchema = {
allOf: [
{ $ref: '#/$defs/baseTargetConfig' },
{
type: 'object',
additionalProperties: false,
properties: {
packageName: {
type: 'string',
description: 'Import/package name for TypeScript and Node packages.',
},
packageManager: {
type: 'string',
description: 'TypeScript package manager preference for generated package metadata.',
},
publish: {
$ref: '#/$defs/npmPublishConfig',
description: 'npm publishing configuration.',
},
},
required: ['publish'],
},
],
}
const result = generateTypeDefinition(schema, 'TypeScriptTargetConfig')
expect(result).toContain('/** Import/package name for TypeScript and Node packages. */')
expect(result).toContain('/** TypeScript package manager preference for generated package metadata. */')
expect(result).toContain('/** npm publishing configuration. */')
})
it('generates record type for patternProperties-only schema without explicit type', () => {
const schema: JSONSchema = {
patternProperties: {
'^x-': true,
},
}
const result = generateTypeDefinition(schema, 'SpecificationExtensions')
expect(result).toStrictEqual('export type SpecificationExtensions = Record<`x-${string}`, unknown>;')
})
it('generates Record<string, never> for patternProperties-only schema with false boolean value', () => {
// The false boolean schema means no values are allowed for matching keys,
// which maps to the never type in TypeScript.
const schema: JSONSchema = {
patternProperties: {
'^x-': false,
},
}
const result = generateTypeDefinition(schema, 'Restricted')
expect(result).toStrictEqual('export type Restricted = Record<`x-${string}`, never>;')
})
it('generates Schema type for property with $dynamicRef pointing to #meta', () => {
// $dynamicRef: '#meta' is a JSON Schema 2020-12 pattern used for recursive
// schema definitions that refer to the root Schema type itself.
const schema: JSONSchema.Object = {
type: 'object',
properties: {
schema: { $dynamicRef: '#meta' },
},
}
const result = generateTypeDefinition(schema, 'SchemaContainer')
expect(result).toContain('schema?: Schema')
})
it('generates type name from non-meta $dynamicRef', () => {
// A $dynamicRef other than '#meta' is converted via refToName like a $ref.
const schema: JSONSchema.Object = {
type: 'object',
properties: {
content: { $dynamicRef: '#/$defs/schema' },
},
}
const result = generateTypeDefinition(schema, 'ContentContainer')
expect(result).toContain('content?: Schema')
})
it('generates union type for schema with array of types', () => {
// JSON Schema allows `type` to be an array of strings to express a union type.
const schema: JSONSchema.Object = {
type: 'object',
properties: {
value: { type: ['string', 'null'] },
},
}
const result = generateTypeDefinition(schema, 'NullableStringContainer')
expect(result).toContain('value?: string | null')
})
it('generates correct union for all supported types in type array', () => {
const schema: JSONSchema.Object = {
type: 'object',
properties: {
anything: { type: ['string', 'number', 'boolean', 'null', 'array', 'object'] },
},
}
const result = generateTypeDefinition(schema, 'AnyTypeContainer')
expect(result).toContain('string | number | boolean | null | unknown[] | Record<string, unknown>')
})
it('infers Record<string, unknown> type for no-type property with boolean true additionalProperties', () => {
// A schema with additionalProperties: true and no explicit type is treated
// as an open record allowing any values.
const schema: JSONSchema.Object = {
type: 'object',
properties: {
extensions: { additionalProperties: true },
},
}
const result = generateTypeDefinition(schema, 'Container')
expect(result).toContain('extensions?: Record<string, unknown>')
})
it('infers Record<string, never> type for no-type property with boolean false additionalProperties', () => {
// A schema with additionalProperties: false and no explicit type means
// no values are allowed, which maps to the never type.
const schema: JSONSchema.Object = {
type: 'object',
properties: {
locked: { additionalProperties: false },
},
}
const result = generateTypeDefinition(schema, 'Container')
expect(result).toContain('locked?: Record<string, never>')
})
it('infers Record<`x-${string}`, unknown> for no-type property with ^x- patternProperties', () => {
// The ^x- pattern is a common JSON Schema convention for vendor extensions that
// maps naturally to the TypeScript template literal `x-${string}`.
const schema: JSONSchema.Object = {
type: 'object',
properties: {
extensions: { patternProperties: { '^x-': true } },
},
}
const result = generateTypeDefinition(schema, 'Container')
expect(result).toContain('extensions?: Record<`x-${string}`, unknown>')
})
it('infers string type for no-type property whose default is a string', () => {
// When a property has no explicit type but has a string default, we infer
// the type as string so the generated TypeScript stays as specific as possible.
const schema: JSONSchema.Object = {
type: 'object',
properties: {
format: { default: 'json' },
},
}
const result = generateTypeDefinition(schema, 'Config')
expect(result).toContain('format?: string')
})
it('infers number type for no-type property whose default is a number', () => {
const schema: JSONSchema.Object = {
type: 'object',
properties: {
timeout: { default: 30 },
},
}
const result = generateTypeDefinition(schema, 'Config')
expect(result).toContain('timeout?: number')
})
it('infers boolean type for no-type property whose default is a boolean', () => {
const schema: JSONSchema.Object = {
type: 'object',
properties: {
enabled: { default: true },
},
}
const result = generateTypeDefinition(schema, 'Config')
expect(result).toContain('enabled?: boolean')
})
it('generates type with JSDoc for additionalProperties-only schema when documentation is found', () => {
// This tests the documentation block (lines 431–438) inside the additionalProperties-only
// path — a code path that is only reached when the schema has no fixed properties but
// does have additionalProperties, and a matching documentation section exists.
const schema: JSONSchema.Object = {
$comment: 'https://spec.openapis.org/oas/v3.1#callback-object',
type: 'object',
additionalProperties: {
$ref: '#/$defs/path-item',
},
}
const result = generateTypeDefinition(schema, 'Callback')
expect(result).toContain('/**')
expect(result).toContain('* Callback')
expect(result).toContain('* https://spec.openapis.org/oas/v3.1#callback-object')
expect(result).toContain('[key: string]: PathItem')
})
it('generates type for product schema with required, optional, and array fields', () => {
const schema: JSONSchema = {
description: 'A product available for purchase in the catalog.',
type: 'object',
properties: {
id: { description: 'Unique product identifier (UUID).', type: 'string' },
name: { description: 'Display name shown to customers.', type: 'string' },
price: { description: 'Unit price in USD cents (must be non-negative).', type: 'number', minimum: 0 },
inStock: { description: 'Whether the product is currently available for purchase.', type: 'boolean' },
tags: {
description: 'Searchable labels associated with the product.',
type: 'array',
items: { type: 'string' },
},
},
required: ['id', 'name', 'price'],
}
const result = generateTypeDefinition(schema, 'Product')
expect(result).toBe(
'/**\n' +
'* Product\n' +
'*\n' +
'* A product available for purchase in the catalog.\n' +
'*/\n' +
'export type Product = {\n' +
' /** Unique product identifier (UUID). */\n' +
' id: string;\n' +
' /** Display name shown to customers. */\n' +
' name: string;\n' +
' /** Unit price in USD cents (must be non-negative). */\n' +
' price: number;\n' +
' /** Whether the product is currently available for purchase. */\n' +
' inStock?: boolean;\n' +
' /** Searchable labels associated with the product. */\n' +
' tags?: string[];\n' +
'};',
)
})
it('generates type for string enum schema', () => {
const schema: JSONSchema = {
description: 'One of the supported theme colors.',
type: 'string',
enum: ['red', 'green', 'blue', 'yellow', 'purple'],
}
const result = generateTypeDefinition(schema, 'ThemeColor')
expect(result).toBe(
'/**\n' +
'* ThemeColor\n' +
'*\n' +
'* One of the supported theme colors.\n' +
'*/\n' +
'export type ThemeColor = "red" | "green" | "blue" | "yellow" | "purple";',
)
})
it('generates type for geo coordinate with min/max constraints on required number fields', () => {
const schema: JSONSchema = {
description: 'A geographic coordinate pair.',
type: 'object',
properties: {
latitude: { description: 'Degrees latitude, from -90 to 90.', type: 'number', minimum: -90, maximum: 90 },
longitude: { description: 'Degrees longitude, from -180 to 180.', type: 'number', minimum: -180, maximum: 180 },
altitude: { description: 'Elevation in metres above sea level.', type: 'number' },
label: { description: 'Human-readable name for this location.', type: 'string' },
},
required: ['latitude', 'longitude'],
}
const result = generateTypeDefinition(schema, 'GeoCoordinate')
expect(result).toBe(
'/**\n' +
'* GeoCoordinate\n' +
'*\n' +
'* A geographic coordinate pair.\n' +
'*/\n' +
'export type GeoCoordinate = {\n' +
' /** Degrees latitude, from -90 to 90. */\n' +
' latitude: number;\n' +
' /** Degrees longitude, from -180 to 180. */\n' +
' longitude: number;\n' +
' /** Elevation in metres above sea level. */\n' +
' altitude?: number;\n' +
' /** Human-readable name for this location. */\n' +
' label?: string;\n' +
'};',
)
})
it('wraps union item types in parentheses for root-level array schema', () => {
const schema: JSONSchema = {
type: 'array',
items: { anyOf: [{ $ref: '#/$defs/parameter' }, { $ref: '#/$defs/reference' }] },
}
const result = generateTypeDefinition(schema, 'Parameters')
expect(result).toBe('export type Parameters = (Parameter | Reference)[];')
})
it('emits JSDoc for non-object schemas with a $comment URL', () => {
const schema: JSONSchema = {
$comment: 'https://spec.openapis.org/oas/v3.1#contact-object',
type: 'array',
items: { $ref: '#/$defs/server' },
}
const result = generateTypeDefinition(schema, 'Contacts')
expect(result).toContain('/**')
expect(result).toContain('* https://spec.openapis.org/oas/v3.1#contact-object')
expect(result).toContain('export type Contacts = Server[];')
})
it('emits JSDoc for non-object schemas with a plain-text $comment', () => {
const schema: JSONSchema = {
$comment: 'A list of parameters applicable to the operation.',
type: 'array',
items: { $ref: '#/$defs/parameter' },
}
const result = generateTypeDefinition(schema, 'Parameters')
expect(result).toContain('/**')
expect(result).toContain('A list of parameters applicable to the operation.')
expect(result).toContain('export type Parameters = Parameter[];')
})
it('generates unknown for external $ref', () => {
// External refs (e.g. from draft-04 schemas) cannot be resolved locally — treated as unknown.
const schema: JSONSchema = {
$ref: 'http://json-schema.org/draft-04/schema#/properties/maximum',
}
const result = generateTypeDefinition(schema, 'Maximum')
expect(result).toBe('export type Maximum = unknown;')
})
it('does not emit a trailing blank line in JSDoc when there is no @see link', () => {
const schema: JSONSchema = {
$comment: 'A plain-text description with no URL.',
type: 'object',
properties: {
value: { type: 'string' },
},
}
const result = generateTypeDefinition(schema, 'PlainComment')
expect(result).toMatch(/\* A plain-text description with no URL\.\n\*\//)
expect(result).not.toContain('* \n*/')
})
it('emits the class name for an x-mjst instanceOf property', () => {
const schema: JSONSchema = {
type: 'object',
properties: { createdAt: { 'x-mjst': { instanceOf: 'Date' } } },
required: ['createdAt'],
}
expect(generateTypeDefinition(schema, 'Event')).toContain('createdAt: Date;')
})
it('emits the class name for a top-level x-mjst instanceOf schema', () => {
const schema: JSONSchema = { 'x-mjst': { instanceOf: 'Date' } }
expect(generateTypeDefinition(schema, 'When')).toBe('export type When = Date;')
})
it('ignores an x-mjst instanceOf that is not a safe identifier', () => {
const schema: JSONSchema = { 'x-mjst': { instanceOf: 'Date; doEvil()' } }
expect(generateTypeDefinition(schema, 'When')).not.toContain('doEvil')
})
it('emits a primitive type for an x-mjst bigint property', () => {
const schema: JSONSchema = {
type: 'object',
properties: { balance: { 'x-mjst': { primitive: 'bigint' } } },
required: ['balance'],
}
expect(generateTypeDefinition(schema, 'Account')).toContain('balance: bigint;')
})
it('emits a primitive type for a top-level x-mjst bigint schema', () => {
expect(generateTypeDefinition({ 'x-mjst': { primitive: 'bigint' } }, 'Big')).toBe('export type Big = bigint;')
})
it('wraps a branded property in a nominal intersection', () => {
const schema: JSONSchema = {
type: 'object',
properties: { id: { type: 'string', 'x-mjst': { brand: 'UserId' } } },
required: ['id'],
}
expect(generateTypeDefinition(schema, 'User')).toContain("id: (string & { readonly __brand: 'UserId' });")
})
it('brands a top-level schema and combines with instanceOf', () => {
expect(generateTypeDefinition({ type: 'string', 'x-mjst': { brand: 'Email' } }, 'Email')).toBe(
"export type Email = (string & { readonly __brand: 'Email' });",
)
expect(generateTypeDefinition({ 'x-mjst': { instanceOf: 'Date', brand: 'Timestamp' } }, 'Ts')).toBe(
"export type Ts = (Date & { readonly __brand: 'Timestamp' });",
)
})
it('ignores an x-mjst brand that is not safe to embed', () => {
const schema: JSONSchema = { type: 'string', 'x-mjst': { brand: "x'; doEvil()" } }
const result = generateTypeDefinition(schema, 'Bad')
expect(result).not.toContain('doEvil')
expect(result).toBe('export type Bad = string;')
})
describe('readonly option', () => {
it('marks every property as readonly, deeply', () => {
const schema: JSONSchema = {
type: 'object',
properties: {
id: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } },
nested: {
type: 'object',
properties: { value: { type: 'number' } },
},
},
required: ['id'],
}
const result = generateTypeDefinition(schema, 'Doc', { readonly: true })
expect(result).toContain('readonly id: string;')
expect(result).toContain('readonly tags?: readonly string[];')
expect(result).toContain('readonly nested?: { readonly value?: number }')
})
it('wraps record types in Readonly', () => {
const schema: JSONSchema = {
type: 'object',
additionalProperties: { type: 'number' },
}
expect(generateTypeDefinition(schema, 'Map', { readonly: true })).toContain('readonly [key: string]: number;')
})
it('leaves output unchanged when readonly is not set', () => {
const schema: JSONSchema = {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
}
const result = generateTypeDefinition(schema, 'Doc')
expect(result).not.toContain('readonly')
expect(result).toContain('id: string;')
})
})
describe('typeSuffix', () => {
const schema: JSONSchema = {
type: 'object',
properties: {
contact: { $ref: '#/$defs/contact' },
},
required: ['contact'],
}
it('appends the suffix to ref-derived type names', () => {
const result = generateTypeDefinition(schema, 'Document', { typeSuffix: 'Object' })
expect(result).toContain('contact: ContactObject;')
})
it('emits no suffix by default', () => {
const result = generateTypeDefinition(schema, 'Document')
expect(result).toContain('contact: Contact;')
expect(result).not.toContain('ContactObject')
})
})
})
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
import { getMjstBrand, getMjstInstanceOf, getMjstPrimitive } from './mjst-extension'
import { refToName } from './ref-to-name'
import { safeKey } from './safe-accessor'
import { isObjectSchema, isSchemaObject } from './schema-guards'
type ConditionalObjectResult = {
schema: JSONSchema.Object
thenRef: string | null
}
/** Options controlling generated type output. */
export type TypeOptions = {
/** When true, every property, array, and record in the generated types is emitted as readonly. */
readonly readonly?: boolean
/**
* Suffix appended to every generated type name derived from a `$ref`.
* Defaults to `''` (no suffix). Set to e.g. `'Object'` to emit `ContactObject`.
*/
readonly typeSuffix?: string
}
const getConditionalObjectSchema = (schema: JSONSchema): ConditionalObjectResult | null => {
if (!isSchemaObject(schema)) {
return null
}
if (!('if' in schema) || !('then' in schema)) {
return null
}
const ifSchema = schema.if
const thenSchema = schema.then
if (!isSchemaObject(ifSchema) || !isSchemaObject(thenSchema)) {
return null
}
const ifProperties = ifSchema.properties
const thenProperties = thenSchema.properties
const hasIfProperties = ifProperties && typeof ifProperties === 'object'
const hasThenProperties = thenProperties && typeof thenProperties === 'object'
if (!hasIfProperties && !hasThenProperties) {
return null
}
const properties = {
...(hasIfProperties ? ifProperties : {}),
...(hasThenProperties ? thenProperties : {}),
}
const required = new Set<string>()
if (Array.isArray(ifSchema.required)) {
for (const key of ifSchema.required) {
required.add(key)
}
}
if (hasIfProperties) {
for (const key in ifProperties) {
required.add(key)
}
}
if (Array.isArray(thenSchema.required)) {
for (const key of thenSchema.required) {
required.add(key)
}
}
if (hasThenProperties) {
for (const key in thenProperties) {
required.add(key)
}
}
const thenRef = typeof thenSchema.$ref === 'string' ? thenSchema.$ref : null
return {
schema: {
type: 'object',
properties,
...(required.size > 0 ? { required: Array.from(required) } : {}),
},
thenRef,
}
}
const isObjectLikeSchema = (schema: JSONSchema): schema is JSONSchema.Object => {
if (!isSchemaObject(schema)) {
return false
}
if (isObjectSchema(schema)) {
return true
}
return 'patternProperties' in schema || 'additionalProperties' in schema || ('if' in schema && 'then' in schema)
}
const getBooleanSubSchemaType = (schema: boolean): string => {
return schema ? 'unknown' : 'never'
}
const buildJsDocBlock = (title: string, description: string, commentUrl?: string): string => {
let block = `/**\n`
block += `* ${title}\n`
block += `*\n`
block += `* ${description}\n`
if (commentUrl?.startsWith('http')) {
block += `* \n`
block += `* @see {@link ${commentUrl}}\n`
}
block += `*/\n`
return block
}
/**
* Converts a JSON Schema type to its TypeScript equivalent, applying any
* `x-mjst` brand. Branding is type-level only, so we compute the underlying
* type and intersect it with a unique brand marker. This is the recursion entry
* point, so branded nested fields are wrapped too.
*/
const getTypeScriptType = (schema: JSONSchema, options: TypeOptions = {}): string => {
const base = getUnbrandedType(schema, options)
const brand = getMjstBrand(schema)
return brand ? `(${base} & { readonly __brand: '${brand}' })` : base
}
/** Wraps a `Record<...>` in `Readonly<...>` when readonly output is requested. */
const recordType = (keyType: string, valueType: string, options: TypeOptions): string =>
options.readonly ? `Readonly<Record<${keyType}, ${valueType}>>` : `Record<${keyType}, ${valueType}>`
/**
* Converts a JSON Schema type to its TypeScript equivalent, ignoring any brand.
* Recursively handles nested objects and arrays.
*/
const getUnbrandedType = (schema: JSONSchema, options: TypeOptions = {}): string => {
// Boolean schema: `true` means any value is valid (unknown), `false` means no value is valid (never)
if (typeof schema === 'boolean') {
return getBooleanSubSchemaType(schema)
}
// Check if schema is an object (not a boolean schema)
if (typeof schema !== 'object' || schema === null) {
return 'unknown'
}
// An x-mjst instanceOf hint means the value is a runtime class (e.g. Date)
// that JSON Schema cannot describe — emit the class name as the type directly.
const instanceOf = getMjstInstanceOf(schema)
if (instanceOf) {
return instanceOf
}
// An x-mjst primitive hint (e.g. bigint) names a non-JSON primitive — emit it
// directly as the TypeScript type.
const primitive = getMjstPrimitive(schema)
if (primitive) {
return primitive
}
// Handle $ref
if (schema.$ref) {
// External refs (e.g. http://json-schema.org/...) cannot be resolved locally — treat as unknown
if (!schema.$ref.startsWith('#')) {
return 'unknown'
}
return refToName(schema.$ref, options.typeSuffix)
}
// Handle $dynamicRef (used for recursive schemas)
if (schema.$dynamicRef) {
// For #meta, this refers to the Schema type itself (JSON Schema 2020-12 $dynamicAnchor pattern)
if (schema.$dynamicRef === '#meta') {
return `Schema${options.typeSuffix ?? ''}`
}
return refToName(schema.$dynamicRef, options.typeSuffix)
}
// Handle const - literal type
if (schema.const !== undefined) {
return JSON.stringify(schema.const)
}
// Handle enum - union of literal types
if (schema.enum && schema.enum.length > 0) {
if (schema.enum.length === 1) {
return JSON.stringify(schema.enum[0])
}
let enumUnion = JSON.stringify(schema.enum[0])
for (let i = 1; i < schema.enum.length; i++) {
enumUnion += ' | ' + JSON.stringify(schema.enum[i])
}
return enumUnion
}
// Handle multi-value enum - union of literal types
if (schema.enum && schema.enum.length > 1) {
let multiEnumUnion = JSON.stringify(schema.enum[0])
for (let i = 1; i < schema.enum.length; i++) {
multiEnumUnion += ' | ' + JSON.stringify(schema.enum[i])
}
return multiEnumUnion
}
// Handle union types (oneOf, anyOf) - check this before returning unknown
if (schema.oneOf && Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
// schema.oneOf[0] is safe: we guard with .length > 0 above
let oneOfUnion = getTypeScriptType(schema.oneOf[0]!, options)
for (let i = 1; i < schema.oneOf.length; i++) {
oneOfUnion += ' | ' + getTypeScriptType(schema.oneOf[i]!, options)
}
return oneOfUnion
}
if (schema.anyOf && Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
// schema.anyOf[0] is safe: we guard with .length > 0 above
let anyOfUnion = getTypeScriptType(schema.anyOf[0]!, options)
for (let i = 1; i < schema.anyOf.length; i++) {
anyOfUnion += ' | ' + getTypeScriptType(schema.anyOf[i]!, options)
}
return anyOfUnion
}
// Handle allOf (intersection types)
if (schema.allOf && Array.isArray(schema.allOf) && schema.allOf.length > 0) {
// schema.allOf[0] is safe: we guard with .length > 0 above
let intersectionTypes = getTypeScriptType(schema.allOf[0]!, options)
for (let i = 1; i < schema.allOf.length; i++) {
intersectionTypes += ' & ' + getTypeScriptType(schema.allOf[i]!, options)
}
return intersectionTypes
}
// Handle object-like conditional schemas that use if/then without declaring type
const conditionalResult = getConditionalObjectSchema(schema)
if (conditionalResult) {
const baseType = getTypeScriptType(conditionalResult.schema, options)
if (conditionalResult.thenRef) {
return `(${baseType}) & ${refToName(conditionalResult.thenRef, options.typeSuffix)}`
}
return baseType
}
// No type so we return unknown
if (!schema.type) {
if (schema.additionalProperties !== undefined) {
if (typeof schema.additionalProperties === 'boolean') {
return recordType('string', getBooleanSubSchemaType(schema.additionalProperties), options)
}
return recordType('string', getTypeScriptType(schema.additionalProperties, options), options)
}
if (schema.patternProperties && typeof schema.patternProperties === 'object') {
const firstEntry = Object.entries(schema.patternProperties)[0]
if (firstEntry) {
const [pattern, value] = firstEntry
if (value !== undefined) {
const valueType =
typeof value === 'boolean' ? getBooleanSubSchemaType(value) : getTypeScriptType(value, options)
// The ^x- pattern is a common JSON Schema convention for vendor extensions
// that maps naturally to the TypeScript template literal `x-${string}`
if (pattern === '^x-') {
return recordType('`x-${string}`', valueType, options)
}
return recordType('string', valueType, options)
}
}
}
if (schema.default !== undefined) {
if (typeof schema.default === 'string') {
return 'string'
}
if (typeof schema.default === 'number') {
return 'number'
}
if (typeof schema.default === 'boolean') {
return 'boolean'
}
}
return 'unknown'
}
// Handle type as an array (union of types)
if (Array.isArray(schema.type)) {
const mapType = (t: string): string => {
switch (t) {
case 'string':
return 'string'
case 'number':
case 'integer':
return 'number'
case 'boolean':
return 'boolean'
case 'null':
return 'null'
case 'array':
return 'unknown[]'
case 'object':
return 'Record<string, unknown>'
default:
return 'unknown'
}
}
let typeUnion = mapType(schema.type[0])
for (let i = 1; i < schema.type.length; i++) {
typeUnion += ' | ' + mapType(schema.type[i])
}
return typeUnion
}
switch (schema.type) {
// String
case 'string':
return 'string'
// Number
case 'number':
case 'integer':
return 'number'
// Boolean
case 'boolean':
return 'boolean'
// Array
case 'array':
if (schema.items) {
const itemType = getTypeScriptType(schema.items, options)
// Wrap union types in parentheses so `(A | B)[]` is not misread as `A | B[]`
const wrappedItemType = itemType.includes(' | ') ? `(${itemType})` : itemType
return options.readonly ? `readonly ${wrappedItemType}[]` : `${wrappedItemType}[]`
}
return options.readonly ? 'readonly unknown[]' : 'unknown[]'
// Object
case 'object':
if (schema.properties) {
const readonlyPrefix = options.readonly ? 'readonly ' : ''
const hasDescriptions = Object.values(schema.properties).some(
(p) => isSchemaObject(p) && (typeof p.description === 'string' || typeof p.$comment === 'string'),
)
if (hasDescriptions) {
let properties = ''
let first = true
for (const key in schema.properties) {
// schema.properties[key] is safe: key comes from iterating schema.properties
const propSchema = schema.properties[key]!
const isRequired = schema.required?.includes(key) ?? false
const optional = isRequired ? '' : '?'
const propType = getTypeScriptType(propSchema, options)
const inlineDescription =
isSchemaObject(propSchema) && typeof propSchema.description === 'string'
? propSchema.description
: isSchemaObject(propSchema) && typeof propSchema.$comment === 'string'
? propSchema.$comment
: undefined
if (!first) properties += '\n'
first = false
if (inlineDescription) {
properties +=
' /** ' +
inlineDescription +
' */\n ' +
readonlyPrefix +
safeKey(key) +
optional +
': ' +
propType +
';'
} else {
properties += ' ' + readonlyPrefix + safeKey(key) + optional + ': ' + propType + ';'
}
}
return '{\n' + properties + '\n}'
}
let properties = ''
let first = true
for (const key in schema.properties) {
// schema.properties[key] is safe: key comes from iterating schema.properties
const propSchema = schema.properties[key]!
const isRequired = schema.required?.includes(key) ?? false
const optional = isRequired ? '' : '?'
const propType = getTypeScriptType(propSchema, options)
if (!first) properties += '; '
properties += readonlyPrefix + safeKey(key) + optional + ': ' + propType
first = false
}
return '{ ' + properties + ' }'
}
// Handle additionalProperties with $ref or $dynamicRef
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
const additionalPropType = getTypeScriptType(schema.additionalProperties, options)
return recordType('string', additionalPropType, options)
}
// Handle patternProperties as a Record type
if (schema.patternProperties && typeof schema.patternProperties === 'object') {
const firstEntry = Object.entries(schema.patternProperties)[0]
if (firstEntry) {
const [pattern, patternVal] = firstEntry
if (patternVal) {
const valueType = getTypeScriptType(patternVal, options)
if (pattern === '^x-') {
return recordType('`x-${string}`', valueType, options)
}
return recordType('string', valueType, options)
}
}
}
return 'object'
// Default to unknown
default:
return 'unknown'
}
}
/**
* Generates a TypeScript type definition from a JSON Schema.
* Handles required vs optional properties based on the schema's required array.
* Uses $comment as inline JSDoc description when present.
*/
export const generateTypeDefinition = (schema: JSONSchema, typeName: string, options: TypeOptions = {}): string => {
const readonlyPrefix = options.readonly ? 'readonly ' : ''
// Handle non-object schemas first
if (!isObjectLikeSchema(schema)) {
const tsType = getTypeScriptType(schema, options)
let result = ''
const topLevelComment =
(isSchemaObject(schema) && typeof schema.description === 'string' && schema.description) ||
(isSchemaObject(schema) && typeof schema.$comment === 'string' && schema.$comment) ||
undefined
if (topLevelComment) {
result += buildJsDocBlock(typeName, topLevelComment)
}
result += `export type ${typeName} = ${tsType};`
return result
}
if (isObjectLikeSchema(schema)) {
const conditionalResult = getConditionalObjectSchema(schema)
const normalizedSchema = conditionalResult?.schema ?? schema
const conditionalThenRef = conditionalResult?.thenRef ?? null
let jsDocTitle: string | undefined
let jsDocDescription: string | undefined
const topLevelComment =
(isSchemaObject(schema) && typeof schema.description === 'string' && schema.description) ||
(isSchemaObject(schema) && typeof schema.$comment === 'string' && schema.$comment) ||
undefined
if (topLevelComment) {
jsDocTitle = typeName
jsDocDescription = topLevelComment
}
const hasProperties = normalizedSchema.properties && Object.keys(normalizedSchema.properties).length > 0
const hasAdditionalProperties =
normalizedSchema.additionalProperties && typeof normalizedSchema.additionalProperties === 'object'
const hasPatternProperties =
normalizedSchema.patternProperties &&
typeof normalizedSchema.patternProperties === 'object' &&
Object.keys(normalizedSchema.patternProperties).length > 0
// Handle objects with only patternProperties (no fixed properties)
if (!hasProperties && hasPatternProperties && normalizedSchema.patternProperties) {
const firstEntry = Object.entries(normalizedSchema.patternProperties)[0]
const firstPattern = firstEntry?.[0]
const firstPatternProperty = firstEntry?.[1]
if (firstPatternProperty === undefined) {
return `export type ${typeName} = Record<string, unknown>;`
}
const patternPropType =
typeof firstPatternProperty === 'boolean'
? getBooleanSubSchemaType(firstPatternProperty)
: getTypeScriptType(firstPatternProperty, options)
// The ^x- pattern is a common JSON Schema convention for vendor extensions
// that maps naturally to the TypeScript template literal `x-${string}`
const keyType = firstPattern === '^x-' ? '`x-${string}`' : 'string'
let result = ''
if (jsDocTitle && jsDocDescription) {
result += buildJsDocBlock(jsDocTitle, jsDocDescription)
}
result += `export type ${typeName} = ${recordType(keyType, patternPropType, options)};`
return result
}
// Handle objects with only additionalProperties (no fixed properties)
if (!hasProperties && hasAdditionalProperties && normalizedSchema.additionalProperties) {
const additionalPropType = getTypeScriptType(normalizedSchema.additionalProperties, options)
let result = ''
if (jsDocTitle && jsDocDescription) {
result += buildJsDocBlock(jsDocTitle, jsDocDescription)
}
result += `export type ${typeName} = {\n ${readonlyPrefix}[key: string]: ${additionalPropType};\n};`
return result
}
const schemaProps = normalizedSchema.properties ?? {}
let properties = ''
let isFirstProp = true
for (const key in schemaProps) {
// schemaProps[key] is safe: key comes from iterating schemaProps
const propSchema = schemaProps[key]!
const isRequired = normalizedSchema.required?.includes(key) ?? false
const optional = isRequired ? '' : '?'
const propType = getTypeScriptType(propSchema, options)
const quotedKey = readonlyPrefix + safeKey(key)
if (!isFirstProp) properties += '\n'
isFirstProp = false
// Add JSDoc comment from $comment or description if available
const inlineDescription =
isSchemaObject(propSchema) && typeof propSchema.description === 'string'
? propSchema.description
: isSchemaObject(propSchema) && typeof propSchema.$comment === 'string'
? propSchema.$comment
: undefined
if (inlineDescription) {
properties += ' /** ' + inlineDescription + ' */\n ' + quotedKey + optional + ': ' + propType + ';'
} else {
properties += ' ' + quotedKey + optional + ': ' + propType + ';'
}
}
// Collect allOf $ref intersections
const allOfIntersections: string[] = []
if (isSchemaObject(schema) && Array.isArray(schema.allOf)) {
for (const entry of schema.allOf) {
if (isSchemaObject(entry) && entry.$ref) {
allOfIntersections.push(refToName(entry.$ref, options.typeSuffix))
}
}
}
// JSON Schema 2019-09+ allows $ref as a sibling to other keywords.
// Treat it as an additional intersection type (e.g. for specification-extensions).
if (isSchemaObject(schema) && typeof schema.$ref === 'string' && schema.$ref.startsWith('#')) {
allOfIntersections.push(refToName(schema.$ref, options.typeSuffix))
}
let result = ''
if (jsDocTitle && jsDocDescription) {
result += buildJsDocBlock(jsDocTitle, jsDocDescription)
}
let typeBody = '{\n' + properties + '\n}'
if (conditionalThenRef) {
typeBody += ' & ' + refToName(conditionalThenRef, options.typeSuffix)
}
for (const intersectionType of allOfIntersections) {
typeBody += ' & ' + intersectionType
}
result += 'export type ' + typeName + ' = ' + typeBody + ';'
return result
}
return 'export type ' + typeName + ' = unknown;'
}
/** Type guard to check if a value is a non-null, non-array object with a string $ref property */
export const hasRef = (value: unknown): value is { $ref: string } & Record<string, unknown> => {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
'$ref' in value &&
typeof (value as { $ref: unknown }).$ref === 'string'
)
}
import { describe, expect, it } from 'vitest'
import { isObject } from './is-object'
describe('is-object', () => {
it('returns true for plain object', () => {
expect(isObject({})).toBe(true)
})
it('returns true for object with properties', () => {
expect(isObject({ a: 1, b: 'hello' })).toBe(true)
})
it('returns false for null', () => {
expect(isObject(null)).toBe(false)
})
it('returns false for undefined', () => {
expect(isObject(undefined)).toBe(false)
})
it('returns false for array', () => {
expect(isObject([])).toBe(false)
expect(isObject([1, 2, 3])).toBe(false)
})
it('returns false for string', () => {
expect(isObject('hello')).toBe(false)
})
it('returns false for number', () => {
expect(isObject(42)).toBe(false)
expect(isObject(0)).toBe(false)
})
it('returns false for boolean', () => {
expect(isObject(true)).toBe(false)
expect(isObject(false)).toBe(false)
})
it('returns true for Date object', () => {
// This implementation returns true for Date since it is a simple typeof check
expect(isObject(new Date())).toBe(true)
})
it('returns true for RegExp object', () => {
expect(isObject(/test/)).toBe(true)
})
it('returns true for nested object', () => {
expect(isObject({ nested: { deep: true } })).toBe(true)
})
it('returns true for Object.create(null)', () => {
expect(isObject(Object.create(null))).toBe(true)
})
it('returns false for function', () => {
expect(isObject(() => {})).toBe(false)
})
it('returns false for empty string', () => {
expect(isObject('')).toBe(false)
})
})
/**
* Returns true if the provided value is a plain object.
* Optimized for JSON data validation: checks that value is truthy, typeof object,
* and not an array. This correctly handles all JSON value types.
*
* Examples:
* isObject({}) // true
* isObject({ a: 1 }) // true
* isObject([]) // false (Array)
* isObject(null) // false
* isObject(123) // false
* isObject('string') // false
*/
export const isObject = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value)
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
import { describe, expect, it } from 'vitest'
import { getMjstBrand, getMjstInstanceOf, getMjstPrimitive, MJST_EXTENSION_KEY } from './mjst-extension'
describe('getMjstInstanceOf', () => {
it('reads a valid instanceOf class name', () => {
expect(getMjstInstanceOf({ 'x-mjst': { instanceOf: 'Date' } })).toBe('Date')
})
it('returns undefined when the extension is absent', () => {
expect(getMjstInstanceOf({ type: 'string' })).toBeUndefined()
})
it('returns undefined for a boolean schema', () => {
expect(getMjstInstanceOf(true as unknown as JSONSchema)).toBeUndefined()
})
it('rejects instanceOf values that are not safe identifiers', () => {
expect(getMjstInstanceOf({ 'x-mjst': { instanceOf: 'Date; doEvil()' } })).toBeUndefined()
expect(getMjstInstanceOf({ 'x-mjst': { instanceOf: '' } })).toBeUndefined()
})
it('exposes the extension key', () => {
expect(MJST_EXTENSION_KEY).toBe('x-mjst')
})
})
describe('getMjstPrimitive', () => {
it('reads a supported primitive', () => {
expect(getMjstPrimitive({ 'x-mjst': { primitive: 'bigint' } })).toBe('bigint')
})
it('ignores unsupported primitives', () => {
expect(getMjstPrimitive({ 'x-mjst': { primitive: 'symbol' } })).toBeUndefined()
expect(getMjstPrimitive({ 'x-mjst': { primitive: 'evil()' } })).toBeUndefined()
})
it('returns undefined when the extension is absent', () => {
expect(getMjstPrimitive({ type: 'string' })).toBeUndefined()
})
})
describe('getMjstBrand', () => {
it('reads a safe brand name', () => {
expect(getMjstBrand({ 'x-mjst': { brand: 'UserId' } })).toBe('UserId')
expect(getMjstBrand({ 'x-mjst': { brand: 'order-id 2' } })).toBe('order-id 2')
})
it('rejects brand names that could break out of a string literal', () => {
expect(getMjstBrand({ 'x-mjst': { brand: "x'; doEvil()" } })).toBeUndefined()
expect(getMjstBrand({ 'x-mjst': { brand: 'a\\b' } })).toBeUndefined()
expect(getMjstBrand({ 'x-mjst': { brand: '' } })).toBeUndefined()
})
it('returns undefined when the extension is absent', () => {
expect(getMjstBrand({ type: 'string' })).toBeUndefined()
})
})
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
import { isSchemaObject } from './schema-guards'
/**
* Vendor extension keyword carrying mjst-specific runtime hints that plain JSON
* Schema cannot express on its own. Adapters (TypeBox, Zod, ...) emit it when a
* source construct has no native JSON Schema equivalent, and the generators read
* it to produce the right TypeScript type and runtime checks.
*/
export const MJST_EXTENSION_KEY = 'x-mjst'
/**
* The shape of the `x-mjst` extension object.
*
* - `instanceOf` names a JavaScript class the value must be an instance of at
* runtime (e.g. `'Date'`). It round-trips constructs like TypeBox's
* `Type.Date()` that JSON Schema's core vocabulary has no keyword for.
* - `primitive` names a non-JSON primitive type (e.g. `'bigint'`) that has no
* JSON Schema representation. Unlike `instanceOf`, the value is checked with
* `typeof`, not `instanceof`.
* - `brand` carries a nominal-typing brand name. It is purely type-level: the
* runtime value still validates as its underlying JSON Schema type, but the
* generated TypeScript type is intersected with a unique brand so values are
* not interchangeable with the unbranded base type.
*/
export type MjstExtension = {
readonly instanceOf?: string
readonly primitive?: string
readonly brand?: string
}
// Only identifier-safe class names are honoured, so a malicious or malformed
// schema cannot inject arbitrary code into the generated output.
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
// The non-JSON primitives we know how to generate type/runtime handling for.
// Anything outside this set is ignored so unknown hints degrade gracefully.
const SUPPORTED_PRIMITIVES = new Set(['bigint'])
// Brand names are embedded inside a single-quoted string literal in generated
// output, so we only allow characters that cannot break out of the literal.
const SAFE_BRAND = /^[\w$ -]+$/
const readExtensionString = (schema: JSONSchema, field: keyof MjstExtension): string | undefined => {
if (!isSchemaObject(schema)) return undefined
const extension = (schema as Record<string, unknown>)[MJST_EXTENSION_KEY]
if (typeof extension !== 'object' || extension === null) return undefined
const value = (extension as Record<string, unknown>)[field]
return typeof value === 'string' ? value : undefined
}
/**
* Reads the `instanceOf` class name from a schema's `x-mjst` extension, when it
* is present and a safe identifier. Returns undefined otherwise so callers fall
* back to ordinary type handling.
*/
export const getMjstInstanceOf = (schema: JSONSchema): string | undefined => {
const instanceOf = readExtensionString(schema, 'instanceOf')
return instanceOf !== undefined && IDENTIFIER.test(instanceOf) ? instanceOf : undefined
}
/**
* Reads the `primitive` type name from a schema's `x-mjst` extension, when it is
* one we support (e.g. `'bigint'`). Returns undefined otherwise.
*/
export const getMjstPrimitive = (schema: JSONSchema): string | undefined => {
const primitive = readExtensionString(schema, 'primitive')
return primitive !== undefined && SUPPORTED_PRIMITIVES.has(primitive) ? primitive : undefined
}
/**
* Reads the nominal `brand` name from a schema's `x-mjst` extension, when it is
* present and safe to embed in generated output. Returns undefined otherwise.
*/
export const getMjstBrand = (schema: JSONSchema): string | undefined => {
const brand = readExtensionString(schema, 'brand')
return brand !== undefined && SAFE_BRAND.test(brand) ? brand : undefined
}
import { describe, expect, it } from 'vitest'
import { parseDocumentation } from './parse-documentation'
describe('parse-documentation', () => {
const markdown = `
#### Info Object
This is the info description that spans
across multiple lines.
##### Fixed Fields
Field Name | Type | Description
---|---|---
title | string | **REQUIRED**. The title of the API.
description | string | A description of the API. [CommonMark syntax](http://commonmark.org/) MAY be used for rich text representation.
version | string | **REQUIRED**. The version of the OpenAPI Document.
This object MAY be extended with Specification Extensions.
#### Contact Object
Contact information for the exposed API.
##### Fixed Fields
Field Name | Type | Description
---|---|---
name | string | The identifying name of the contact person/organization.
url | string | The URL for the contact information. This MUST be in the form of a URL.
email | string | The email address of the contact person/organization.
`
it('parses title from fragment ID', () => {
const result = parseDocumentation(markdown, 'https://spec.openapis.org/oas/v3.1.1#info-object')
expect(result?.title).toBe('Info object')
})
it('parses description from section content', () => {
const result = parseDocumentation(markdown, 'https://spec.openapis.org/oas/v3.1.1#info-object')
expect(result?.description).toContain('info description')
})
it('parses properties from Fixed Fields table', () => {
const result = parseDocumentation(markdown, 'https://spec.openapis.org/oas/v3.1.1#info-object')
expect(result?.properties).toHaveProperty('title')
expect(result?.properties).toHaveProperty('description')
expect(result?.properties).toHaveProperty('version')
})
it('detects REQUIRED fields', () => {
const result = parseDocumentation(markdown, 'https://spec.openapis.org/oas/v3.1.1#info-object')
expect(result?.properties['title']?.isRequired).toBe(true)
expect(result?.properties['description']?.isRequired).toBe(false)
expect(result?.properties['version']?.isRequired).toBe(true)
})
it('returns null when fragment ID is missing', () => {
const result = parseDocumentation(markdown, 'https://spec.openapis.org/oas/v3.1.1')
expect(result).toBeNull()
})
it('returns null when section is not found', () => {
const result = parseDocumentation(markdown, 'https://spec.openapis.org/oas/v3.1.1#nonexistent-object')
expect(result).toBeNull()
})
it('parses contact object correctly', () => {
const result = parseDocumentation(markdown, 'https://spec.openapis.org/oas/v3.1.1#contact-object')
expect(result?.title).toBe('Contact object')
expect(result?.properties).toHaveProperty('name')
expect(result?.properties).toHaveProperty('url')
expect(result?.properties).toHaveProperty('email')
})
it('replaces relative anchor links with full URLs', () => {
const mdWithAnchors = `
#### Test Object
Test description.
##### Fixed Fields
Field Name | Type | Description
---|---|---
ref | string | See (#other-object) for details.
`
const result = parseDocumentation(mdWithAnchors, 'https://spec.openapis.org/oas/v3.1.1#test-object')
expect(result?.properties['ref']?.description).toContain('https://spec.openapis.org/oas/v3.1.1#other-object')
})
it('handles escaped pipes in table cells', () => {
const mdWithPipes = `
#### Pipe Object
Description.
##### Fixed Fields
Field Name | Type | Description
---|---|---
pattern | string | Uses \\| as separator.
`
const result = parseDocumentation(mdWithPipes, 'https://example.com#pipe-object')
expect(result?.properties['pattern']?.description).toContain('|')
})
it('handles table with Applies To column', () => {
const mdWithAppliesTo = `
#### Extended Object
Description.
##### Fixed Fields
Field Name | Applies To | Type | Description
---|---|---|---
name | All | string | The field name description.
`
const result = parseDocumentation(mdWithAppliesTo, 'https://example.com#extended-object')
expect(result?.properties['name']?.description).toContain('field name description')
})
it('returns null for empty markdown', () => {
const result = parseDocumentation('', 'https://example.com#some-object')
expect(result).toBeNull()
})
it('returns empty description when section has no description before Fixed Fields', () => {
const mdNoDesc = `
#### No Desc Object
##### Fixed Fields
Field Name | Type | Description
---|---|---
name | string | A name field.
`
const result = parseDocumentation(mdNoDesc, 'https://example.com#no-desc-object')
expect(result?.description).toBe('')
expect(result?.properties).toHaveProperty('name')
})
it('handles HTML tags in field names', () => {
const mdWithHtml = `
#### Html Object
Description.
##### Fixed Fields
Field Name | Type | Description
---|---|---
<span>name</span> | string | A name field.
`
const result = parseDocumentation(mdWithHtml, 'https://example.com#html-object')
expect(result?.properties).toHaveProperty('name')
})
it('inherits properties from fallback section when primary has no Fixed Fields table', () => {
const mdWithFallback = `
#### Header Object
The Header Object follows the structure of the Parameter Object.
#### Parameter Object
Parameter description.
##### Fixed Fields
Field Name | Type | Description
---|---|---
description | string | A brief description.
required | boolean | Whether the parameter is mandatory.
`
const result = parseDocumentation(
mdWithFallback,
'https://example.com#header-object',
'https://example.com#parameter-object',
)
expect(result?.title).toBe('Header object')
expect(result?.description).toContain('follows the structure')
expect(result?.properties).toHaveProperty('description')
expect(result?.properties).toHaveProperty('required')
})
it('does not use fallback when primary section has its own Fixed Fields table', () => {
const mdWithBoth = `
#### Header Object
Header description.
##### Fixed Fields
Field Name | Type | Description
---|---|---
ownField | string | A field unique to header.
#### Parameter Object
Parameter description.
##### Fixed Fields
Field Name | Type | Description
---|---|---
description | string | A brief description.
`
const result = parseDocumentation(
mdWithBoth,
'https://example.com#header-object',
'https://example.com#parameter-object',
)
expect(result?.properties).toHaveProperty('ownField')
expect(result?.properties).not.toHaveProperty('description')
})
it('excludes non-field rows from adjacent tables within the Fixed Fields section', () => {
// The Encoding Object has a "default contentType" table between its two Fixed Fields
// sub-tables. Rows like `\`string\`` and `[_absent_]` must not be treated as field names.
const mdWithAdjacentTable = `
#### Encoding Object
Encoding description.
##### Fixed Fields
###### Common Fixed Fields
| Field Name | Type | Description |
| ---- | :----: | ---- |
| contentType | \`string\` | The content type. |
This object MAY be extended with Specification Extensions.
| \`type\` | \`contentEncoding\` | Default |
| ---- | ---- | ---- |
| \`string\` | present | \`application/octet-stream\` |
| [_absent_](#foo) | n/a | \`application/octet-stream\` |
###### RFC6570 Fields
| Field Name | Type | Description |
| ---- | :----: | ---- |
| style | \`string\` | The style. |
`
const result = parseDocumentation(mdWithAdjacentTable, 'https://example.com#encoding-object')
expect(result?.properties).toHaveProperty('contentType')
expect(result?.properties).toHaveProperty('style')
// Rows from the non-field table should be excluded
expect(result?.properties).not.toHaveProperty('`string`')
expect(result?.properties).not.toHaveProperty('`type`')
expect(Object.keys(result?.properties ?? {}).some((k) => k.startsWith('['))).toBe(false)
})
it('collects properties from multiple sub-tables within a single Fixed Fields section', () => {
const mdMultiTable = `
#### Encoding Object
Encoding description.
##### Fixed Fields
###### Common Fixed Fields
| Field Name | Type | Description |
| ---- | :----: | ---- |
| contentType | \`string\` | The content type. |
| headers | Map | A map of headers. |
This object MAY be extended with Specification Extensions.
###### Fixed Fields for RFC6570-style Serialization
| Field Name | Type | Description |
| ---- | :----: | ---- |
| style | \`string\` | The style. |
| explode | \`boolean\` | Whether to explode. |
| allowReserved | \`boolean\` | Whether to allow reserved. |
#### Next Object
Next description.
`
const result = parseDocumentation(mdMultiTable, 'https://example.com#encoding-object')
expect(result?.properties).toHaveProperty('contentType')
expect(result?.properties).toHaveProperty('headers')
expect(result?.properties).toHaveProperty('style')
expect(result?.properties).toHaveProperty('explode')
expect(result?.properties).toHaveProperty('allowReserved')
expect(result?.properties['style']?.description).toBe('The style.')
})
it('ignores fallback when fallback section also has no properties', () => {
const mdNoProperties = `
#### Header Object
Header description.
#### Parameter Object
Parameter description with no table.
`
const result = parseDocumentation(
mdNoProperties,
'https://example.com#header-object',
'https://example.com#parameter-object',
)
expect(result?.properties).toEqual({})
})
})
type PropertyDocumentation = {
description: string
isRequired: boolean
}
export type ObjectDocumentation = {
title: string
description: string
properties: Record<string, PropertyDocumentation>
}
/**
* Fetches and parses OpenAPI specification documentation from the markdown source.
*
* When a section has no Fixed Fields table (e.g. Header Object delegates to Parameter Object),
* pass `fallbackCommentUrl` to inherit property documentation from another section.
*/
export const parseDocumentation = (
markdownDocumentation: string,
commentUrl: string,
fallbackCommentUrl?: string,
): ObjectDocumentation | null => {
try {
const markdown = markdownDocumentation
// Extract the fragment ID from the URL (e.g., #info-object)
const fragmentId = commentUrl.split('#')[1]
if (!fragmentId) {
return null
}
// Extract the base URL (everything before the #)
const baseUrl = commentUrl.split('#')[0]
// Convert fragment ID to title case (e.g., "info-object" -> "Info Object")
const titleWords = fragmentId.split('-')
let sectionTitle = ''
for (let i = 0; i < titleWords.length; i++) {
if (i > 0) sectionTitle += ' '
const word = titleWords[i] ?? ''
sectionTitle += word.charAt(0).toUpperCase() + word.slice(1)
}
// Detect the heading level used for object sections in this markdown file.
// OAS 3.1 uses #### while OAS 3.2 uses ###, so we probe for both.
const headingLevelMatch = markdown.match(/^(#{2,5})\s+\S.*Object\s*$/m)
const headingHashes = headingLevelMatch?.[1] ?? '####'
const subHeadingHashes = headingHashes + '#'
// Find the section in the markdown
// Match the section heading and capture everything until the next same-level heading or end of file
const escapedHashes = headingHashes.replace(/#/g, '\\#')
const sectionRegex = new RegExp(
`${escapedHashes}\\s+${sectionTitle}\\s*\\n([\\s\\S]*?)(?=\\n${escapedHashes}\\s|$)`,
'i',
)
const sectionMatch = markdown.match(sectionRegex)
if (!sectionMatch) {
return null
}
const sectionContent = sectionMatch?.[1]
if (!sectionContent) {
return null
}
// Extract the description (paragraphs before the "Fixed Fields" sub-heading)
// \n? handles the case where sectionContent starts directly with the sub-heading (no leading newline),
// which happens when the section heading is followed by a blank line consumed by \s* in sectionRegex.
const escapedSubHashes = subHeadingHashes.replace(/#/g, '\\#')
const descriptionMatch = sectionContent.match(new RegExp(`^([\\s\\S]*?)(?=\\n?${escapedSubHashes}|$)`))
const description = descriptionMatch?.[1]?.trim().replace(/\n/g, ' ') || ''
// Extract all Fixed Fields tables within the section (some objects like Encoding Object
// split their fields across multiple sub-tables under different sub-sub-headings).
// Capture everything from the first "Fixed Fields" sub-heading to the end of the section —
// the section content is already bounded by the outer section regex so we don't need
// a stop condition here.
const fixedFieldsRegex = new RegExp(`${escapedSubHashes} Fixed Fields\\s*\\n([\\s\\S]*)`)
const fixedFieldsMatch = sectionContent.match(fixedFieldsRegex)
const properties: Record<string, PropertyDocumentation> = {}
if (fixedFieldsMatch?.[1]) {
const fixedFieldsContent = fixedFieldsMatch[1]
// Parse all markdown table rows across all sub-tables within the Fixed Fields section
const lines = fixedFieldsContent.split('\n')
let inTable = false
for (const line of lines) {
// Skip the header row and separator row
if (line.includes('Field Name') || line.includes('---|')) {
inTable = true
continue
}
// A ###### sub-heading resets inTable so we re-enter on the next header row
if (line.startsWith('#')) {
inTable = false
continue
}
if (!inTable || !line.trim() || !line.includes('|')) {
continue
}
// Parse table row: | fieldName | type | description |
// First, replace escaped pipes (\|) with a placeholder to avoid splitting on them
const lineWithPlaceholder = line.replace(/\\\|/g, '___PIPE___')
const cells = lineWithPlaceholder
.split('|')
.map((cell) => cell.replace(/___PIPE___/g, '|').trim())
.filter((cell) => cell)
if (cells.length >= 3) {
const fieldName = cells[0]?.replace(/<[^>]*>/g, '').trim() // Remove HTML tags
// Some OpenAPI tables include an "Applies To" column before Description.
// We always want the right-most column as the property description.
const descriptionCellIndex = cells.length >= 4 ? 3 : 2
let fieldDescription = cells[descriptionCellIndex]?.trim() || ''
const isRequired = fieldDescription.includes('REQUIRED')
// Replace relative anchor links with full URLs using the base URL
fieldDescription = fieldDescription.replace(/\(#([^)]+)\)/g, `(${baseUrl}#$1)`)
// Only accept field names that look like valid identifiers (camelCase or plain words).
// This filters out rows from non-field tables (e.g. type/contentType default tables)
// where the "field name" column contains type annotations like `string` or [_absent_].
const isValidFieldName =
fieldName &&
!fieldName.includes('Field Name') &&
!fieldName.startsWith('`') &&
!fieldName.startsWith('[') &&
/^[a-zA-Z]/.test(fieldName)
if (isValidFieldName) {
properties[fieldName] = {
description: fieldDescription,
isRequired,
}
}
}
}
}
// Format title as "Info object" instead of "Info Object"
const title = sectionTitle.replace(/\sObject$/, ' object')
// If no properties were found and a fallback URL is provided, inherit properties from it
if (Object.keys(properties).length === 0 && fallbackCommentUrl) {
const fallback = parseDocumentation(markdownDocumentation, fallbackCommentUrl)
if (fallback && Object.keys(fallback.properties).length > 0) {
return {
title,
description,
properties: fallback.properties,
}
}
}
return {
title,
description,
properties,
}
} catch (error) {
console.error('Failed to fetch OpenAPI documentation:', error)
return null
}
}
import { describe, expect, it } from 'vitest'
import { refToFilename } from './ref-to-filename'
describe('ref-to-filename', () => {
it('extracts filename from simple ref', () => {
expect(refToFilename('#/$defs/contact')).toBe('contact')
})
it('extracts filename from kebab-case ref', () => {
expect(refToFilename('#/$defs/server-variable')).toBe('server-variable')
})
it('extracts filename from multi-word ref', () => {
expect(refToFilename('#/$defs/external-documentation')).toBe('external-documentation')
})
it('extracts filename from refs with multiple path segments', () => {
expect(refToFilename('#/components/schemas/user-profile')).toBe('user-profile')
})
it('extracts filename from single word ref', () => {
expect(refToFilename('#/$defs/info')).toBe('info')
})
it('handles refs with numbers', () => {
expect(refToFilename('#/$defs/oauth2-flow')).toBe('oauth2-flow')
})
it('converts PascalCase definitions keys to kebab-case', () => {
expect(refToFilename('#/definitions/ServerVariable')).toBe('server-variable')
expect(refToFilename('#/definitions/Contact')).toBe('contact')
expect(refToFilename('#/definitions/ExternalDocumentation')).toBe('external-documentation')
})
it('converts consecutive uppercase acronyms correctly', () => {
expect(refToFilename('#/definitions/APIKeySecurityScheme')).toBe('api-key-security-scheme')
})
it('handles OAuth mixed-case acronym correctly', () => {
expect(refToFilename('#/definitions/OAuthFlows')).toBe('oauth-flows')
expect(refToFilename('#/definitions/ImplicitOAuthFlow')).toBe('implicit-oauth-flow')
expect(refToFilename('#/definitions/OAuth2SecurityScheme')).toBe('oauth2-security-scheme')
})
it('derives filename from a plain URI ref', () => {
expect(refToFilename('http://asyncapi.com/definitions/3.1.0/channel.json')).toBe('channel')
expect(refToFilename('http://asyncapi.com/definitions/3.1.0/info.json')).toBe('info')
})
it('derives filename from a URI binding ref including version for disambiguation', () => {
expect(refToFilename('http://asyncapi.com/bindings/kafka/0.5.0/channel.json')).toBe('bindings-kafka-0-5-0-channel')
})
it('derives filename from a URI ref with a fragment', () => {
expect(refToFilename('http://asyncapi.com/bindings/sns/0.1.0/channel.json#/definitions/queue')).toBe(
'bindings-sns-0-1-0-channel-queue',
)
})
it('handles URI ref with empty fragment (trailing #)', () => {
expect(refToFilename('http://json-schema.org/draft-07/schema#')).toBe('draft-07-schema')
})
})
/**
* Converts a PascalCase or camelCase string to kebab-case.
* Handles consecutive uppercase sequences (e.g. "APIKey" → "api-key") and
* known mixed-case acronyms like "OAuth" that would otherwise split incorrectly.
*
* @example
* ```ts
* toKebabCase('ServerVariable') // 'server-variable'
* toKebabCase('APIKeySecurityScheme') // 'api-key-security-scheme'
* toKebabCase('OAuthFlows') // 'oauth-flows'
* toKebabCase('already-kebab') // 'already-kebab'
* ```
*/
export const toKebabCase = (value: string): string =>
value
// Collapse known mixed-case acronyms before splitting so they stay together
.replace(/OAuth/g, 'Oauth')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
.replace(/([a-z\d])([A-Z])/g, '$1-$2')
.toLowerCase()
/**
* Derives a unique kebab-case filename from a URI ref.
*
* For a plain URI (no fragment), uses the path segments after the host,
* stripping version numbers, `.json` extension, and joining with `-`.
* For a URI with a fragment, appends the fragment's last path segment.
*
* @example
* ```ts
* uriRefToFilename('http://asyncapi.com/definitions/3.1.0/channel.json')
* // 'channel'
* uriRefToFilename('http://asyncapi.com/bindings/kafka/0.5.0/channel.json')
* // 'kafka-channel-binding'
* uriRefToFilename('http://asyncapi.com/bindings/sns/0.1.0/channel.json#/definitions/queue')
* // 'sns-channel-queue'
* ```
*/
const uriRefToFilename = (uri: string): string => {
const hashIndex = uri.indexOf('#')
const baseUri = hashIndex === -1 ? uri : uri.slice(0, hashIndex)
const fragment = hashIndex === -1 ? '' : uri.slice(hashIndex + 1)
// Strip protocol + host, remove .json extension
const withoutProtocol = baseUri.replace(/^https?:\/\/[^/]+\//, '')
const withoutExt = withoutProtocol.replace(/\.json$/, '')
// Drop structural/noise segments:
// - "definitions" and "$defs" container keys
// - Version numbers that immediately follow "definitions" (e.g. "3.1.0" in "definitions/3.1.0/channel")
// but NOT version numbers in other positions (e.g. "0.5.0" in "bindings/kafka/0.5.0/channel")
// since those are needed to disambiguate multiple versions of the same binding
const rawSegments = withoutExt.split('/')
const SKIP_KEYS = new Set(['definitions', '$defs'])
const segments: string[] = []
for (let i = 0; i < rawSegments.length; i++) {
const s = rawSegments[i] as string
if (SKIP_KEYS.has(s)) continue
// Skip a version segment only if the previous (non-skipped) segment was "definitions"
const prevRaw = rawSegments[i - 1]
if (/^\d+\.\d+/.test(s) && prevRaw !== undefined && SKIP_KEYS.has(prevRaw)) continue
segments.push(s)
}
// Join remaining segments, converting to kebab-case and replacing dots with dashes
const baseName = segments.map((s) => toKebabCase(s).replace(/\./g, '-')).join('-')
if (!fragment) return baseName
// Append the last meaningful segment of the fragment, skipping structural keys
const fragSegments = fragment.split('/').filter((s) => s && !SKIP_KEYS.has(s) && s !== 'properties')
const fragLast = fragSegments[fragSegments.length - 1]
if (!fragLast) return baseName
return `${baseName}-${toKebabCase(fragLast)}`
}
/**
* Converts a JSON Schema $ref to a filename.
*
* Handles three ref forms:
* - Internal `#/$defs/contact` → `contact`
* - Internal `#/definitions/ServerVariable` → `server-variable`
* - URI `http://example.com/definitions/3.1.0/channel.json` → `channel`
* - URI with fragment `http://example.com/channel.json#/definitions/queue` → `channel-queue`
*
* @param ref - The $ref string
* @returns The filename without extension
*
* @example
* ```ts
* refToFilename('#/$defs/contact') // 'contact'
* refToFilename('#/$defs/server-variable') // 'server-variable'
* refToFilename('#/definitions/ServerVariable') // 'server-variable'
* refToFilename('#/definitions/APIKeySecurityScheme') // 'api-key-security-scheme'
* refToFilename('http://asyncapi.com/definitions/3.1.0/channel.json') // 'channel'
* ```
*/
export const refToFilename = (ref: string): string => {
// URI ref — derive name from URI path
if (ref.startsWith('http://') || ref.startsWith('https://')) {
return uriRefToFilename(ref)
}
// Internal ref — extract the last segment after the last /
const segments = ref.split('/')
// Non-null assertion is safe here: split always returns at least one element
let filename = segments[segments.length - 1] as string
// Normalise PascalCase/camelCase keys (e.g. from draft-07 "definitions") to kebab-case
if (/[A-Z]/.test(filename)) {
filename = toKebabCase(filename)
}
return filename
}
import { describe, expect, it } from 'vitest'
import { refToName } from './ref-to-name'
describe('ref-to-name', () => {
it('converts simple ref to PascalCase', () => {
expect(refToName('#/$defs/contact')).toBe('Contact')
})
it('converts kebab-case ref to PascalCase', () => {
expect(refToName('#/$defs/server-variable')).toBe('ServerVariable')
})
it('converts multi-word kebab-case ref to PascalCase', () => {
expect(refToName('#/$defs/external-documentation')).toBe('ExternalDocumentation')
})
it('handles refs with multiple path segments', () => {
expect(refToName('#/components/schemas/user-profile')).toBe('UserProfile')
})
it('handles single word refs', () => {
expect(refToName('#/$defs/info')).toBe('Info')
})
it('handles refs with numbers', () => {
expect(refToName('#/$defs/oauth2-flow')).toBe('Oauth2Flow')
})
it('converts uppercase acronym keys to PascalCase via kebab normalization', () => {
expect(refToName('#/$defs/APIKey')).toBe('ApiKey')
})
it('derives type name from a URI ref', () => {
expect(refToName('http://asyncapi.com/definitions/3.1.0/channel.json')).toBe('Channel')
expect(refToName('http://asyncapi.com/definitions/3.1.0/info.json')).toBe('Info')
})
it('derives type name from a URI ref with a fragment', () => {
expect(refToName('http://asyncapi.com/bindings/sns/0.1.0/channel.json#/definitions/queue')).toBe(
'BindingsSns010ChannelQueue',
)
})
it('handles draft-07 schema URI', () => {
expect(refToName('http://json-schema.org/draft-07/schema')).toBe('Draft07Schema')
})
it('appends the suffix when one is provided', () => {
expect(refToName('#/$defs/contact', 'Object')).toBe('ContactObject')
expect(refToName('#/$defs/server-variable', 'Object')).toBe('ServerVariableObject')
})
it('treats an empty suffix the same as omitting it', () => {
expect(refToName('#/$defs/contact', '')).toBe('Contact')
})
})
import { refToFilename } from './ref-to-filename'
/**
* Converts a kebab-case filename to PascalCase, appending an optional suffix.
*
* @example
* ```ts
* kebabToPascal('server-variable') // 'ServerVariable'
* kebabToPascal('channel') // 'Channel'
* kebabToPascal('channel', 'Object') // 'ChannelObject'
* ```
*/
const kebabToPascal = (kebab: string, suffix: string): string => {
const words = kebab.split('-')
let pascalCase = ''
for (const word of words) {
pascalCase += word.charAt(0).toUpperCase() + word.slice(1)
}
return pascalCase + suffix
}
/**
* Converts a JSON Schema $ref to a type name.
* Derives the filename via `refToFilename` then converts to PascalCase,
* appending an optional suffix.
*
* Handles all ref forms: internal `#/$defs/...`, `#/definitions/...`, and URI refs.
*
* @param ref - The $ref string
* @param suffix - Optional suffix appended to the PascalCase name. Defaults to
* `''` (no suffix). Pass e.g. `'Object'` to get `ContactObject`.
* @returns The type name in PascalCase with the suffix applied
*
* @example
* ```ts
* refToName('#/$defs/contact') // 'Contact'
* refToName('#/$defs/server-variable') // 'ServerVariable'
* refToName('#/$defs/contact', 'Object') // 'ContactObject'
* refToName('http://asyncapi.com/definitions/3.1.0/channel.json') // 'Channel'
* ```
*/
export const refToName = (ref: string, suffix = ''): string => kebabToPascal(refToFilename(ref), suffix)
import { describe, expect, it } from 'vitest'
import { resolveDynamicRefs } from './resolve-dynamic-refs'
describe('resolve-dynamic-refs', () => {
it('replaces $dynamicRef with $ref in properties', () => {
const schema = {
type: 'object' as const,
properties: {
schema: { $dynamicRef: '#meta' },
},
}
const result = resolveDynamicRefs(schema, { '#meta': '#/$defs/schema' })
expect(result).toEqual({
type: 'object',
properties: {
schema: { $ref: '#/$defs/schema' },
},
})
})
it('replaces $dynamicRef in additionalProperties', () => {
const schema = {
type: 'object' as const,
additionalProperties: { $dynamicRef: '#meta' },
}
const result = resolveDynamicRefs(schema, { '#meta': '#/$defs/schema' })
expect(result).toEqual({
type: 'object',
additionalProperties: { $ref: '#/$defs/schema' },
})
})
it('does not mutate the original schema', () => {
const schema = {
type: 'object' as const,
properties: {
schema: { $dynamicRef: '#meta' },
},
}
resolveDynamicRefs(schema, { '#meta': '#/$defs/schema' })
// Original should be unchanged
expect(schema.properties.schema).toEqual({ $dynamicRef: '#meta' })
})
it('returns schema unchanged when dynamicRefMap is empty', () => {
const schema = {
type: 'object' as const,
properties: {
schema: { $dynamicRef: '#meta' },
},
}
const result = resolveDynamicRefs(schema, {})
expect(result).toEqual(schema)
})
it('returns non-object schemas unchanged', () => {
expect(resolveDynamicRefs(true, { '#meta': '#/$defs/schema' })).toBe(true)
expect(resolveDynamicRefs(false, { '#meta': '#/$defs/schema' })).toBe(false)
})
it('replaces $dynamicRef nested inside an array keyword (allOf)', () => {
const schema = {
allOf: [{ $dynamicRef: '#meta' }, { type: 'object' as const, properties: { x: { $dynamicRef: '#meta' } } }],
}
const result = resolveDynamicRefs(schema, { '#meta': '#/$defs/schema' })
expect(result).toEqual({
allOf: [{ $ref: '#/$defs/schema' }, { type: 'object', properties: { x: { $ref: '#/$defs/schema' } } }],
})
})
it('handles nested $dynamicRef in property sub-schemas', () => {
const schema = {
type: 'object' as const,
properties: {
schemas: {
type: 'object' as const,
additionalProperties: { $dynamicRef: '#meta' },
},
},
}
const result = resolveDynamicRefs(schema, { '#meta': '#/$defs/schema' })
const expected = {
type: 'object',
properties: {
schemas: {
type: 'object',
additionalProperties: { $ref: '#/$defs/schema' },
},
},
}
expect(result).toEqual(expected)
})
})
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
/**
* Replaces $dynamicRef with $ref in a schema using the provided anchor-to-path map.
*
* This creates a deep clone of the schema to avoid mutating the original, then walks
* the clone and converts any { $dynamicRef: "#meta" } to { $ref: "#/$defs/schema" }
* (or whatever the dynamicRefMap dictates).
*
* Walks both object properties and array elements, so a `$dynamicRef` nested
* inside a keyword whose value is an array of subschemas (`allOf`, `anyOf`,
* `oneOf`, `prefixItems`, …) is rewritten too. The build system generates a
* separate file per `$def`, so each schema walked here is relatively shallow.
*/
export const resolveDynamicRefs = (schema: JSONSchema, dynamicRefMap: Record<string, string>): JSONSchema => {
if (typeof schema !== 'object' || schema === null) {
return schema
}
// Skip if there are no dynamic refs to resolve
if (Object.keys(dynamicRefMap).length === 0) {
return schema
}
const clone = JSON.parse(JSON.stringify(schema)) as Record<string, unknown>
const walk = (obj: unknown): void => {
if (typeof obj !== 'object' || obj === null) {
return
}
if (Array.isArray(obj)) {
for (const item of obj) walk(item)
return
}
const record = obj as Record<string, unknown>
if ('$dynamicRef' in record && typeof record['$dynamicRef'] === 'string') {
const resolved = dynamicRefMap[record['$dynamicRef'] as string]
if (resolved) {
record['$ref'] = resolved
delete record['$dynamicRef']
}
}
for (const key in record) {
walk(record[key])
}
}
walk(clone)
return clone as JSONSchema
}
import { describe, expect, it } from 'vitest'
import { resolveRef } from './resolve-ref'
describe('resolveRef', () => {
it('resolves a simple $ref to $defs', () => {
const schema = {
$defs: {
contact: {
type: 'object',
properties: {
email: { type: 'string' },
phone: { type: 'string' },
},
},
},
}
const result = resolveRef('#/$defs/contact', schema)
expect(result).toEqual({
type: 'object',
properties: {
email: { type: 'string' },
phone: { type: 'string' },
},
})
})
it('resolves a nested $ref', () => {
const schema = {
components: {
schemas: {
User: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
},
},
},
}
const result = resolveRef('#/components/schemas/User', schema)
expect(result).toEqual({
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
})
})
it('returns undefined for non-existent $ref', () => {
const schema = {
$defs: {
contact: { type: 'object' },
},
}
const result = resolveRef('#/$defs/nonexistent', schema)
expect(result).toBeUndefined()
})
it('returns undefined for URI refs not present in $defs', () => {
const schema = {
$defs: {
contact: { type: 'object' },
},
}
const result = resolveRef('http://example.com/schema.json', schema)
expect(result).toBeUndefined()
})
it('resolves a URI ref that is a $defs key', () => {
const schema = {
$defs: {
'http://example.com/channel.json': { type: 'object', properties: { name: { type: 'string' } } },
},
}
const result = resolveRef('http://example.com/channel.json', schema)
expect(result).toEqual({ type: 'object', properties: { name: { type: 'string' } } })
})
it('resolves a URI ref with a fragment into a nested definition', () => {
const schema = {
$defs: {
'http://example.com/channel.json': {
type: 'object',
$defs: {
queue: { type: 'object', properties: { name: { type: 'string' } } },
},
},
},
}
const result = resolveRef('http://example.com/channel.json#/$defs/queue', schema)
expect(result).toEqual({ type: 'object', properties: { name: { type: 'string' } } })
})
it('handles URI-encoded characters in $ref', () => {
const schema = {
$defs: {
'my~field': {
type: 'string',
},
},
}
// ~0 represents ~
const result = resolveRef('#/$defs/my~0field', schema)
expect(result).toEqual({ type: 'string' })
})
it('resolves deeply nested $ref', () => {
const schema = {
definitions: {
nested: {
deep: {
value: {
type: 'boolean',
},
},
},
},
}
const result = resolveRef('#/definitions/nested/deep/value', schema)
expect(result).toEqual({ type: 'boolean' })
})
it('returns undefined for empty $ref', () => {
const schema = {
$defs: {
contact: { type: 'object' },
},
}
const result = resolveRef('#', schema)
expect(result).toEqual(schema)
})
it('handles $ref to array items', () => {
const schema = {
$defs: {
stringArray: {
type: 'array',
items: { type: 'string' },
},
},
}
const result = resolveRef('#/$defs/stringArray', schema)
expect(result).toEqual({
type: 'array',
items: { type: 'string' },
})
})
it('returns undefined when a path segment resolves to a non-object value', () => {
// When a path segment exists but its value is a primitive (not an object),
// navigation cannot continue and the ref is unresolvable.
const schema = {
$defs: {
// "count" is a number, not an object — resolveRef cannot navigate into it
count: 42,
},
}
const result = resolveRef('#/$defs/count', schema as unknown as Record<string, unknown>)
expect(result).toBeUndefined()
})
})
/**
* Navigates a JSON Pointer fragment (e.g. `/$defs/foo` or `/definitions/bar`)
* through a schema object, returning the target or undefined if not found.
*/
const navigatePointer = (pointer: string, schema: Record<string, unknown>): Record<string, unknown> | undefined => {
const parts = pointer.split('/').filter(Boolean)
let current = schema
for (const part of parts) {
const decodedPart = part.replace(/~1/g, '/').replace(/~0/g, '~')
if (current && typeof current === 'object' && decodedPart in current) {
const next = current[decodedPart as keyof typeof current]
if (typeof next === 'object' && next !== null) {
current = next as Record<string, unknown>
} else {
return undefined
}
} else {
return undefined
}
}
return current
}
/**
* Resolves a JSON Schema $ref pointer to the actual schema definition.
*
* Supports three ref forms:
* - Internal: `#/$defs/contact` — navigates the root schema by JSON Pointer
* - URI key: `http://example.com/foo.json` — looks up the key directly in `$defs`
* - URI with fragment: `http://example.com/foo.json#/definitions/bar` — looks up
* the base URI in `$defs`, then navigates the fragment within that definition
*
* @param ref - The $ref string
* @param rootSchema - The root schema containing the definitions
* @returns The resolved schema or undefined if not found
*
* @example
* ```ts
* const rootSchema = {
* $defs: {
* contact: { type: 'object', properties: { email: { type: 'string' } } },
* 'http://example.com/server.json': { type: 'object' },
* }
* }
* resolveRef('#/$defs/contact', rootSchema)
* resolveRef('http://example.com/server.json', rootSchema)
* ```
*/
export const resolveRef = (ref: string, rootSchema: Record<string, unknown>): Record<string, unknown> | undefined => {
// Internal reference: navigate from root by JSON Pointer
if (ref.startsWith('#')) {
return navigatePointer(ref.slice(1), rootSchema)
}
// URI ref: may have a fragment (e.g. "http://foo.com/bar.json#/definitions/baz")
// A trailing bare "#" (e.g. "http://foo.com/schema#") means "the whole document" — treat as no fragment
const hashIndex = ref.indexOf('#')
const baseUri = hashIndex === -1 ? ref : ref.slice(0, hashIndex)
const rawFragment = hashIndex === -1 ? '' : ref.slice(hashIndex + 1)
const fragment = rawFragment === '' || rawFragment === '/' ? '' : rawFragment
// Look up the base URI as a key in $defs
const defs = rootSchema['$defs']
if (typeof defs !== 'object' || defs === null) return undefined
const defsRecord = defs as Record<string, unknown>
const base = defsRecord[baseUri]
if (typeof base !== 'object' || base === null) return undefined
// No fragment — return the definition directly
if (!fragment) return base as Record<string, unknown>
// Normalize the fragment: draft-07 schemas use `/definitions/` but after
// upgradeDraft07Schema the key is renamed to `/$defs/`
const normalizedFragment = fragment.replace(/^\/definitions\//, '/$defs/')
// Navigate the fragment within the resolved definition
return navigatePointer(normalizedFragment, base as Record<string, unknown>)
}
import { describe, expect, it } from 'vitest'
import { safeAccessor, safeKey } from './safe-accessor'
describe('safe-accessor', () => {
it('uses dot notation for simple identifiers', () => {
expect(safeAccessor('input', 'name')).toBe('input.name')
})
it('uses bracket notation for hyphenated keys', () => {
expect(safeAccessor('input', 'x-linkedin')).toBe("input['x-linkedin']")
})
it('uses optional chaining with bracket notation for hyphenated keys', () => {
expect(safeAccessor('input?', 'x-linkedin')).toBe("input?.['x-linkedin']")
})
it('uses dot notation for underscored identifiers', () => {
expect(safeAccessor('input', '_private')).toBe('input._private')
})
it('uses bracket notation for keys starting with numbers', () => {
expect(safeAccessor('input', '0foo')).toBe("input['0foo']")
})
it('uses bracket notation for keys with dots', () => {
expect(safeAccessor('input', 'foo.bar')).toBe("input['foo.bar']")
})
it('returns unquoted key for simple identifiers', () => {
expect(safeKey('name')).toBe('name')
})
it('returns quoted key for hyphenated names', () => {
expect(safeKey('x-linkedin')).toBe("'x-linkedin'")
})
it('returns quoted key for names with dots', () => {
expect(safeKey('foo.bar')).toBe("'foo.bar'")
})
})
/**
* Checks whether a property name is a valid JavaScript identifier that can be
* accessed with dot notation. Property names containing hyphens, dots, or
* other special characters (e.g., "x-linkedin") must use bracket notation.
*/
const JS_IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
/**
* Generates a safe property accessor for a given key on an object variable.
* Uses dot notation for simple identifiers and bracket notation for keys
* that contain special characters like hyphens.
*
* @param variable - The variable name (e.g., "input", "input?")
* @param key - The property name to access
* @returns A valid JS property access expression
*
* @example
* safeAccessor("input", "name") // "input.name"
* safeAccessor("input?", "x-linkedin") // "input?.['x-linkedin']"
* safeAccessor("input", "x-linkedin") // "input['x-linkedin']"
*/
export const safeAccessor = (variable: string, key: string): string => {
if (JS_IDENTIFIER.test(key)) {
return `${variable}.${key}`
}
// Handle optional chaining: "input?" -> "input?.['key']"
if (variable.endsWith('?')) {
return `${variable}.['${key}']`
}
return `${variable}['${key}']`
}
/**
* Generates a safe property key for use in object literals.
* Wraps keys that are not valid identifiers in quotes.
*
* @example
* safeKey("name") // "name"
* safeKey("x-linkedin") // "'x-linkedin'"
*/
export const safeKey = (key: string): string => {
if (JS_IDENTIFIER.test(key)) {
return key
}
return `'${key}'`
}
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
import { describe, expect, it } from 'vitest'
import {
hasAdditionalProperties,
hasAllOf,
hasAnyOf,
hasConst,
hasDefault,
hasDependentRequired,
hasEnum,
hasExamples,
hasExclusiveMaximum,
hasExclusiveMinimum,
hasFormat,
hasItems,
hasMaxItems,
hasMaximum,
hasMaxLength,
hasMaxProperties,
hasMinItems,
hasMinimum,
hasMinLength,
hasMinProperties,
hasMultipleOf,
hasOneOf,
hasPattern,
hasProperties,
hasPropertyNames,
hasRef,
hasRequired,
hasType,
hasUniqueItems,
isObjectSchema,
isSchemaObject,
} from './schema-guards'
describe('schema-guards', () => {
// isSchemaObject
it('isSchemaObject returns true for plain object', () => {
expect(isSchemaObject({})).toBe(true)
})
it('isSchemaObject returns true for object with properties', () => {
expect(isSchemaObject({ type: 'string' })).toBe(true)
})
it('isSchemaObject returns false for boolean true', () => {
expect(isSchemaObject(true)).toBe(false)
})
it('isSchemaObject returns false for boolean false', () => {
expect(isSchemaObject(false)).toBe(false)
})
// hasType
it('hasType returns true when type is a string', () => {
expect(hasType({ type: 'string' })).toBe(true)
})
it('hasType returns false when type is missing', () => {
expect(hasType({})).toBe(false)
})
it('hasType returns false for boolean schema', () => {
expect(hasType(true)).toBe(false)
})
it('hasType returns false when type is an array', () => {
expect(hasType({ type: ['string', 'number'] })).toBe(false)
})
// isObjectSchema
it('isObjectSchema returns true for schema with type object', () => {
expect(isObjectSchema({ type: 'object' })).toBe(true)
})
it('isObjectSchema returns true for schema with properties but no type', () => {
expect(isObjectSchema({ properties: { name: { type: 'string' } } })).toBe(true)
})
it('isObjectSchema returns false for string type', () => {
expect(isObjectSchema({ type: 'string' })).toBe(false)
})
it('isObjectSchema returns false for boolean schema', () => {
expect(isObjectSchema(true)).toBe(false)
})
// hasProperties
it('hasProperties returns true when properties is an object', () => {
expect(hasProperties({ properties: { name: { type: 'string' } } })).toBe(true)
})
it('hasProperties returns true for empty properties object', () => {
expect(hasProperties({ properties: {} })).toBe(true)
})
it('hasProperties returns false when properties is missing', () => {
expect(hasProperties({ type: 'object' })).toBe(false)
})
it('hasProperties returns false for boolean schema', () => {
expect(hasProperties(true)).toBe(false)
})
// hasEnum
it('hasEnum returns true when enum is an array', () => {
expect(hasEnum({ enum: ['a', 'b'] })).toBe(true)
})
it('hasEnum returns true for empty enum array', () => {
expect(hasEnum({ enum: [] })).toBe(true)
})
it('hasEnum returns false when enum is missing', () => {
expect(hasEnum({})).toBe(false)
})
it('hasEnum returns false for boolean schema', () => {
expect(hasEnum(false)).toBe(false)
})
// hasConst
it('hasConst returns true when const is present', () => {
expect(hasConst({ const: 'value' })).toBe(true)
})
it('hasConst returns true for null const', () => {
expect(hasConst({ const: null })).toBe(true)
})
it('hasConst returns false when const is missing', () => {
expect(hasConst({})).toBe(false)
})
// hasPattern
it('hasPattern returns true when pattern is a string', () => {
expect(hasPattern({ pattern: '^[a-z]+$' })).toBe(true)
})
it('hasPattern returns false when pattern is not a string', () => {
// Intentionally passing wrong type to verify the guard rejects it
expect(hasPattern({ pattern: 123 } as unknown as JSONSchema)).toBe(false)
})
it('hasPattern returns false when pattern is missing', () => {
expect(hasPattern({})).toBe(false)
})
// hasFormat
it('hasFormat returns true when format is a string', () => {
expect(hasFormat({ format: 'date-time' })).toBe(true)
})
it('hasFormat returns false when format is missing', () => {
expect(hasFormat({})).toBe(false)
})
it('hasFormat returns false when format is not a string', () => {
// Intentionally passing wrong type to verify the guard rejects it
expect(hasFormat({ format: 123 } as unknown as JSONSchema)).toBe(false)
})
// hasDefault
it('hasDefault returns true when default is present', () => {
expect(hasDefault({ default: 'value' })).toBe(true)
})
it('hasDefault returns true for undefined-like defaults', () => {
expect(hasDefault({ default: null })).toBe(true)
})
it('hasDefault returns false when default is missing', () => {
expect(hasDefault({})).toBe(false)
})
// hasExamples
it('hasExamples returns true when examples is an array', () => {
expect(hasExamples({ examples: ['foo'] })).toBe(true)
})
it('hasExamples returns true for empty examples array', () => {
expect(hasExamples({ examples: [] })).toBe(true)
})
it('hasExamples returns false when examples is missing', () => {
expect(hasExamples({})).toBe(false)
})
it('hasExamples returns false when examples is not an array', () => {
// Intentionally passing wrong type to verify the guard rejects it
expect(hasExamples({ examples: 'not-array' } as unknown as JSONSchema)).toBe(false)
})
// hasOneOf
it('hasOneOf returns true when oneOf is an array', () => {
expect(hasOneOf({ oneOf: [{ type: 'string' }] })).toBe(true)
})
it('hasOneOf returns false when oneOf is missing', () => {
expect(hasOneOf({})).toBe(false)
})
// hasAnyOf
it('hasAnyOf returns true when anyOf is an array', () => {
expect(hasAnyOf({ anyOf: [{ type: 'string' }] })).toBe(true)
})
it('hasAnyOf returns false when anyOf is missing', () => {
expect(hasAnyOf({})).toBe(false)
})
// hasAllOf
it('hasAllOf returns true when allOf is an array', () => {
expect(hasAllOf({ allOf: [{ type: 'string' }] })).toBe(true)
})
it('hasAllOf returns false when allOf is missing', () => {
expect(hasAllOf({})).toBe(false)
})
// hasRequired
it('hasRequired returns true when required is an array', () => {
expect(hasRequired({ required: ['name'] })).toBe(true)
})
it('hasRequired returns true for empty required array', () => {
expect(hasRequired({ required: [] })).toBe(true)
})
it('hasRequired returns false when required is missing', () => {
expect(hasRequired({})).toBe(false)
})
// hasItems
it('hasItems returns true when items is a schema object', () => {
expect(hasItems({ items: { type: 'string' } })).toBe(true)
})
it('hasItems returns false when items is boolean true', () => {
expect(hasItems({ items: true })).toBe(false)
})
it('hasItems returns false when items is missing', () => {
expect(hasItems({})).toBe(false)
})
// hasAdditionalProperties
it('hasAdditionalProperties returns true when additionalProperties is present', () => {
expect(hasAdditionalProperties({ additionalProperties: false })).toBe(true)
})
it('hasAdditionalProperties returns true for schema additionalProperties', () => {
expect(hasAdditionalProperties({ additionalProperties: { type: 'string' } })).toBe(true)
})
it('hasAdditionalProperties returns false when missing', () => {
expect(hasAdditionalProperties({})).toBe(false)
})
// hasMinLength
it('hasMinLength returns true when minLength is a number', () => {
expect(hasMinLength({ minLength: 1 })).toBe(true)
})
it('hasMinLength returns false when minLength is not a number', () => {
// Intentionally passing wrong type to verify the guard rejects it
expect(hasMinLength({ minLength: '1' } as unknown as JSONSchema)).toBe(false)
})
it('hasMinLength returns false when missing', () => {
expect(hasMinLength({})).toBe(false)
})
// hasMaxLength
it('hasMaxLength returns true when maxLength is a number', () => {
expect(hasMaxLength({ maxLength: 100 })).toBe(true)
})
it('hasMaxLength returns false when missing', () => {
expect(hasMaxLength({})).toBe(false)
})
// hasMinimum
it('hasMinimum returns true when minimum is a number', () => {
expect(hasMinimum({ minimum: 0 })).toBe(true)
})
it('hasMinimum returns false when missing', () => {
expect(hasMinimum({})).toBe(false)
})
// hasMaximum
it('hasMaximum returns true when maximum is a number', () => {
expect(hasMaximum({ maximum: 100 })).toBe(true)
})
it('hasMaximum returns false when missing', () => {
expect(hasMaximum({})).toBe(false)
})
// hasExclusiveMinimum
it('hasExclusiveMinimum returns true when exclusiveMinimum is a number', () => {
expect(hasExclusiveMinimum({ exclusiveMinimum: 0 })).toBe(true)
})
it('hasExclusiveMinimum returns false for boolean exclusiveMinimum', () => {
// Intentionally passing wrong type to verify the guard rejects it
expect(hasExclusiveMinimum({ exclusiveMinimum: true } as unknown as JSONSchema)).toBe(false)
})
it('hasExclusiveMinimum returns false when missing', () => {
expect(hasExclusiveMinimum({})).toBe(false)
})
// hasExclusiveMaximum
it('hasExclusiveMaximum returns true when exclusiveMaximum is a number', () => {
expect(hasExclusiveMaximum({ exclusiveMaximum: 100 })).toBe(true)
})
it('hasExclusiveMaximum returns false for boolean exclusiveMaximum', () => {
// Intentionally passing wrong type to verify the guard rejects it
expect(hasExclusiveMaximum({ exclusiveMaximum: false } as unknown as JSONSchema)).toBe(false)
})
it('hasExclusiveMaximum returns false when missing', () => {
expect(hasExclusiveMaximum({})).toBe(false)
})
// hasMultipleOf
it('hasMultipleOf returns true when multipleOf is a number', () => {
expect(hasMultipleOf({ multipleOf: 5 })).toBe(true)
})
it('hasMultipleOf returns false when missing', () => {
expect(hasMultipleOf({})).toBe(false)
})
// hasMinItems
it('hasMinItems returns true when minItems is a number', () => {
expect(hasMinItems({ minItems: 1 })).toBe(true)
})
it('hasMinItems returns false when missing', () => {
expect(hasMinItems({})).toBe(false)
})
// hasMaxItems
it('hasMaxItems returns true when maxItems is a number', () => {
expect(hasMaxItems({ maxItems: 10 })).toBe(true)
})
it('hasMaxItems returns false when missing', () => {
expect(hasMaxItems({})).toBe(false)
})
// hasUniqueItems
it('hasUniqueItems returns true when uniqueItems is a boolean', () => {
expect(hasUniqueItems({ uniqueItems: true })).toBe(true)
expect(hasUniqueItems({ uniqueItems: false })).toBe(true)
})
it('hasUniqueItems returns false when missing', () => {
expect(hasUniqueItems({})).toBe(false)
})
// hasMinProperties
it('hasMinProperties returns true when minProperties is a number', () => {
expect(hasMinProperties({ minProperties: 1 })).toBe(true)
})
it('hasMinProperties returns false when missing', () => {
expect(hasMinProperties({})).toBe(false)
})
it('hasMinProperties returns false for non-number value', () => {
// Intentionally passing wrong type to verify the guard rejects it
expect(hasMinProperties({ minProperties: '1' } as unknown as JSONSchema)).toBe(false)
})
// hasMaxProperties
it('hasMaxProperties returns true when maxProperties is a number', () => {
expect(hasMaxProperties({ maxProperties: 10 })).toBe(true)
})
it('hasMaxProperties returns false when missing', () => {
expect(hasMaxProperties({})).toBe(false)
})
// hasDependentRequired
it('hasDependentRequired returns true for an object value', () => {
expect(hasDependentRequired({ dependentRequired: { a: ['b'] } })).toBe(true)
})
it('hasDependentRequired returns false when missing or non-object', () => {
expect(hasDependentRequired({})).toBe(false)
expect(hasDependentRequired({ dependentRequired: null } as unknown as JSONSchema)).toBe(false)
})
// hasPropertyNames
it('hasPropertyNames returns true when the keyword is present', () => {
expect(hasPropertyNames({ propertyNames: { pattern: '^[a-z]+$' } })).toBe(true)
})
it('hasPropertyNames returns false when missing', () => {
expect(hasPropertyNames({})).toBe(false)
})
// hasRef
it('hasRef returns true when $ref is a string', () => {
expect(hasRef({ $ref: '#/$defs/user' })).toBe(true)
})
it('hasRef returns false when $ref is missing', () => {
expect(hasRef({})).toBe(false)
})
it('hasRef returns false for boolean schema', () => {
expect(hasRef(true)).toBe(false)
})
it('hasRef returns false when $ref is not a string', () => {
// Intentionally passing wrong type to verify the guard rejects it
expect(hasRef({ $ref: 123 } as unknown as JSONSchema)).toBe(false)
})
})
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
type SchemaObject = Exclude<JSONSchema, false | boolean>
/** Type guard to check if schema is not false */
export const isSchemaObject = (schema: JSONSchema): schema is SchemaObject => {
return typeof schema === 'object' && schema !== null && typeof schema !== 'boolean'
}
/** Type guard to check if schema has a type property */
export const hasType = (schema: JSONSchema): schema is SchemaObject & { type: string } => {
return isSchemaObject(schema) && 'type' in schema && typeof schema.type === 'string'
}
/** Type guard to check if schema is an object schema */
export const isObjectSchema = (schema: JSONSchema): schema is JSONSchema.Object => {
return isSchemaObject(schema) && (('type' in schema && schema.type === 'object') || 'properties' in schema)
}
/** Type guard to check if schema has properties */
export const hasProperties = (
schema: JSONSchema,
): schema is SchemaObject & { properties: Record<string, JSONSchema> } => {
return (
isSchemaObject(schema) &&
'properties' in schema &&
typeof schema.properties === 'object' &&
schema.properties !== null
)
}
/** Type guard to check if schema has enum */
export const hasEnum = (schema: JSONSchema): schema is SchemaObject & { enum: readonly unknown[] } => {
return isSchemaObject(schema) && 'enum' in schema && Array.isArray(schema.enum)
}
/** Type guard to check if schema has const */
export const hasConst = (schema: JSONSchema): schema is SchemaObject & { const: unknown } => {
return isSchemaObject(schema) && 'const' in schema
}
/** Type guard to check if schema has pattern */
export const hasPattern = (schema: JSONSchema): schema is SchemaObject & { pattern: string } => {
return isSchemaObject(schema) && 'pattern' in schema && typeof schema.pattern === 'string'
}
/** Type guard to check if schema has format */
export const hasFormat = (schema: JSONSchema): schema is SchemaObject & { format: string } => {
return isSchemaObject(schema) && 'format' in schema && typeof schema.format === 'string'
}
/** Type guard to check if schema has default */
export const hasDefault = (schema: JSONSchema): schema is SchemaObject & { default: unknown } => {
return isSchemaObject(schema) && 'default' in schema
}
/** Type guard to check if schema has examples */
export const hasExamples = (schema: JSONSchema): schema is SchemaObject & { examples: readonly unknown[] } => {
return isSchemaObject(schema) && 'examples' in schema && Array.isArray(schema.examples)
}
/** Type guard to check if schema has oneOf */
export const hasOneOf = (schema: JSONSchema): schema is SchemaObject & { oneOf: readonly JSONSchema[] } => {
return isSchemaObject(schema) && 'oneOf' in schema && Array.isArray(schema.oneOf)
}
/** Type guard to check if schema has anyOf */
export const hasAnyOf = (schema: JSONSchema): schema is SchemaObject & { anyOf: readonly JSONSchema[] } => {
return isSchemaObject(schema) && 'anyOf' in schema && Array.isArray(schema.anyOf)
}
/** Type guard to check if schema has allOf */
export const hasAllOf = (schema: JSONSchema): schema is SchemaObject & { allOf: readonly JSONSchema[] } => {
return isSchemaObject(schema) && 'allOf' in schema && Array.isArray(schema.allOf)
}
/** Type guard to check if schema has required */
export const hasRequired = (schema: JSONSchema): schema is SchemaObject & { required: readonly string[] } => {
return isSchemaObject(schema) && 'required' in schema && Array.isArray(schema.required)
}
/** Type guard to check if schema has items (and it's not just boolean) */
export const hasItems = (schema: JSONSchema): schema is SchemaObject & { items: SchemaObject } => {
return (
isSchemaObject(schema) &&
'items' in schema &&
typeof schema.items === 'object' &&
schema.items !== null &&
typeof schema.items !== 'boolean'
)
}
/** Type guard to check if schema has additionalProperties */
export const hasAdditionalProperties = (
schema: JSONSchema,
): schema is SchemaObject & { additionalProperties: JSONSchema | boolean } => {
return isSchemaObject(schema) && 'additionalProperties' in schema
}
/** Type guard to check if schema has minLength */
export const hasMinLength = (schema: JSONSchema): schema is SchemaObject & { minLength: number } => {
return isSchemaObject(schema) && 'minLength' in schema && typeof schema.minLength === 'number'
}
/** Type guard to check if schema has maxLength */
export const hasMaxLength = (schema: JSONSchema): schema is SchemaObject & { maxLength: number } => {
return isSchemaObject(schema) && 'maxLength' in schema && typeof schema.maxLength === 'number'
}
/** Type guard to check if schema has minimum */
export const hasMinimum = (schema: JSONSchema): schema is SchemaObject & { minimum: number } => {
return isSchemaObject(schema) && 'minimum' in schema && typeof schema.minimum === 'number'
}
/** Type guard to check if schema has maximum */
export const hasMaximum = (schema: JSONSchema): schema is SchemaObject & { maximum: number } => {
return isSchemaObject(schema) && 'maximum' in schema && typeof schema.maximum === 'number'
}
/** Type guard to check if schema has exclusiveMinimum */
export const hasExclusiveMinimum = (schema: JSONSchema): schema is SchemaObject & { exclusiveMinimum: number } => {
return isSchemaObject(schema) && 'exclusiveMinimum' in schema && typeof schema.exclusiveMinimum === 'number'
}
/** Type guard to check if schema has exclusiveMaximum */
export const hasExclusiveMaximum = (schema: JSONSchema): schema is SchemaObject & { exclusiveMaximum: number } => {
return isSchemaObject(schema) && 'exclusiveMaximum' in schema && typeof schema.exclusiveMaximum === 'number'
}
/** Type guard to check if schema has multipleOf */
export const hasMultipleOf = (schema: JSONSchema): schema is SchemaObject & { multipleOf: number } => {
return isSchemaObject(schema) && 'multipleOf' in schema && typeof schema.multipleOf === 'number'
}
/** Type guard to check if schema has minItems */
export const hasMinItems = (schema: JSONSchema): schema is SchemaObject & { minItems: number } => {
return isSchemaObject(schema) && 'minItems' in schema && typeof schema.minItems === 'number'
}
/** Type guard to check if schema has maxItems */
export const hasMaxItems = (schema: JSONSchema): schema is SchemaObject & { maxItems: number } => {
return isSchemaObject(schema) && 'maxItems' in schema && typeof schema.maxItems === 'number'
}
/** Type guard to check if schema has uniqueItems */
export const hasUniqueItems = (schema: JSONSchema): schema is SchemaObject & { uniqueItems: boolean } => {
return isSchemaObject(schema) && 'uniqueItems' in schema && typeof schema.uniqueItems === 'boolean'
}
/** Type guard to check if schema has minProperties */
export const hasMinProperties = (schema: JSONSchema): schema is SchemaObject & { minProperties: number } => {
return isSchemaObject(schema) && 'minProperties' in schema && typeof schema.minProperties === 'number'
}
/** Type guard to check if schema has maxProperties */
export const hasMaxProperties = (schema: JSONSchema): schema is SchemaObject & { maxProperties: number } => {
return isSchemaObject(schema) && 'maxProperties' in schema && typeof schema.maxProperties === 'number'
}
/** Type guard to check if schema has dependentRequired (2020-12). */
export const hasDependentRequired = (
schema: JSONSchema,
): schema is SchemaObject & { dependentRequired: Record<string, readonly string[]> } => {
return (
isSchemaObject(schema) &&
'dependentRequired' in schema &&
typeof schema.dependentRequired === 'object' &&
schema.dependentRequired !== null
)
}
/** Type guard to check if schema has a propertyNames subschema. */
export const hasPropertyNames = (schema: JSONSchema): schema is SchemaObject & { propertyNames: JSONSchema } => {
return isSchemaObject(schema) && 'propertyNames' in schema
}
export { hasRef } from './has-ref'
/**
* Upgrades a JSON Schema draft-07 document to be compatible with the
* draft 2020-12 conventions used by the build pipeline.
*
* Draft-07 schemas differ from 2020-12 in two ways that affect our pipeline:
* - They use `definitions` instead of `$defs`
* - Their `definitions` keys (and `$ref` values) may be full URIs
* (e.g. `http://asyncapi.com/definitions/3.1.0/channel.json`) rather than
* short names (e.g. `channel`)
*
* This function:
* 1. Renames `definitions` → `$defs` at the root level only
* 2. Hoists any nested `$defs` (originally `definitions` inside sub-schemas)
* up to the root `$defs` with a prefixed name, rewriting internal refs
* so they resolve correctly from the root
* 3. Rewrites bare `$ref: "#"` self-references within each definition to
* point back to that definition's root-level `$defs` entry
*
* Only applied when the schema declares `$schema: http://json-schema.org/draft-07/schema`.
*/
import { refToFilename, toKebabCase } from './ref-to-filename'
/**
* Returns true if the schema is a draft-07 document that needs upgrading.
*/
export const isDraft07Schema = (schema: Record<string, unknown>): boolean =>
typeof schema['$schema'] === 'string' && schema['$schema'].includes('draft-07')
/**
* Rewrites `$ref` values in a schema tree using an explicit string→string map.
* Also rewrites bare `$ref: "#"` to the given `selfRef` path when provided.
*/
const rewriteRefs = (obj: unknown, refMap: ReadonlyMap<string, string>, selfRef?: string): unknown => {
if (typeof obj !== 'object' || obj === null) return obj
if (Array.isArray(obj)) return obj.map((item) => rewriteRefs(item, refMap, selfRef))
const record = obj as Record<string, unknown>
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(record)) {
if (key === '$ref' && typeof value === 'string') {
if (refMap.has(value)) {
result[key] = refMap.get(value)
} else if (value === '#' && selfRef) {
result[key] = selfRef
} else {
result[key] = value
}
} else {
result[key] = rewriteRefs(value, refMap, selfRef)
}
}
return result
}
/**
* Hoists nested `$defs` from each root-level definition up to the root `$defs`.
*
* When a definition contains its own `$defs` (originally `definitions` in draft-07,
* e.g. the json-schema meta-schema or avro schema), those nested defs are moved to
* the root with a `parentName-childName` prefix. All internal `#/$defs/X` refs
* within the parent and its nested defs are rewritten to `#/$defs/parentName-X`.
* Bare `$ref: "#"` within nested defs is rewritten to `#/$defs/parentName`.
*/
const hoistNestedDefs = (defs: Record<string, unknown>): Record<string, unknown> => {
const hoisted: Record<string, unknown> = {}
for (const [parentName, parentSchema] of Object.entries(defs)) {
if (typeof parentSchema !== 'object' || parentSchema === null) {
hoisted[parentName] = parentSchema
continue
}
const parentObj = parentSchema as Record<string, unknown>
const nestedDefs = parentObj['$defs'] as Record<string, unknown> | undefined
if (!nestedDefs || typeof nestedDefs !== 'object') {
hoisted[parentName] = parentSchema
continue
}
// Derive a short kebab-case prefix from the parent name (which may be a URI)
const parentPrefix =
parentName.startsWith('http://') || parentName.startsWith('https://') ? refToFilename(parentName) : parentName
// Build a map from local ref → hoisted ref for every nested def
const localToHoisted = new Map<string, string>()
for (const localName of Object.keys(nestedDefs)) {
const hoistedName = `${parentPrefix}-${toKebabCase(localName)}`
localToHoisted.set(`#/$defs/${localName}`, `#/$defs/${hoistedName}`)
}
const selfRef = `#/$defs/${parentPrefix}`
// Rewrite refs in the parent. Keep the nested $defs in place so that
// URI-with-fragment refs (e.g. "http://foo.json#/$defs/queue") can still
// navigate into the parent's nested defs after resolution.
const rewrittenParent = rewriteRefs(parentObj, localToHoisted, selfRef) as Record<string, unknown>
hoisted[parentName] = rewrittenParent
// Hoist each nested def, rewriting its internal refs too
for (const [localName, localSchema] of Object.entries(nestedDefs)) {
const hoistedName = `${parentPrefix}-${toKebabCase(localName)}`
hoisted[hoistedName] = rewriteRefs(localSchema, localToHoisted, selfRef)
}
}
return hoisted
}
/**
* Upgrades a draft-07 schema so it is compatible with the build pipeline.
* If the schema is not draft-07, it is returned unchanged.
*
* @param schema - The raw JSON Schema (any draft)
* @returns The schema with `definitions` renamed to `$defs` at the root,
* nested defs hoisted to the root, and internal refs rewritten
*/
export const upgradeDraft07Schema = (schema: Record<string, unknown>): Record<string, unknown> => {
if (!isDraft07Schema(schema)) return schema
// Rename root-level `definitions` to `$defs` (keep all other keys as-is)
const { definitions, $schema: _, ...rest } = schema
const rawDefs = (definitions ?? {}) as Record<string, unknown>
// Recursively rename `definitions` → `$defs` inside each definition's body
// so nested defs are accessible before hoisting
const renamedDefs: Record<string, unknown> = {}
for (const [key, value] of Object.entries(rawDefs)) {
renamedDefs[key] = renameNestedDefs(value)
}
// Hoist nested $defs up to root so the pipeline can resolve all refs flatly
const hoistedDefs = hoistNestedDefs(renamedDefs)
// Add short-name aliases for URI-keyed definitions so that internal refs
// like `#/$defs/draft-07-schema` (produced by self-ref rewriting in hoistNestedDefs)
// resolve correctly alongside the original URI key lookups.
for (const key of Object.keys(hoistedDefs)) {
if (key.startsWith('http://') || key.startsWith('https://')) {
const shortName = refToFilename(key)
if (shortName && !(shortName in hoistedDefs)) {
hoistedDefs[shortName] = hoistedDefs[key]
}
}
}
return {
...rest,
$defs: hoistedDefs,
}
}
/**
* Recursively renames `definitions` → `$defs` within a schema value and
* rewrites `$ref: "#/definitions/X"` to `$ref: "#/$defs/X"` so that
* `hoistNestedDefs` can map them to their hoisted root-level equivalents.
* Does NOT hoist — hoisting is done separately at the root level.
*/
const renameNestedDefs = (obj: unknown): unknown => {
if (typeof obj !== 'object' || obj === null) return obj
if (Array.isArray(obj)) return obj.map(renameNestedDefs)
const record = obj as Record<string, unknown>
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(record)) {
if (key === '$ref' && typeof value === 'string' && value.startsWith('#/definitions/')) {
result[key] = value.replace('#/definitions/', '#/$defs/')
} else {
const outKey = key === 'definitions' ? '$defs' : key
result[outKey] = renameNestedDefs(value)
}
}
return result
}
import { describe, expect, it } from 'vitest'
import { validateArray } from './validate-array'
describe('validate-array', () => {
it('validates and transforms array items with parser function', () => {
const parser = (item: unknown) => (typeof item === 'number' ? item * 2 : 0)
const result = validateArray([1, 2, 3], parser)
expect(result).toEqual([2, 4, 6])
})
it('returns empty array when input is not an array', () => {
const parser = (item: unknown) => item
expect(validateArray(null, parser)).toEqual([])
expect(validateArray(undefined, parser)).toEqual([])
expect(validateArray('string', parser)).toEqual([])
expect(validateArray(123, parser)).toEqual([])
expect(validateArray({}, parser)).toEqual([])
})
it('handles empty array input', () => {
const parser = (item: unknown) => item
const result = validateArray([], parser)
expect(result).toEqual([])
})
it('applies parser to each item independently', () => {
const parser = (item: unknown) => {
if (typeof item === 'string') return item.toUpperCase()
if (typeof item === 'number') return item.toString()
return 'unknown'
}
const result = validateArray(['hello', 42, true, null], parser)
expect(result).toEqual(['HELLO', '42', 'unknown', 'unknown'])
})
it('propagates parser errors for invalid items', () => {
// Parser that throws on invalid input
const strictParser = (item: unknown) => {
if (typeof item !== 'number') {
throw new Error(`Expected number, got ${typeof item}`)
}
return item * 2
}
// Valid array passes through
expect(validateArray([1, 2, 3], strictParser)).toEqual([2, 4, 6])
// Invalid item causes parser to throw
expect(() => validateArray([1, 'invalid', 3], strictParser)).toThrow('Expected number, got string')
})
})
/** Parses the items of an array with a parser function */
export const validateArray = (input: unknown, parser: (input: unknown) => unknown) => {
if (!Array.isArray(input)) {
return []
}
// Pre-allocate the result array for better performance than push()
const len = input.length
const result = new Array(len)
for (let i = 0; i < len; i++) {
result[i] = parser(input[i])
}
return result
}
import { describe, expect, it } from 'vitest'
import { validateRecord } from './validate-record'
describe('validate-record', () => {
it('validates all values in a record using the parser function', () => {
const input = { a: '1', b: '2', c: '3' }
const parser = (value: unknown) => Number(value)
const result = validateRecord(input, parser)
expect(result).toEqual({ a: 1, b: 2, c: 3 })
})
it('returns empty object when input is not a plain object', () => {
const parser = (value: unknown) => value
expect(validateRecord(null, parser)).toEqual({})
expect(validateRecord(undefined, parser)).toEqual({})
expect(validateRecord(123, parser)).toEqual({})
expect(validateRecord('string', parser)).toEqual({})
expect(validateRecord([], parser)).toEqual({})
expect(validateRecord(new Date(), parser)).toEqual({})
})
it('handles empty objects correctly', () => {
const parser = (value: unknown) => value
const result = validateRecord({}, parser)
expect(result).toEqual({})
})
it('preserves keys and applies parser to nested objects as values', () => {
const input = { user: { name: 'John' }, count: { value: 42 } }
const parser = (value: unknown) => JSON.stringify(value)
const result = validateRecord(input, parser)
expect(result).toEqual({
user: '{"name":"John"}',
count: '{"value":42}',
})
})
it('applies parser to all values including null and undefined', () => {
const input = { a: null, b: undefined, c: 0, d: false, e: '' }
const parser = (value: unknown) => (value === null || value === undefined ? 'missing' : value)
const result = validateRecord(input, parser)
expect(result).toEqual({
a: 'missing',
b: 'missing',
c: 0,
d: false,
e: '',
})
})
})
import { isObject } from './is-object'
/**
* Parses the values of a record with a parser function.
* Uses for...in instead of Object.entries() to avoid allocating an intermediate array.
*/
export const validateRecord = (input: unknown, parser: (input: unknown) => unknown) => {
if (!isObject(input)) {
return {}
}
const record = input as Record<string, unknown>
const result: Record<string, unknown> = {}
for (const key in record) {
result[key] = parser(record[key])
}
return result
}
import { describe, expect, it, vi } from 'vitest'
import { type RefNode, walkRefGraph } from './walk-ref-graph'
/** Collects every visited node so a test can assert what the walker produced. */
const collect = (rootSchema: Parameters<typeof walkRefGraph>[0], rootTypeName: string, typeSuffix = ''): RefNode[] => {
const nodes: RefNode[] = []
walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => nodes.push(node))
return nodes
}
describe('walk-ref-graph', () => {
it('visits the root first, then each referenced definition', () => {
const schema = {
type: 'object',
properties: { contact: { $ref: '#/$defs/contact' } },
$defs: { contact: { type: 'object', properties: { email: { type: 'string' } } } },
}
const nodes = collect(schema, 'Document')
expect(nodes.map((n) => ({ ref: n.ref, typeName: n.typeName, filename: n.filename, isRoot: n.isRoot }))).toEqual([
{ ref: undefined, typeName: 'Document', filename: 'document', isRoot: true },
{ ref: '#/$defs/contact', typeName: 'Contact', filename: 'contact', isRoot: false },
])
})
it('follows nested refs breadth-first and resolves each schema', () => {
const schema = {
properties: { user: { $ref: '#/$defs/user' } },
$defs: {
user: { type: 'object', properties: { address: { $ref: '#/$defs/address' } } },
address: { type: 'object', properties: { city: { type: 'string' } } },
},
}
const nodes = collect(schema, 'Document')
expect(nodes.map((n) => n.filename)).toEqual(['document', 'user', 'address'])
expect(nodes[2]?.schema).toEqual({ type: 'object', properties: { city: { type: 'string' } } })
})
it('applies the type suffix to ref-derived names but not the root', () => {
const schema = { properties: { contact: { $ref: '#/$defs/contact' } }, $defs: { contact: { type: 'object' } } }
const nodes = collect(schema, 'Document', 'Object')
expect(nodes.map((n) => n.typeName)).toEqual(['Document', 'ContactObject'])
})
it('does not visit two refs that map to the same filename twice', () => {
// A URI key and its short-name alias both resolve to the same definition and
// share a filename, so only one file should be emitted.
const schema = {
allOf: [{ $ref: 'http://example.com/channel.json' }, { $ref: '#/$defs/channel' }],
$defs: {
'http://example.com/channel.json': { type: 'object' },
channel: { type: 'object' },
},
}
const filenames = collect(schema, 'Document').map((n) => n.filename)
expect(filenames.filter((f) => f === 'channel')).toHaveLength(1)
})
it('seeds $dynamicAnchor definitions and rewrites $dynamicRef to $ref', () => {
const schema = {
type: 'object',
properties: { payload: { $dynamicRef: '#meta' } },
$defs: { schema: { $dynamicAnchor: 'meta', type: 'object' } },
}
const nodes = collect(schema, 'Document')
// The dynamic-anchor target gets its own file even though no plain $ref points at it...
expect(nodes.map((n) => n.filename)).toContain('schema')
// ...and the $dynamicRef on the root is rewritten to a concrete $ref.
const root = nodes.find((n) => n.isRoot)
expect(root?.schema).toMatchObject({ properties: { payload: { $ref: '#/$defs/schema' } } })
})
it('warns and skips a ref that cannot be resolved', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const schema = { properties: { missing: { $ref: '#/$defs/nope' } }, $defs: {} }
const nodes = collect(schema, 'Document')
expect(nodes.map((n) => n.filename)).toEqual(['document'])
expect(warn).toHaveBeenCalledWith('Warning: Could not resolve ref: #/$defs/nope')
warn.mockRestore()
})
it('handles circular refs (A → B → A) without infinite looping', () => {
const schema = {
properties: { root: { $ref: '#/$defs/a' } },
$defs: {
a: { type: 'object', properties: { b: { $ref: '#/$defs/b' } } },
b: { type: 'object', properties: { a: { $ref: '#/$defs/a' } } },
},
}
const nodes = collect(schema, 'Document')
// Both defs are visited exactly once each despite the cycle.
expect(nodes.map((n) => n.filename)).toEqual(['document', 'a', 'b'])
})
it('reuses cached resolution across repeated walks of the same schema object', () => {
// The second walk should produce the same nodes from the per-root cache,
// proving the cache does not corrupt or drop results on reuse.
const schema = { properties: { contact: { $ref: '#/$defs/contact' } }, $defs: { contact: { type: 'object' } } }
const first = collect(schema, 'Document').map((n) => n.filename)
const second = collect(schema, 'Document').map((n) => n.filename)
expect(second).toEqual(first)
})
})
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
import { buildDynamicRefMap } from './build-dynamic-ref-map'
import { extractDynamicAnchorDefs } from './extract-dynamic-anchor-defs'
import { extractRefs } from './extract-refs'
import { refToFilename } from './ref-to-filename'
import { refToName } from './ref-to-name'
import { resolveDynamicRefs } from './resolve-dynamic-refs'
import { resolveRef } from './resolve-ref'
import { upgradeDraft07Schema } from './upgrade-draft07-schema'
/**
* One node of the `$ref` graph, handed to the `visit` callback. Everything a
* generator needs to emit a single output file is pre-computed here so the
* traversal, naming, and `$dynamicRef` rewriting live in one place instead of
* being copy-pasted into every generator.
*/
export type RefNode = {
/** The `$ref` string this node was reached through, or `undefined` for the root schema. */
ref: string | undefined
/** PascalCase type name — the root type name verbatim, or `refToName(ref, typeSuffix)`. */
typeName: string
/** kebab-case filename without extension — the lowercased root type name, or `refToFilename(ref)`. */
filename: string
/** The subschema to generate, with any `$dynamicRef` already rewritten to `$ref`. */
schema: JSONSchema
/** The upgraded root document, for callers that resolve imports against it. */
rootSchema: Record<string, unknown>
/** True for the root schema node, which is always visited first. */
isRoot: boolean
}
/** Options controlling how `$ref`-derived names are produced. */
export type WalkRefGraphOptions = {
/**
* Suffix appended to every `$ref`-derived type name (e.g. `'Object'` →
* `ContactObject`). The root type name is used verbatim and is not affected.
* Defaults to `''`.
*/
readonly typeSuffix?: string
}
/**
* The reusable, schema-scoped work the walker memoizes. Keyed by the *original*
* root schema object so repeated walks of the same document — the parsers,
* validators, and examples generators all running over one loaded schema —
* pay for the draft-07 upgrade, the dynamic-ref map, and each `resolveRef` /
* `extractRefs` exactly once. JSON Schema inputs are treated as immutable here;
* the `WeakMap` drops the entry once the caller releases the schema.
*/
type RootCache = {
upgraded: Record<string, unknown>
dynamicRefMap: Record<string, string>
resolveRefCache: Map<string, Record<string, unknown> | undefined>
extractRefsCache: WeakMap<object, Set<string>>
}
const rootCaches = new WeakMap<object, RootCache>()
const getRootCache = (rootSchema: JSONSchema): RootCache => {
// Only object roots can key a WeakMap. A boolean root has no refs to walk and
// the draft-07 upgrade is a no-op for it, so a throwaway cache is fine.
if (typeof rootSchema !== 'object' || rootSchema === null) {
const upgraded = rootSchema as unknown as Record<string, unknown>
return { upgraded, dynamicRefMap: {}, resolveRefCache: new Map(), extractRefsCache: new WeakMap() }
}
const existing = rootCaches.get(rootSchema)
if (existing) return existing
const upgraded = upgradeDraft07Schema(rootSchema as Record<string, unknown>)
const cache: RootCache = {
upgraded,
dynamicRefMap: buildDynamicRefMap(upgraded as JSONSchema),
resolveRefCache: new Map(),
extractRefsCache: new WeakMap(),
}
rootCaches.set(rootSchema, cache)
return cache
}
/** Memoized `resolveRef` keyed by ref string within a single root document. */
const cachedResolveRef = (cache: RootCache, ref: string): Record<string, unknown> | undefined => {
if (cache.resolveRefCache.has(ref)) return cache.resolveRefCache.get(ref)
const resolved = resolveRef(ref, cache.upgraded)
cache.resolveRefCache.set(ref, resolved)
return resolved
}
/** Memoized `extractRefs` keyed by the (stable) resolved subschema identity. */
const cachedExtractRefs = (cache: RootCache, schema: JSONSchema): Set<string> => {
if (typeof schema !== 'object' || schema === null) return extractRefs(schema)
const existing = cache.extractRefsCache.get(schema)
if (existing) return existing
const refs = extractRefs(schema)
cache.extractRefsCache.set(schema, refs)
return refs
}
/**
* Walks a JSON Schema and its entire `$ref` / `$dynamicRef` graph, invoking
* `visit` once per distinct output file: first the root, then every reachable
* definition (breadth-first). This is the single, shared traversal the parser,
* validator, and example generators were each re-implementing.
*
* For every node the walker has already upgraded draft-07 inputs, resolved the
* ref, rewritten `$dynamicRef` to `$ref`, and derived the type/file names — so
* callers only have to turn `node.schema` into file content. Definitions
* reachable only via `$dynamicAnchor` are seeded too, so nothing the generated
* code imports goes ungenerated. A ref that fails to resolve is reported via
* `console.warn` and skipped, matching the generators' prior behavior.
*
* Resolution work is memoized per root document (see {@link RootCache}), so
* running several generators over the same loaded schema does the expensive
* walking once.
*
* @param rootSchema - The root JSON Schema to walk.
* @param rootTypeName - The name for the root type (e.g. `'Document'`).
* @param options - Naming options ({@link WalkRefGraphOptions}).
* @param visit - Called once per output file with a fully prepared {@link RefNode}.
*/
export const walkRefGraph = (
rootSchema: JSONSchema,
rootTypeName: string,
options: WalkRefGraphOptions,
visit: (node: RefNode) => void,
): void => {
const typeSuffix = options.typeSuffix ?? ''
const cache = getRootCache(rootSchema)
const { upgraded, dynamicRefMap } = cache
const processedRefs = new Set<string>()
const processedFilenames = new Set<string>()
// Root node first — its filename reserves a slot so a later ref that maps to
// the same name does not emit a duplicate file.
const rootFilename = rootTypeName.toLowerCase()
processedFilenames.add(rootFilename)
visit({
ref: undefined,
typeName: rootTypeName,
filename: rootFilename,
schema: resolveDynamicRefs(upgraded as JSONSchema, dynamicRefMap),
rootSchema: upgraded,
isRoot: true,
})
const queue: string[] = [
...cachedExtractRefs(cache, upgraded as JSONSchema),
...extractDynamicAnchorDefs(upgraded as JSONSchema),
]
while (queue.length > 0) {
const ref = queue.shift()
if (!ref || processedRefs.has(ref)) continue
processedRefs.add(ref)
const resolved = cachedResolveRef(cache, ref)
if (!resolved) {
console.warn(`Warning: Could not resolve ref: ${ref}`)
continue
}
const filename = refToFilename(ref)
if (!processedFilenames.has(filename)) {
processedFilenames.add(filename)
visit({
ref,
typeName: refToName(ref, typeSuffix),
filename,
schema: resolveDynamicRefs(resolved as JSONSchema, dynamicRefMap),
rootSchema: upgraded,
isRoot: false,
})
}
// Always queue nested refs from the resolved schema, even when its file was
// a duplicate: two ref strings can share a filename yet reach different
// sub-definitions (e.g. a URI key and its short-name alias).
for (const nested of cachedExtractRefs(cache, resolved as JSONSchema)) {
if (!processedRefs.has(nested)) queue.push(nested)
}
}
}