from-json-schema.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. import { globalRegistry } from "../core/registries.js";
  2. import * as _checks from "./checks.js";
  3. import * as _iso from "./iso.js";
  4. import * as _schemas from "./schemas.js";
  5. // Local z object to avoid circular dependency with ../index.js
  6. const z = {
  7. ..._schemas,
  8. ..._checks,
  9. iso: _iso,
  10. };
  11. // Keys that are recognized and handled by the conversion logic
  12. const RECOGNIZED_KEYS = new Set([
  13. // Schema identification
  14. "$schema",
  15. "$ref",
  16. "$defs",
  17. "definitions",
  18. // Core schema keywords
  19. "$id",
  20. "id",
  21. "$comment",
  22. "$anchor",
  23. "$vocabulary",
  24. "$dynamicRef",
  25. "$dynamicAnchor",
  26. // Type
  27. "type",
  28. "enum",
  29. "const",
  30. // Composition
  31. "anyOf",
  32. "oneOf",
  33. "allOf",
  34. "not",
  35. // Object
  36. "properties",
  37. "required",
  38. "additionalProperties",
  39. "patternProperties",
  40. "propertyNames",
  41. "minProperties",
  42. "maxProperties",
  43. // Array
  44. "items",
  45. "prefixItems",
  46. "additionalItems",
  47. "minItems",
  48. "maxItems",
  49. "uniqueItems",
  50. "contains",
  51. "minContains",
  52. "maxContains",
  53. // String
  54. "minLength",
  55. "maxLength",
  56. "pattern",
  57. "format",
  58. // Number
  59. "minimum",
  60. "maximum",
  61. "exclusiveMinimum",
  62. "exclusiveMaximum",
  63. "multipleOf",
  64. // Already handled metadata
  65. "description",
  66. "default",
  67. // Content
  68. "contentEncoding",
  69. "contentMediaType",
  70. "contentSchema",
  71. // Unsupported (error-throwing)
  72. "unevaluatedItems",
  73. "unevaluatedProperties",
  74. "if",
  75. "then",
  76. "else",
  77. "dependentSchemas",
  78. "dependentRequired",
  79. // OpenAPI
  80. "nullable",
  81. "readOnly",
  82. ]);
  83. function detectVersion(schema, defaultTarget) {
  84. const $schema = schema.$schema;
  85. if ($schema === "https://json-schema.org/draft/2020-12/schema") {
  86. return "draft-2020-12";
  87. }
  88. if ($schema === "http://json-schema.org/draft-07/schema#") {
  89. return "draft-7";
  90. }
  91. if ($schema === "http://json-schema.org/draft-04/schema#") {
  92. return "draft-4";
  93. }
  94. // Use defaultTarget if provided, otherwise default to draft-2020-12
  95. return defaultTarget ?? "draft-2020-12";
  96. }
  97. function resolveRef(ref, ctx) {
  98. if (!ref.startsWith("#")) {
  99. throw new Error("External $ref is not supported, only local refs (#/...) are allowed");
  100. }
  101. const path = ref.slice(1).split("/").filter(Boolean);
  102. // Handle root reference "#"
  103. if (path.length === 0) {
  104. return ctx.rootSchema;
  105. }
  106. const defsKey = ctx.version === "draft-2020-12" ? "$defs" : "definitions";
  107. if (path[0] === defsKey) {
  108. const key = path[1];
  109. if (!key || !ctx.defs[key]) {
  110. throw new Error(`Reference not found: ${ref}`);
  111. }
  112. return ctx.defs[key];
  113. }
  114. throw new Error(`Reference not found: ${ref}`);
  115. }
  116. function convertBaseSchema(schema, ctx) {
  117. // Handle unsupported features
  118. if (schema.not !== undefined) {
  119. // Special case: { not: {} } represents never
  120. if (typeof schema.not === "object" && Object.keys(schema.not).length === 0) {
  121. return z.never();
  122. }
  123. throw new Error("not is not supported in Zod (except { not: {} } for never)");
  124. }
  125. if (schema.unevaluatedItems !== undefined) {
  126. throw new Error("unevaluatedItems is not supported");
  127. }
  128. if (schema.unevaluatedProperties !== undefined) {
  129. throw new Error("unevaluatedProperties is not supported");
  130. }
  131. if (schema.if !== undefined || schema.then !== undefined || schema.else !== undefined) {
  132. throw new Error("Conditional schemas (if/then/else) are not supported");
  133. }
  134. if (schema.dependentSchemas !== undefined || schema.dependentRequired !== undefined) {
  135. throw new Error("dependentSchemas and dependentRequired are not supported");
  136. }
  137. // Handle $ref
  138. if (schema.$ref) {
  139. const refPath = schema.$ref;
  140. if (ctx.refs.has(refPath)) {
  141. return ctx.refs.get(refPath);
  142. }
  143. if (ctx.processing.has(refPath)) {
  144. // Circular reference - use lazy
  145. return z.lazy(() => {
  146. if (!ctx.refs.has(refPath)) {
  147. throw new Error(`Circular reference not resolved: ${refPath}`);
  148. }
  149. return ctx.refs.get(refPath);
  150. });
  151. }
  152. ctx.processing.add(refPath);
  153. const resolved = resolveRef(refPath, ctx);
  154. const zodSchema = convertSchema(resolved, ctx);
  155. ctx.refs.set(refPath, zodSchema);
  156. ctx.processing.delete(refPath);
  157. return zodSchema;
  158. }
  159. // Handle enum
  160. if (schema.enum !== undefined) {
  161. const enumValues = schema.enum;
  162. // Special case: OpenAPI 3.0 null representation { type: "string", nullable: true, enum: [null] }
  163. if (ctx.version === "openapi-3.0" &&
  164. schema.nullable === true &&
  165. enumValues.length === 1 &&
  166. enumValues[0] === null) {
  167. return z.null();
  168. }
  169. if (enumValues.length === 0) {
  170. return z.never();
  171. }
  172. if (enumValues.length === 1) {
  173. return z.literal(enumValues[0]);
  174. }
  175. // Check if all values are strings
  176. if (enumValues.every((v) => typeof v === "string")) {
  177. return z.enum(enumValues);
  178. }
  179. // Mixed types - use union of literals
  180. const literalSchemas = enumValues.map((v) => z.literal(v));
  181. if (literalSchemas.length < 2) {
  182. return literalSchemas[0];
  183. }
  184. return z.union([literalSchemas[0], literalSchemas[1], ...literalSchemas.slice(2)]);
  185. }
  186. // Handle const
  187. if (schema.const !== undefined) {
  188. return z.literal(schema.const);
  189. }
  190. // Handle type
  191. const type = schema.type;
  192. if (Array.isArray(type)) {
  193. // Expand type array into anyOf union
  194. const typeSchemas = type.map((t) => {
  195. const typeSchema = { ...schema, type: t };
  196. return convertBaseSchema(typeSchema, ctx);
  197. });
  198. if (typeSchemas.length === 0) {
  199. return z.never();
  200. }
  201. if (typeSchemas.length === 1) {
  202. return typeSchemas[0];
  203. }
  204. return z.union(typeSchemas);
  205. }
  206. if (!type) {
  207. // No type specified - empty schema (any)
  208. return z.any();
  209. }
  210. let zodSchema;
  211. switch (type) {
  212. case "string": {
  213. let stringSchema = z.string();
  214. // Apply format using .check() with Zod format functions
  215. if (schema.format) {
  216. const format = schema.format;
  217. // Map common formats to Zod check functions
  218. if (format === "email") {
  219. stringSchema = stringSchema.check(z.email());
  220. }
  221. else if (format === "uri" || format === "uri-reference") {
  222. stringSchema = stringSchema.check(z.url());
  223. }
  224. else if (format === "uuid" || format === "guid") {
  225. stringSchema = stringSchema.check(z.uuid());
  226. }
  227. else if (format === "date-time") {
  228. stringSchema = stringSchema.check(z.iso.datetime());
  229. }
  230. else if (format === "date") {
  231. stringSchema = stringSchema.check(z.iso.date());
  232. }
  233. else if (format === "time") {
  234. stringSchema = stringSchema.check(z.iso.time());
  235. }
  236. else if (format === "duration") {
  237. stringSchema = stringSchema.check(z.iso.duration());
  238. }
  239. else if (format === "ipv4") {
  240. stringSchema = stringSchema.check(z.ipv4());
  241. }
  242. else if (format === "ipv6") {
  243. stringSchema = stringSchema.check(z.ipv6());
  244. }
  245. else if (format === "mac") {
  246. stringSchema = stringSchema.check(z.mac());
  247. }
  248. else if (format === "cidr") {
  249. stringSchema = stringSchema.check(z.cidrv4());
  250. }
  251. else if (format === "cidr-v6") {
  252. stringSchema = stringSchema.check(z.cidrv6());
  253. }
  254. else if (format === "base64") {
  255. stringSchema = stringSchema.check(z.base64());
  256. }
  257. else if (format === "base64url") {
  258. stringSchema = stringSchema.check(z.base64url());
  259. }
  260. else if (format === "e164") {
  261. stringSchema = stringSchema.check(z.e164());
  262. }
  263. else if (format === "jwt") {
  264. stringSchema = stringSchema.check(z.jwt());
  265. }
  266. else if (format === "emoji") {
  267. stringSchema = stringSchema.check(z.emoji());
  268. }
  269. else if (format === "nanoid") {
  270. stringSchema = stringSchema.check(z.nanoid());
  271. }
  272. else if (format === "cuid") {
  273. stringSchema = stringSchema.check(z.cuid());
  274. }
  275. else if (format === "cuid2") {
  276. stringSchema = stringSchema.check(z.cuid2());
  277. }
  278. else if (format === "ulid") {
  279. stringSchema = stringSchema.check(z.ulid());
  280. }
  281. else if (format === "xid") {
  282. stringSchema = stringSchema.check(z.xid());
  283. }
  284. else if (format === "ksuid") {
  285. stringSchema = stringSchema.check(z.ksuid());
  286. }
  287. // Note: json-string format is not currently supported by Zod
  288. // Custom formats are ignored - keep as plain string
  289. }
  290. // Apply constraints
  291. if (typeof schema.minLength === "number") {
  292. stringSchema = stringSchema.min(schema.minLength);
  293. }
  294. if (typeof schema.maxLength === "number") {
  295. stringSchema = stringSchema.max(schema.maxLength);
  296. }
  297. if (schema.pattern) {
  298. // JSON Schema patterns are not implicitly anchored (match anywhere in string)
  299. stringSchema = stringSchema.regex(new RegExp(schema.pattern));
  300. }
  301. zodSchema = stringSchema;
  302. break;
  303. }
  304. case "number":
  305. case "integer": {
  306. let numberSchema = type === "integer" ? z.number().int() : z.number();
  307. // Apply constraints
  308. if (typeof schema.minimum === "number") {
  309. numberSchema = numberSchema.min(schema.minimum);
  310. }
  311. if (typeof schema.maximum === "number") {
  312. numberSchema = numberSchema.max(schema.maximum);
  313. }
  314. if (typeof schema.exclusiveMinimum === "number") {
  315. numberSchema = numberSchema.gt(schema.exclusiveMinimum);
  316. }
  317. else if (schema.exclusiveMinimum === true && typeof schema.minimum === "number") {
  318. numberSchema = numberSchema.gt(schema.minimum);
  319. }
  320. if (typeof schema.exclusiveMaximum === "number") {
  321. numberSchema = numberSchema.lt(schema.exclusiveMaximum);
  322. }
  323. else if (schema.exclusiveMaximum === true && typeof schema.maximum === "number") {
  324. numberSchema = numberSchema.lt(schema.maximum);
  325. }
  326. if (typeof schema.multipleOf === "number") {
  327. numberSchema = numberSchema.multipleOf(schema.multipleOf);
  328. }
  329. zodSchema = numberSchema;
  330. break;
  331. }
  332. case "boolean": {
  333. zodSchema = z.boolean();
  334. break;
  335. }
  336. case "null": {
  337. zodSchema = z.null();
  338. break;
  339. }
  340. case "object": {
  341. const shape = {};
  342. const properties = schema.properties || {};
  343. const requiredSet = new Set(schema.required || []);
  344. // Convert properties - mark optional ones
  345. for (const [key, propSchema] of Object.entries(properties)) {
  346. const propZodSchema = convertSchema(propSchema, ctx);
  347. // If not in required array, make it optional
  348. shape[key] = requiredSet.has(key) ? propZodSchema : propZodSchema.optional();
  349. }
  350. // Handle propertyNames
  351. if (schema.propertyNames) {
  352. const keySchema = convertSchema(schema.propertyNames, ctx);
  353. const valueSchema = schema.additionalProperties && typeof schema.additionalProperties === "object"
  354. ? convertSchema(schema.additionalProperties, ctx)
  355. : z.any();
  356. // Case A: No properties (pure record)
  357. if (Object.keys(shape).length === 0) {
  358. zodSchema = z.record(keySchema, valueSchema);
  359. break;
  360. }
  361. // Case B: With properties (intersection of object and looseRecord)
  362. const objectSchema = z.object(shape).passthrough();
  363. const recordSchema = z.looseRecord(keySchema, valueSchema);
  364. zodSchema = z.intersection(objectSchema, recordSchema);
  365. break;
  366. }
  367. // Handle patternProperties
  368. if (schema.patternProperties) {
  369. // patternProperties: keys matching pattern must satisfy corresponding schema
  370. // Use loose records so non-matching keys pass through
  371. const patternProps = schema.patternProperties;
  372. const patternKeys = Object.keys(patternProps);
  373. const looseRecords = [];
  374. for (const pattern of patternKeys) {
  375. const patternValue = convertSchema(patternProps[pattern], ctx);
  376. const keySchema = z.string().regex(new RegExp(pattern));
  377. looseRecords.push(z.looseRecord(keySchema, patternValue));
  378. }
  379. // Build intersection: object schema + all pattern property records
  380. const schemasToIntersect = [];
  381. if (Object.keys(shape).length > 0) {
  382. // Use passthrough so patternProperties can validate additional keys
  383. schemasToIntersect.push(z.object(shape).passthrough());
  384. }
  385. schemasToIntersect.push(...looseRecords);
  386. if (schemasToIntersect.length === 0) {
  387. zodSchema = z.object({}).passthrough();
  388. }
  389. else if (schemasToIntersect.length === 1) {
  390. zodSchema = schemasToIntersect[0];
  391. }
  392. else {
  393. // Chain intersections: (A & B) & C & D ...
  394. let result = z.intersection(schemasToIntersect[0], schemasToIntersect[1]);
  395. for (let i = 2; i < schemasToIntersect.length; i++) {
  396. result = z.intersection(result, schemasToIntersect[i]);
  397. }
  398. zodSchema = result;
  399. }
  400. break;
  401. }
  402. // Handle additionalProperties
  403. // In JSON Schema, additionalProperties defaults to true (allow any extra properties)
  404. // In Zod, objects strip unknown keys by default, so we need to handle this explicitly
  405. const objectSchema = z.object(shape);
  406. if (schema.additionalProperties === false) {
  407. // Strict mode - no extra properties allowed
  408. zodSchema = objectSchema.strict();
  409. }
  410. else if (typeof schema.additionalProperties === "object") {
  411. // Extra properties must match the specified schema
  412. zodSchema = objectSchema.catchall(convertSchema(schema.additionalProperties, ctx));
  413. }
  414. else {
  415. // additionalProperties is true or undefined - allow any extra properties (passthrough)
  416. zodSchema = objectSchema.passthrough();
  417. }
  418. break;
  419. }
  420. case "array": {
  421. // TODO: uniqueItems is not supported
  422. // TODO: contains/minContains/maxContains are not supported
  423. // Check if this is a tuple (prefixItems or items as array)
  424. const prefixItems = schema.prefixItems;
  425. const items = schema.items;
  426. if (prefixItems && Array.isArray(prefixItems)) {
  427. // Tuple with prefixItems (draft-2020-12)
  428. const tupleItems = prefixItems.map((item) => convertSchema(item, ctx));
  429. const rest = items && typeof items === "object" && !Array.isArray(items)
  430. ? convertSchema(items, ctx)
  431. : undefined;
  432. if (rest) {
  433. zodSchema = z.tuple(tupleItems).rest(rest);
  434. }
  435. else {
  436. zodSchema = z.tuple(tupleItems);
  437. }
  438. // Apply minItems/maxItems constraints to tuples
  439. if (typeof schema.minItems === "number") {
  440. zodSchema = zodSchema.check(z.minLength(schema.minItems));
  441. }
  442. if (typeof schema.maxItems === "number") {
  443. zodSchema = zodSchema.check(z.maxLength(schema.maxItems));
  444. }
  445. }
  446. else if (Array.isArray(items)) {
  447. // Tuple with items array (draft-7)
  448. const tupleItems = items.map((item) => convertSchema(item, ctx));
  449. const rest = schema.additionalItems && typeof schema.additionalItems === "object"
  450. ? convertSchema(schema.additionalItems, ctx)
  451. : undefined; // additionalItems: false means no rest, handled by default tuple behavior
  452. if (rest) {
  453. zodSchema = z.tuple(tupleItems).rest(rest);
  454. }
  455. else {
  456. zodSchema = z.tuple(tupleItems);
  457. }
  458. // Apply minItems/maxItems constraints to tuples
  459. if (typeof schema.minItems === "number") {
  460. zodSchema = zodSchema.check(z.minLength(schema.minItems));
  461. }
  462. if (typeof schema.maxItems === "number") {
  463. zodSchema = zodSchema.check(z.maxLength(schema.maxItems));
  464. }
  465. }
  466. else if (items !== undefined) {
  467. // Regular array
  468. const element = convertSchema(items, ctx);
  469. let arraySchema = z.array(element);
  470. // Apply constraints
  471. if (typeof schema.minItems === "number") {
  472. arraySchema = arraySchema.min(schema.minItems);
  473. }
  474. if (typeof schema.maxItems === "number") {
  475. arraySchema = arraySchema.max(schema.maxItems);
  476. }
  477. zodSchema = arraySchema;
  478. }
  479. else {
  480. // No items specified - array of any
  481. zodSchema = z.array(z.any());
  482. }
  483. break;
  484. }
  485. default:
  486. throw new Error(`Unsupported type: ${type}`);
  487. }
  488. // Apply metadata
  489. if (schema.description) {
  490. zodSchema = zodSchema.describe(schema.description);
  491. }
  492. if (schema.default !== undefined) {
  493. zodSchema = zodSchema.default(schema.default);
  494. }
  495. return zodSchema;
  496. }
  497. function convertSchema(schema, ctx) {
  498. if (typeof schema === "boolean") {
  499. return schema ? z.any() : z.never();
  500. }
  501. // Convert base schema first (ignoring composition keywords)
  502. let baseSchema = convertBaseSchema(schema, ctx);
  503. const hasExplicitType = schema.type || schema.enum !== undefined || schema.const !== undefined;
  504. // Process composition keywords LAST (they can appear together)
  505. // Handle anyOf - wrap base schema with union
  506. if (schema.anyOf && Array.isArray(schema.anyOf)) {
  507. const options = schema.anyOf.map((s) => convertSchema(s, ctx));
  508. const anyOfUnion = z.union(options);
  509. baseSchema = hasExplicitType ? z.intersection(baseSchema, anyOfUnion) : anyOfUnion;
  510. }
  511. // Handle oneOf - exclusive union (exactly one must match)
  512. if (schema.oneOf && Array.isArray(schema.oneOf)) {
  513. const options = schema.oneOf.map((s) => convertSchema(s, ctx));
  514. const oneOfUnion = z.xor(options);
  515. baseSchema = hasExplicitType ? z.intersection(baseSchema, oneOfUnion) : oneOfUnion;
  516. }
  517. // Handle allOf - wrap base schema with intersection
  518. if (schema.allOf && Array.isArray(schema.allOf)) {
  519. if (schema.allOf.length === 0) {
  520. baseSchema = hasExplicitType ? baseSchema : z.any();
  521. }
  522. else {
  523. let result = hasExplicitType ? baseSchema : convertSchema(schema.allOf[0], ctx);
  524. const startIdx = hasExplicitType ? 0 : 1;
  525. for (let i = startIdx; i < schema.allOf.length; i++) {
  526. result = z.intersection(result, convertSchema(schema.allOf[i], ctx));
  527. }
  528. baseSchema = result;
  529. }
  530. }
  531. // Handle nullable (OpenAPI 3.0)
  532. if (schema.nullable === true && ctx.version === "openapi-3.0") {
  533. baseSchema = z.nullable(baseSchema);
  534. }
  535. // Handle readOnly
  536. if (schema.readOnly === true) {
  537. baseSchema = z.readonly(baseSchema);
  538. }
  539. // Collect metadata: core schema keywords and unrecognized keys
  540. const extraMeta = {};
  541. // Core schema keywords that should be captured as metadata
  542. const coreMetadataKeys = ["$id", "id", "$comment", "$anchor", "$vocabulary", "$dynamicRef", "$dynamicAnchor"];
  543. for (const key of coreMetadataKeys) {
  544. if (key in schema) {
  545. extraMeta[key] = schema[key];
  546. }
  547. }
  548. // Content keywords - store as metadata
  549. const contentMetadataKeys = ["contentEncoding", "contentMediaType", "contentSchema"];
  550. for (const key of contentMetadataKeys) {
  551. if (key in schema) {
  552. extraMeta[key] = schema[key];
  553. }
  554. }
  555. // Unrecognized keys (custom metadata)
  556. for (const key of Object.keys(schema)) {
  557. if (!RECOGNIZED_KEYS.has(key)) {
  558. extraMeta[key] = schema[key];
  559. }
  560. }
  561. if (Object.keys(extraMeta).length > 0) {
  562. ctx.registry.add(baseSchema, extraMeta);
  563. }
  564. return baseSchema;
  565. }
  566. /**
  567. * Converts a JSON Schema to a Zod schema. This function should be considered semi-experimental. It's behavior is liable to change. */
  568. export function fromJSONSchema(schema, params) {
  569. // Handle boolean schemas
  570. if (typeof schema === "boolean") {
  571. return schema ? z.any() : z.never();
  572. }
  573. const version = detectVersion(schema, params?.defaultTarget);
  574. const defs = (schema.$defs || schema.definitions || {});
  575. const ctx = {
  576. version,
  577. defs,
  578. refs: new Map(),
  579. processing: new Set(),
  580. rootSchema: schema,
  581. registry: params?.registry ?? globalRegistry,
  582. };
  583. return convertSchema(schema, ctx);
  584. }