@amritk/adapters
Advanced tools
@@ -23,2 +23,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'; | ||
| }; | ||
| //# sourceMappingURL=adapter.d.ts.map |
@@ -12,2 +12,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'; | ||
| export declare const effectToJsonSchema: (source: unknown) => Promise<JSONSchema>; | ||
| //# sourceMappingURL=effect-to-json-schema.d.ts.map |
@@ -12,2 +12,1 @@ import type { Adapter } from './adapter.js'; | ||
| export declare const getAdapter: (format: SourceFormat) => Adapter; | ||
| //# sourceMappingURL=get-adapter.d.ts.map |
@@ -12,2 +12,1 @@ /** | ||
| export type SourceFormat = 'json' | 'typebox' | 'zod' | 'valibot' | 'effect'; | ||
| //# sourceMappingURL=source-format.d.ts.map |
@@ -14,2 +14,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'; | ||
| export declare const typeboxToJsonSchema: (source: unknown) => JSONSchema; | ||
| //# sourceMappingURL=typebox-to-json-schema.d.ts.map |
@@ -14,2 +14,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'; | ||
| export declare const valibotToJsonSchema: (source: unknown) => Promise<JSONSchema>; | ||
| //# sourceMappingURL=valibot-to-json-schema.d.ts.map |
@@ -13,2 +13,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'; | ||
| export declare const zodToJsonSchema: (source: unknown) => Promise<JSONSchema>; | ||
| //# sourceMappingURL=zod-to-json-schema.d.ts.map |
+3
-11
| { | ||
| "name": "@amritk/adapters", | ||
| "version": "0.2.8", | ||
| "version": "0.2.9", | ||
| "description": "Convert schemas from external libraries (TypeBox, Zod, ...) into JSON Schema for mjst.", | ||
@@ -28,4 +28,3 @@ "type": "module", | ||
| "files": [ | ||
| "dist", | ||
| "src" | ||
| "dist" | ||
| ], | ||
@@ -42,3 +41,3 @@ "publishConfig": { | ||
| "json-schema-typed": "^8.0.1", | ||
| "@amritk/helpers": "0.8.0" | ||
| "@amritk/helpers": "0.9.0" | ||
| }, | ||
@@ -48,3 +47,2 @@ "exports": { | ||
| "./adapter": { | ||
| "development": "./src/adapter.ts", | ||
| "default": "./dist/adapter.js", | ||
@@ -54,3 +52,2 @@ "types": "./dist/adapter.d.ts" | ||
| "./source-format": { | ||
| "development": "./src/source-format.ts", | ||
| "default": "./dist/source-format.js", | ||
@@ -60,3 +57,2 @@ "types": "./dist/source-format.d.ts" | ||
| "./get-adapter": { | ||
| "development": "./src/get-adapter.ts", | ||
| "default": "./dist/get-adapter.js", | ||
@@ -66,3 +62,2 @@ "types": "./dist/get-adapter.d.ts" | ||
| "./typebox-to-json-schema": { | ||
| "development": "./src/typebox-to-json-schema.ts", | ||
| "default": "./dist/typebox-to-json-schema.js", | ||
@@ -72,3 +67,2 @@ "types": "./dist/typebox-to-json-schema.d.ts" | ||
| "./zod-to-json-schema": { | ||
| "development": "./src/zod-to-json-schema.ts", | ||
| "default": "./dist/zod-to-json-schema.js", | ||
@@ -78,3 +72,2 @@ "types": "./dist/zod-to-json-schema.d.ts" | ||
| "./valibot-to-json-schema": { | ||
| "development": "./src/valibot-to-json-schema.ts", | ||
| "default": "./dist/valibot-to-json-schema.js", | ||
@@ -84,3 +77,2 @@ "types": "./dist/valibot-to-json-schema.d.ts" | ||
| "./effect-to-json-schema": { | ||
| "development": "./src/effect-to-json-schema.ts", | ||
| "default": "./dist/effect-to-json-schema.js", | ||
@@ -87,0 +79,0 @@ "types": "./dist/effect-to-json-schema.d.ts" |
| {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAEjE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAEnD;;;;;;;;;GASG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAAA;CAC7E,CAAA"} |
| {"version":3,"file":"effect-to-json-schema.d.ts","sourceRoot":"","sources":["../src/effect-to-json-schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AA4BjE;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,WAAkB,OAAO,KAAG,OAAO,CAAC,UAAU,CAmB5E,CAAA"} |
| {"version":3,"file":"get-adapter.d.ts","sourceRoot":"","sources":["../src/get-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAyBnD;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,WAAY,YAAY,KAAG,OAgBjD,CAAA"} |
| {"version":3,"file":"source-format.d.ts","sourceRoot":"","sources":["../src/source-format.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,SAAS,GAAG,KAAK,GAAG,SAAS,GAAG,QAAQ,CAAA"} |
| {"version":3,"file":"typebox-to-json-schema.d.ts","sourceRoot":"","sources":["../src/typebox-to-json-schema.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAwDjE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,WAAY,OAAO,KAAG,UAUrD,CAAA"} |
| {"version":3,"file":"valibot-to-json-schema.d.ts","sourceRoot":"","sources":["../src/valibot-to-json-schema.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAuCjE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,WAAkB,OAAO,KAAG,OAAO,CAAC,UAAU,CA0B7E,CAAA"} |
| {"version":3,"file":"zod-to-json-schema.d.ts","sourceRoot":"","sources":["../src/zod-to-json-schema.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AA4CjE;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,WAAkB,OAAO,KAAG,OAAO,CAAC,UAAU,CA6CzE,CAAA"} |
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| import type { SourceFormat } from './source-format' | ||
| /** | ||
| * Converts a schema authored in some external library into a Draft 2020-12 | ||
| * JSON Schema, which is the single input shape the mjst generators understand. | ||
| * | ||
| * Adapters receive the already-loaded schema value (an imported module export), | ||
| * not a file path. Loading the module is the caller's job so adapters stay pure | ||
| * and trivial to unit test. When a source construct cannot be represented in | ||
| * JSON Schema, adapters should warn and continue on a best-effort basis rather | ||
| * than throw, so generation is not blocked by a single unsupported field. | ||
| */ | ||
| export type Adapter = { | ||
| /** The source format this adapter handles, matching the `--input` CLI flag. */ | ||
| readonly format: SourceFormat | ||
| /** | ||
| * Convert a loaded source schema value into a JSON Schema. May be async: | ||
| * some adapters (e.g. Zod) dynamically import their source library to perform | ||
| * the conversion, so callers should always `await` the result. | ||
| */ | ||
| readonly toJSONSchema: (source: unknown) => JSONSchema | Promise<JSONSchema> | ||
| } |
| import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition' | ||
| import { Schema } from 'effect' | ||
| import { describe, expect, it } from 'vitest' | ||
| import { effectToJsonSchema } from './effect-to-json-schema' | ||
| describe('effectToJsonSchema', () => { | ||
| it('converts a struct with required and optional fields', async () => { | ||
| const schema = Schema.Struct({ | ||
| name: Schema.String, | ||
| age: Schema.optional(Schema.Number), | ||
| }) | ||
| const result = await effectToJsonSchema(schema) | ||
| expect(result).toMatchObject({ | ||
| type: 'object', | ||
| properties: { | ||
| name: { type: 'string' }, | ||
| age: { type: 'number' }, | ||
| }, | ||
| required: ['name'], | ||
| }) | ||
| expect((result as { required: string[] }).required).not.toContain('age') | ||
| }) | ||
| it('strips the $schema dialect marker', async () => { | ||
| const result = await effectToJsonSchema(Schema.Struct({ name: Schema.String })) | ||
| expect(result).not.toHaveProperty('$schema') | ||
| }) | ||
| it('converts arrays and nested structs', async () => { | ||
| const schema = Schema.Struct({ | ||
| tags: Schema.Array(Schema.String), | ||
| profile: Schema.Struct({ id: Schema.String }), | ||
| }) | ||
| const result = await effectToJsonSchema(schema) | ||
| expect(result).toMatchObject({ | ||
| properties: { | ||
| tags: { type: 'array', items: { type: 'string' } }, | ||
| profile: { type: 'object', properties: { id: { type: 'string' } } }, | ||
| }, | ||
| }) | ||
| }) | ||
| it('represents Schema.Date as its encoded string form', async () => { | ||
| // Effect models Schema.Date as a string-to-Date decode, so the JSON Schema | ||
| // (the wire representation) is a string referenced via $defs. | ||
| const result = await effectToJsonSchema(Schema.Struct({ when: Schema.Date })) | ||
| const json = JSON.stringify(result) | ||
| expect(json).toContain('"when"') | ||
| expect(json).toContain('string') | ||
| }) | ||
| it('throws a helpful error for non-object input', async () => { | ||
| await expect(effectToJsonSchema(null)).rejects.toThrow(/expected an Effect Schema but received null/) | ||
| await expect(effectToJsonSchema('nope')).rejects.toThrow(/received string/) | ||
| }) | ||
| it('round-trips a plain struct through the type generator', async () => { | ||
| const schema = Schema.Struct({ | ||
| id: Schema.String, | ||
| score: Schema.optional(Schema.Number), | ||
| }) | ||
| const typeDef = generateTypeDefinition(await effectToJsonSchema(schema), 'Event') | ||
| expect(typeDef).toContain('id: string;') | ||
| expect(typeDef).toContain('score?: number;') | ||
| }) | ||
| }) |
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| // Minimal structural view of `effect`'s `JSONSchema.make`, loaded at runtime so | ||
| // `effect` stays an optional peer dependency. | ||
| type Make = (schema: unknown) => Record<string, unknown> | ||
| /** | ||
| * Resolves `effect`'s `JSONSchema.make` from a runtime import, throwing a clear | ||
| * error when it is missing. | ||
| */ | ||
| const loadMake = async (): Promise<Make> => { | ||
| let mod: Record<string, unknown> | ||
| try { | ||
| mod = (await import('effect')) as Record<string, unknown> | ||
| } catch { | ||
| throw new Error("The Effect adapter requires 'effect' to be installed in your project.") | ||
| } | ||
| const jsonSchema = mod['JSONSchema'] as Record<string, unknown> | undefined | ||
| const make = jsonSchema?.['make'] | ||
| if (typeof make !== 'function') { | ||
| throw new Error("Effect's 'JSONSchema.make' was not found. The Effect adapter requires effect v3 or later.") | ||
| } | ||
| return make as Make | ||
| } | ||
| /** | ||
| * Converts an Effect `Schema` into a JSON Schema via `JSONSchema.make`. | ||
| * | ||
| * Effect models values as a decode/encode pair, so `JSONSchema.make` describes | ||
| * the *encoded* (wire) representation. For example `Schema.Date` decodes from a | ||
| * string, so it converts to a string schema rather than a runtime `Date`. We | ||
| * pass that representation straight through — it accurately reflects what Effect | ||
| * expects on the wire — only stripping the dialect marker. | ||
| */ | ||
| export const effectToJsonSchema = async (source: unknown): Promise<JSONSchema> => { | ||
| // Effect `Schema` values are callable, so they report as `function`, not `object`. | ||
| if ((typeof source !== 'object' && typeof source !== 'function') || source === null) { | ||
| const received = source === null ? 'null' : typeof source | ||
| throw new Error(`Effect adapter expected an Effect Schema but received ${received}.`) | ||
| } | ||
| const make = await loadMake() | ||
| let json: Record<string, unknown> | ||
| try { | ||
| json = make(source) | ||
| } catch (error) { | ||
| throw new Error(`Effect adapter failed to convert the schema. Is it a valid Effect Schema?\n${String(error)}`) | ||
| } | ||
| delete json['$schema'] | ||
| return json as JSONSchema | ||
| } |
| import { describe, expect, it } from 'vitest' | ||
| import * as zodModule from 'zod' | ||
| import { getAdapter } from './get-adapter' | ||
| const z = ((zodModule as Record<string, unknown>)['z'] ?? | ||
| (zodModule as Record<string, unknown>)['default'] ?? | ||
| zodModule) as typeof import('zod')['z'] | ||
| describe('getAdapter', () => { | ||
| it('returns the TypeBox adapter', () => { | ||
| const adapter = getAdapter('typebox') | ||
| expect(adapter.format).toBe('typebox') | ||
| expect(adapter.toJSONSchema({ type: 'string' })).toEqual({ type: 'string' }) | ||
| }) | ||
| it('returns the Zod adapter, which converts asynchronously', async () => { | ||
| const adapter = getAdapter('zod') | ||
| expect(adapter.format).toBe('zod') | ||
| const result = await adapter.toJSONSchema(z.object({ name: z.string() })) | ||
| expect(result).toMatchObject({ type: 'object', properties: { name: { type: 'string' } } }) | ||
| }) | ||
| it('returns the Valibot adapter', () => { | ||
| expect(getAdapter('valibot').format).toBe('valibot') | ||
| }) | ||
| it('returns the Effect adapter', () => { | ||
| expect(getAdapter('effect').format).toBe('effect') | ||
| }) | ||
| it('throws an actionable error for the json format (handled directly by the CLI)', () => { | ||
| expect(() => getAdapter('json')).toThrow(/No adapter is available for input format 'json'/) | ||
| }) | ||
| }) |
| import type { Adapter } from './adapter' | ||
| import { effectToJsonSchema } from './effect-to-json-schema' | ||
| import type { SourceFormat } from './source-format' | ||
| import { typeboxToJsonSchema } from './typebox-to-json-schema' | ||
| import { valibotToJsonSchema } from './valibot-to-json-schema' | ||
| import { zodToJsonSchema } from './zod-to-json-schema' | ||
| const typeboxAdapter: Adapter = { | ||
| format: 'typebox', | ||
| toJSONSchema: typeboxToJsonSchema, | ||
| } | ||
| const zodAdapter: Adapter = { | ||
| format: 'zod', | ||
| toJSONSchema: zodToJsonSchema, | ||
| } | ||
| const valibotAdapter: Adapter = { | ||
| format: 'valibot', | ||
| toJSONSchema: valibotToJsonSchema, | ||
| } | ||
| const effectAdapter: Adapter = { | ||
| format: 'effect', | ||
| toJSONSchema: effectToJsonSchema, | ||
| } | ||
| /** | ||
| * Resolves the adapter for a non-JSON source format. | ||
| * | ||
| * Only formats with an implemented adapter resolve. The `'json'` format never | ||
| * reaches here — the CLI reads JSON Schema files directly without an adapter — | ||
| * and formats that are named but not yet built throw a clear, actionable error | ||
| * so the CLI can fail fast with guidance instead of a cryptic crash. | ||
| */ | ||
| export const getAdapter = (format: SourceFormat): Adapter => { | ||
| switch (format) { | ||
| case 'typebox': | ||
| return typeboxAdapter | ||
| case 'zod': | ||
| return zodAdapter | ||
| case 'valibot': | ||
| return valibotAdapter | ||
| case 'effect': | ||
| return effectAdapter | ||
| default: | ||
| throw new Error( | ||
| `No adapter is available for input format '${format}'. ` + | ||
| `Supported: 'typebox', 'zod', 'valibot', 'effect'. Use --input json for plain JSON Schema files.`, | ||
| ) | ||
| } | ||
| } |
| /** | ||
| * The schema authoring formats mjst can ingest. | ||
| * | ||
| * `'json'` is the built-in default: a plain JSON Schema file read from disk and | ||
| * handed straight to the generators. The others name external libraries whose | ||
| * schemas are first converted to JSON Schema by a matching adapter. | ||
| * | ||
| * A format may appear here before its adapter exists — `getAdapter` is the | ||
| * source of truth for what is actually implemented today. | ||
| */ | ||
| export type SourceFormat = 'json' | 'typebox' | 'zod' | 'valibot' | 'effect' |
| import { Type } from '@sinclair/typebox' | ||
| import { describe, expect, it } from 'vitest' | ||
| import { typeboxToJsonSchema } from './typebox-to-json-schema' | ||
| describe('typeboxToJsonSchema', () => { | ||
| it('round-trips a real TypeBox schema, mapping Date and bigint', () => { | ||
| const schema = Type.Object({ | ||
| id: Type.String(), | ||
| when: Type.Date(), | ||
| balance: Type.BigInt(), | ||
| nickname: Type.Optional(Type.String()), | ||
| }) | ||
| expect(typeboxToJsonSchema(schema)).toEqual({ | ||
| type: 'object', | ||
| required: ['id', 'when', 'balance'], | ||
| properties: { | ||
| id: { type: 'string' }, | ||
| when: { 'x-mjst': { instanceOf: 'Date' } }, | ||
| balance: { 'x-mjst': { primitive: 'bigint' } }, | ||
| nickname: { type: 'string' }, | ||
| }, | ||
| }) | ||
| }) | ||
| it('strips TypeBox symbol keys and returns plain JSON Schema', () => { | ||
| // Mimic a TypeBox object schema: a plain JSON-Schema-shaped object that also | ||
| // carries internal symbol keys. The symbols must not survive conversion. | ||
| const Kind = Symbol.for('TypeBox.Kind') | ||
| const typeboxSchema = { | ||
| [Kind]: 'Object', | ||
| type: 'object', | ||
| properties: { | ||
| name: { [Kind]: 'String', type: 'string' }, | ||
| age: { [Kind]: 'Number', type: 'number' }, | ||
| }, | ||
| required: ['name'], | ||
| } | ||
| const result = typeboxToJsonSchema(typeboxSchema) | ||
| expect(result).toEqual({ | ||
| type: 'object', | ||
| properties: { | ||
| name: { type: 'string' }, | ||
| age: { type: 'number' }, | ||
| }, | ||
| required: ['name'], | ||
| }) | ||
| expect(Object.getOwnPropertySymbols(result)).toHaveLength(0) | ||
| }) | ||
| it('preserves nested structures and arrays', () => { | ||
| const schema = { | ||
| type: 'object', | ||
| properties: { | ||
| tags: { type: 'array', items: { type: 'string' } }, | ||
| meta: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] }, | ||
| }, | ||
| } | ||
| expect(typeboxToJsonSchema(schema)).toEqual(schema) | ||
| }) | ||
| it('rewrites TypeBox Date into an x-mjst instanceOf hint', () => { | ||
| const schema = { | ||
| type: 'object', | ||
| properties: { | ||
| createdAt: { type: 'Date' }, | ||
| name: { type: 'string' }, | ||
| }, | ||
| required: ['createdAt'], | ||
| } | ||
| expect(typeboxToJsonSchema(schema)).toEqual({ | ||
| type: 'object', | ||
| properties: { | ||
| createdAt: { 'x-mjst': { instanceOf: 'Date' } }, | ||
| name: { type: 'string' }, | ||
| }, | ||
| required: ['createdAt'], | ||
| }) | ||
| }) | ||
| it('leaves unmapped extended types unchanged', () => { | ||
| const schema = { type: 'object', properties: { buf: { type: 'Uint8Array' } } } | ||
| expect(typeboxToJsonSchema(schema)).toEqual(schema) | ||
| }) | ||
| it('rewrites TypeBox bigint into an x-mjst primitive hint', () => { | ||
| const schema = { | ||
| type: 'object', | ||
| properties: { balance: { type: 'bigint' }, name: { type: 'string' } }, | ||
| required: ['balance'], | ||
| } | ||
| expect(typeboxToJsonSchema(schema)).toEqual({ | ||
| type: 'object', | ||
| properties: { | ||
| balance: { 'x-mjst': { primitive: 'bigint' } }, | ||
| name: { type: 'string' }, | ||
| }, | ||
| required: ['balance'], | ||
| }) | ||
| }) | ||
| it('preserves a hand-authored x-mjst brand keyword', () => { | ||
| const schema = { | ||
| type: 'object', | ||
| properties: { id: { type: 'string', 'x-mjst': { brand: 'UserId' } } }, | ||
| } | ||
| expect(typeboxToJsonSchema(schema)).toEqual(schema) | ||
| }) | ||
| it('throws a helpful error for non-object input', () => { | ||
| expect(() => typeboxToJsonSchema(null)).toThrow(/expected a schema object but received null/) | ||
| expect(() => typeboxToJsonSchema('nope')).toThrow(/received string/) | ||
| }) | ||
| }) |
| import { MJST_EXTENSION_KEY } from '@amritk/helpers/mjst-extension' | ||
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| // The seven core JSON Schema 2020-12 types. Anything else in a `type` slot is a | ||
| // TypeBox extended type with no native JSON Schema equivalent. | ||
| const JSON_SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']) | ||
| // Maps a TypeBox extended `type` string to the runtime class an `x-mjst` | ||
| // instanceOf hint should reference. Add entries here as more extended types | ||
| // gain generator support. | ||
| const EXTENDED_TYPE_TO_INSTANCE: Record<string, string> = { | ||
| Date: 'Date', | ||
| } | ||
| // Maps a TypeBox extended `type` string to a non-JSON `x-mjst` primitive hint | ||
| // (e.g. Type.BigInt() emits `{ type: 'bigint' }`). | ||
| const EXTENDED_TYPE_TO_PRIMITIVE: Record<string, string> = { | ||
| bigint: 'bigint', | ||
| } | ||
| /** | ||
| * Recursively rewrites TypeBox extended types into an `x-mjst` instanceOf hint. | ||
| * | ||
| * TypeBox emits non-standard `type` strings for runtime classes (e.g. | ||
| * `Type.Date()` produces `{ type: 'Date' }`). We drop the bogus `type` and | ||
| * record the class under `x-mjst` so the generators can emit the right | ||
| * TypeScript type and `instanceof` checks. Extended types we do not yet map are | ||
| * left untouched with a warning, preserving a permissive best-effort fallback. | ||
| */ | ||
| const rewriteExtendedTypes = (value: unknown): unknown => { | ||
| if (Array.isArray(value)) return value.map(rewriteExtendedTypes) | ||
| if (typeof value !== 'object' || value === null) return value | ||
| const source = value as Record<string, unknown> | ||
| const result: Record<string, unknown> = {} | ||
| for (const [key, val] of Object.entries(source)) { | ||
| result[key] = rewriteExtendedTypes(val) | ||
| } | ||
| const type = result['type'] | ||
| if (typeof type === 'string' && !JSON_SCHEMA_TYPES.has(type)) { | ||
| const primitive = EXTENDED_TYPE_TO_PRIMITIVE[type] | ||
| const instanceOf = EXTENDED_TYPE_TO_INSTANCE[type] | ||
| if (primitive) { | ||
| delete result['type'] | ||
| result[MJST_EXTENSION_KEY] = { primitive } | ||
| } else if (instanceOf) { | ||
| delete result['type'] | ||
| result[MJST_EXTENSION_KEY] = { instanceOf } | ||
| } else { | ||
| console.warn(`[mjst] TypeBox type '${type}' has no JSON Schema or x-mjst mapping; leaving it unchanged.`) | ||
| } | ||
| } | ||
| return result | ||
| } | ||
| /** | ||
| * TypeBox schemas are already JSON Schema objects at runtime, but they carry | ||
| * non-enumerable symbol keys (`Kind`, `Optional`, ...) that TypeBox uses for its | ||
| * own type machinery. A JSON round-trip drops those symbols (and any `undefined` | ||
| * values), leaving a clean, plain JSON Schema. We then rewrite TypeBox's | ||
| * extended types (Date, ...) into `x-mjst` hints the generators understand. | ||
| * | ||
| * We deliberately do not import TypeBox here: the conversion only touches the | ||
| * plain-object shape, so TypeBox stays an optional peer dependency used solely | ||
| * by the consumer's schema module, never by mjst itself. | ||
| */ | ||
| export const typeboxToJsonSchema = (source: unknown): JSONSchema => { | ||
| if (typeof source !== 'object' || source === null) { | ||
| const received = source === null ? 'null' : typeof source | ||
| throw new Error(`TypeBox adapter expected a schema object but received ${received}.`) | ||
| } | ||
| // Boundary cast: JSON.parse yields `unknown`, and the round-tripped, rewritten | ||
| // TypeBox schema is a valid JSON Schema by construction. | ||
| const sanitized: unknown = JSON.parse(JSON.stringify(source)) | ||
| return rewriteExtendedTypes(sanitized) as JSONSchema | ||
| } |
| import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition' | ||
| import * as v from 'valibot' | ||
| import { describe, expect, it } from 'vitest' | ||
| import { valibotToJsonSchema } from './valibot-to-json-schema' | ||
| describe('valibotToJsonSchema', () => { | ||
| it('converts an object with required and optional fields', async () => { | ||
| const schema = v.object({ | ||
| name: v.string(), | ||
| age: v.optional(v.number()), | ||
| }) | ||
| const result = await valibotToJsonSchema(schema) | ||
| expect(result).toMatchObject({ | ||
| type: 'object', | ||
| properties: { | ||
| name: { type: 'string' }, | ||
| age: { type: 'number' }, | ||
| }, | ||
| required: ['name'], | ||
| }) | ||
| expect((result as { required: string[] }).required).not.toContain('age') | ||
| }) | ||
| it('converts picklists to a string schema with an enum list', async () => { | ||
| const result = await valibotToJsonSchema(v.object({ role: v.picklist(['admin', 'user']) })) | ||
| expect(result).toMatchObject({ | ||
| properties: { role: { type: 'string', enum: ['admin', 'user'] } }, | ||
| }) | ||
| }) | ||
| it('converts arrays and nested objects', async () => { | ||
| const schema = v.object({ | ||
| tags: v.array(v.string()), | ||
| profile: v.object({ id: v.string() }), | ||
| }) | ||
| const result = await valibotToJsonSchema(schema) | ||
| expect(result).toMatchObject({ | ||
| properties: { | ||
| tags: { type: 'array', items: { type: 'string' } }, | ||
| profile: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, | ||
| }, | ||
| }) | ||
| }) | ||
| it('strips the $schema dialect marker', async () => { | ||
| const result = await valibotToJsonSchema(v.object({ name: v.string() })) | ||
| expect(result).not.toHaveProperty('$schema') | ||
| }) | ||
| it('maps v.date() to an x-mjst instanceOf Date hint', async () => { | ||
| const result = await valibotToJsonSchema(v.object({ when: v.date(), name: v.string() })) | ||
| expect(result).toMatchObject({ | ||
| properties: { | ||
| when: { 'x-mjst': { instanceOf: 'Date' } }, | ||
| name: { type: 'string' }, | ||
| }, | ||
| }) | ||
| expect((result as { properties: { when: Record<string, unknown> } }).properties.when).not.toHaveProperty('type') | ||
| }) | ||
| it('maps v.bigint() to an x-mjst primitive bigint hint', async () => { | ||
| const result = await valibotToJsonSchema(v.object({ balance: v.bigint(), name: v.string() })) | ||
| expect(result).toMatchObject({ | ||
| properties: { | ||
| balance: { 'x-mjst': { primitive: 'bigint' } }, | ||
| name: { type: 'string' }, | ||
| }, | ||
| }) | ||
| expect((result as { properties: { balance: Record<string, unknown> } }).properties.balance).not.toHaveProperty( | ||
| 'type', | ||
| ) | ||
| }) | ||
| it('throws a helpful error for non-object input', async () => { | ||
| await expect(valibotToJsonSchema(null)).rejects.toThrow(/expected a Valibot schema but received null/) | ||
| await expect(valibotToJsonSchema(42)).rejects.toThrow(/received number/) | ||
| }) | ||
| it('round-trips through the type generator, including Date fields', async () => { | ||
| const schema = v.object({ | ||
| id: v.string(), | ||
| createdAt: v.date(), | ||
| score: v.optional(v.number()), | ||
| }) | ||
| const typeDef = generateTypeDefinition(await valibotToJsonSchema(schema), 'Event') | ||
| expect(typeDef).toContain('id: string;') | ||
| expect(typeDef).toContain('createdAt: Date;') | ||
| expect(typeDef).toContain('score?: number;') | ||
| }) | ||
| }) |
| import { MJST_EXTENSION_KEY } from '@amritk/helpers/mjst-extension' | ||
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| // Minimal structural view of `@valibot/to-json-schema`, so the adapter does not | ||
| // hard-depend on its types — the converter is loaded at runtime as an optional | ||
| // peer dependency. | ||
| type ValibotOverrideContext = { readonly valibotSchema?: { readonly type?: string } } | ||
| type ValibotConfig = { | ||
| readonly errorMode?: 'throw' | 'warn' | 'ignore' | ||
| readonly overrideSchema?: (ctx: ValibotOverrideContext) => Record<string, unknown> | undefined | ||
| } | ||
| type ToJsonSchema = (schema: unknown, config?: ValibotConfig) => Record<string, unknown> | ||
| /** | ||
| * Resolves `@valibot/to-json-schema`'s `toJsonSchema` from a runtime import, | ||
| * throwing a clear error when the converter is missing. | ||
| */ | ||
| const loadToJsonSchema = async (): Promise<ToJsonSchema> => { | ||
| let mod: Record<string, unknown> | ||
| try { | ||
| mod = (await import('@valibot/to-json-schema')) as Record<string, unknown> | ||
| } catch { | ||
| throw new Error( | ||
| "The Valibot adapter requires '@valibot/to-json-schema' (and 'valibot') to be installed in your project.", | ||
| ) | ||
| } | ||
| const named = mod['toJsonSchema'] | ||
| const fromDefault = (mod['default'] as Record<string, unknown> | undefined)?.['toJsonSchema'] | ||
| const converter = typeof named === 'function' ? named : typeof fromDefault === 'function' ? fromDefault : undefined | ||
| if (!converter) { | ||
| throw new Error("'@valibot/to-json-schema' did not export 'toJsonSchema'.") | ||
| } | ||
| return converter as ToJsonSchema | ||
| } | ||
| /** | ||
| * Converts a Valibot schema into a Draft 2020-12 JSON Schema via | ||
| * `@valibot/to-json-schema`. | ||
| * | ||
| * Valibot's `date` schema has no JSON Schema representation and would otherwise | ||
| * throw, so we run with `errorMode: 'warn'` (other unsupported constructs degrade | ||
| * to an open schema rather than failing the whole conversion, and the converter | ||
| * logs which ones — so the widening is visible instead of silent) and use the | ||
| * `overrideSchema` hook to rewrite dates into the shared `x-mjst` instanceOf | ||
| * extension — the same handling TypeBox and Zod dates receive. | ||
| */ | ||
| export const valibotToJsonSchema = async (source: unknown): Promise<JSONSchema> => { | ||
| if (typeof source !== 'object' || source === null) { | ||
| const received = source === null ? 'null' : typeof source | ||
| throw new Error(`Valibot adapter expected a Valibot schema but received ${received}.`) | ||
| } | ||
| const toJsonSchema = await loadToJsonSchema() | ||
| let json: Record<string, unknown> | ||
| try { | ||
| json = toJsonSchema(source, { | ||
| errorMode: 'warn', | ||
| overrideSchema: (ctx) => { | ||
| if (ctx.valibotSchema?.type === 'date') return { [MJST_EXTENSION_KEY]: { instanceOf: 'Date' } } | ||
| if (ctx.valibotSchema?.type === 'bigint') return { [MJST_EXTENSION_KEY]: { primitive: 'bigint' } } | ||
| return undefined | ||
| }, | ||
| }) | ||
| } catch (error) { | ||
| throw new Error(`Valibot adapter failed to convert the schema. Is it a valid Valibot schema?\n${String(error)}`) | ||
| } | ||
| // Valibot emits a draft-07 dialect marker; the generators target 2020-12. | ||
| delete json['$schema'] | ||
| return json as JSONSchema | ||
| } |
| import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition' | ||
| import { describe, expect, it, vi } from 'vitest' | ||
| import * as zodModule from 'zod' | ||
| import { zodToJsonSchema } from './zod-to-json-schema' | ||
| // Resolve the `z` namespace regardless of how the installed Zod build exports it. | ||
| const z = ((zodModule as Record<string, unknown>)['z'] ?? | ||
| (zodModule as Record<string, unknown>)['default'] ?? | ||
| zodModule) as typeof import('zod')['z'] | ||
| describe('zodToJsonSchema', () => { | ||
| it('converts a flat object with required and optional fields', async () => { | ||
| const schema = z.object({ | ||
| name: z.string(), | ||
| age: z.number().int().optional(), | ||
| score: z.number().optional(), | ||
| }) | ||
| const result = await zodToJsonSchema(schema) | ||
| expect(result).toMatchObject({ | ||
| type: 'object', | ||
| properties: { | ||
| name: { type: 'string' }, | ||
| age: { type: 'integer' }, | ||
| score: { type: 'number' }, | ||
| }, | ||
| required: ['name'], | ||
| }) | ||
| expect((result as { required: string[] }).required).not.toContain('age') | ||
| expect((result as { required: string[] }).required).not.toContain('score') | ||
| }) | ||
| it('strips the $schema dialect marker', async () => { | ||
| const result = await zodToJsonSchema(z.object({ name: z.string() })) | ||
| expect(result).not.toHaveProperty('$schema') | ||
| }) | ||
| it('warns when a lossy (unrepresentable) Zod type is widened to "accept anything"', async () => { | ||
| const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) | ||
| try { | ||
| await zodToJsonSchema(z.object({ id: z.string(), sym: z.symbol() })) | ||
| expect(warn).toHaveBeenCalledWith(expect.stringMatching(/Zod adapter: symbol/)) | ||
| } finally { | ||
| warn.mockRestore() | ||
| } | ||
| }) | ||
| it('converts enums to a string schema with an enum list', async () => { | ||
| const result = await zodToJsonSchema(z.object({ role: z.enum(['admin', 'user']) })) | ||
| expect(result).toMatchObject({ | ||
| properties: { role: { type: 'string', enum: ['admin', 'user'] } }, | ||
| }) | ||
| }) | ||
| it('converts arrays and nested objects', async () => { | ||
| const schema = z.object({ | ||
| tags: z.array(z.string()), | ||
| profile: z.object({ id: z.string() }), | ||
| }) | ||
| const result = await zodToJsonSchema(schema) | ||
| expect(result).toMatchObject({ | ||
| properties: { | ||
| tags: { type: 'array', items: { type: 'string' } }, | ||
| profile: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, | ||
| }, | ||
| }) | ||
| }) | ||
| it('converts a top-level scalar schema', async () => { | ||
| expect(await zodToJsonSchema(z.string())).toMatchObject({ type: 'string' }) | ||
| }) | ||
| it('maps z.date() to an x-mjst instanceOf Date hint', async () => { | ||
| const result = await zodToJsonSchema(z.object({ when: z.date(), name: z.string() })) | ||
| expect(result).toMatchObject({ | ||
| properties: { | ||
| when: { 'x-mjst': { instanceOf: 'Date' } }, | ||
| name: { type: 'string' }, | ||
| }, | ||
| }) | ||
| // The date property must not carry a leftover JSON Schema `type`. | ||
| expect((result as { properties: { when: Record<string, unknown> } }).properties.when).not.toHaveProperty('type') | ||
| }) | ||
| it('maps z.bigint() to an x-mjst primitive bigint hint', async () => { | ||
| const result = await zodToJsonSchema(z.object({ balance: z.bigint(), name: z.string() })) | ||
| expect(result).toMatchObject({ | ||
| properties: { | ||
| balance: { 'x-mjst': { primitive: 'bigint' } }, | ||
| name: { type: 'string' }, | ||
| }, | ||
| }) | ||
| expect((result as { properties: { balance: Record<string, unknown> } }).properties.balance).not.toHaveProperty( | ||
| 'type', | ||
| ) | ||
| }) | ||
| it('throws a helpful error for non-object input', async () => { | ||
| await expect(zodToJsonSchema(null)).rejects.toThrow(/expected a Zod schema but received null/) | ||
| await expect(zodToJsonSchema('nope')).rejects.toThrow(/received string/) | ||
| }) | ||
| it('round-trips through the type generator, including Date fields', async () => { | ||
| const schema = z.object({ | ||
| id: z.string(), | ||
| createdAt: z.date(), | ||
| score: z.number().optional(), | ||
| }) | ||
| const json = await zodToJsonSchema(schema) | ||
| const typeDef = generateTypeDefinition(json, 'Event') | ||
| expect(typeDef).toContain('id: string;') | ||
| expect(typeDef).toContain('createdAt: Date;') | ||
| expect(typeDef).toContain('score?: number;') | ||
| }) | ||
| }) |
| import { MJST_EXTENSION_KEY } from '@amritk/helpers/mjst-extension' | ||
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| // Zod 4's `toJSONSchema` does the heavy lifting. We only describe the slice of | ||
| // its surface we touch so the adapter does not need a hard dependency on Zod's | ||
| // types — `zod` stays an optional peer dependency loaded at runtime. | ||
| type ToJsonSchema = (schema: unknown, options?: ZodToJsonSchemaOptions) => Record<string, unknown> | ||
| type OverrideContext = { | ||
| readonly zodSchema?: { readonly _zod?: { readonly def?: { readonly type?: string } } } | ||
| readonly jsonSchema: Record<string, unknown> | ||
| } | ||
| type ZodToJsonSchemaOptions = { | ||
| readonly unrepresentable?: 'any' | 'throw' | ||
| readonly override?: (ctx: OverrideContext) => void | ||
| } | ||
| /** | ||
| * Resolves Zod's `toJSONSchema` from a runtime import, tolerating both the | ||
| * named export (`import { toJSONSchema }`) and the `z` namespace | ||
| * (`z.toJSONSchema`). Throws a clear error when Zod is missing or too old, | ||
| * since `toJSONSchema` only exists in Zod 4 and later. | ||
| */ | ||
| const loadToJsonSchema = async (): Promise<ToJsonSchema> => { | ||
| let mod: Record<string, unknown> | ||
| try { | ||
| mod = (await import('zod')) as Record<string, unknown> | ||
| } catch { | ||
| throw new Error("The Zod adapter requires 'zod' (v4 or later) to be installed in your project.") | ||
| } | ||
| const named = mod['toJSONSchema'] | ||
| const namespace = (mod['z'] ?? mod['default'] ?? mod) as Record<string, unknown> | undefined | ||
| const fromNamespace = namespace?.['toJSONSchema'] | ||
| const converter = | ||
| typeof named === 'function' ? named : typeof fromNamespace === 'function' ? fromNamespace : undefined | ||
| if (!converter) { | ||
| throw new Error("Zod's 'toJSONSchema' was not found. The Zod adapter requires zod v4 or later.") | ||
| } | ||
| return converter as ToJsonSchema | ||
| } | ||
| /** | ||
| * Converts a Zod schema into a Draft 2020-12 JSON Schema using Zod 4's native | ||
| * `toJSONSchema`. | ||
| * | ||
| * Zod's `z.date()` has no JSON Schema representation and would otherwise throw. | ||
| * We pass `unrepresentable: 'any'` so conversion never fails on it, then use the | ||
| * `override` hook to rewrite date schemas into an `x-mjst` instanceOf hint — the | ||
| * same extension TypeBox dates use — so generated types and runtime checks treat | ||
| * them as `Date`. | ||
| */ | ||
| export const zodToJsonSchema = async (source: unknown): Promise<JSONSchema> => { | ||
| if (typeof source !== 'object' || source === null) { | ||
| const received = source === null ? 'null' : typeof source | ||
| throw new Error(`Zod adapter expected a Zod schema but received ${received}.`) | ||
| } | ||
| const toJSONSchema = await loadToJsonSchema() | ||
| // Zod types with no JSON Schema equivalent that we do *not* rescue into an | ||
| // `x-mjst` hint. `unrepresentable: 'any'` turns these into `{}` (accepts | ||
| // anything), which silently widens the generated type — so we surface them. | ||
| const LOSSY_TYPES = new Set(['symbol', 'nan', 'void', 'undefined', 'never', 'map', 'set', 'promise', 'function']) | ||
| const droppedTypes = new Set<string>() | ||
| let json: Record<string, unknown> | ||
| try { | ||
| json = toJSONSchema(source, { | ||
| unrepresentable: 'any', | ||
| override: (ctx) => { | ||
| const type = ctx.zodSchema?._zod?.def?.type | ||
| if (type === 'date') { | ||
| for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key] | ||
| ctx.jsonSchema[MJST_EXTENSION_KEY] = { instanceOf: 'Date' } | ||
| } else if (type === 'bigint') { | ||
| for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key] | ||
| ctx.jsonSchema[MJST_EXTENSION_KEY] = { primitive: 'bigint' } | ||
| } else if (type && LOSSY_TYPES.has(type)) { | ||
| droppedTypes.add(type) | ||
| } | ||
| }, | ||
| }) | ||
| } catch (error) { | ||
| throw new Error(`Zod adapter failed to convert the schema. Is it a valid Zod schema?\n${String(error)}`) | ||
| } | ||
| if (droppedTypes.size > 0) { | ||
| console.warn( | ||
| `[mjst] Zod adapter: ${[...droppedTypes].sort().join(', ')} ${droppedTypes.size === 1 ? 'has' : 'have'} no JSON Schema representation and became "accept anything". The generated type will be wider than the Zod schema.`, | ||
| ) | ||
| } | ||
| // The dialect marker is noise for the generators, which already target 2020-12. | ||
| delete json['$schema'] | ||
| return json as JSONSchema | ||
| } |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
20006
-62.76%15
-55.88%389
-65.17%1
Infinity%+ Added
- Removed
Updated