diff --git a/src/codecTools/toJsonSchema.ts b/src/codecTools/toJsonSchema.ts index 113849b..7960b3d 100644 --- a/src/codecTools/toJsonSchema.ts +++ b/src/codecTools/toJsonSchema.ts @@ -1,7 +1,7 @@ /* eslint-disable no-underscore-dangle */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { JSONSchema7 } from 'json-schema'; import * as t from 'io-ts'; +import type { JSONSchema7 } from 'json-schema'; /** * io-ts types compatible w/ JSON Schema @@ -27,10 +27,15 @@ type MappableType = * Convert an io-ts codec to a JSON Schema (v7) * @param _type - an io-ts codec * @param strict - whether to enable strict mode + * @param alwaysIncludeRequired - whether to always include required fields (OpenAI requires this) * @returns a JSON schema object * @see https://json-schema.org/understanding-json-schema/basics.html */ -export const toJsonSchema = (_type: any, strict = false): JSONSchema7 => { +export const toJsonSchema = ( + _type: any, + strict = false, + alwaysIncludeRequired = false, +): JSONSchema7 => { const type = _type as MappableType; if (type._tag === 'StringType') { @@ -52,11 +57,37 @@ export const toJsonSchema = (_type: any, strict = false): JSONSchema7 => { return { type: 'string', enum: Object.keys(type.keys) }; } if (type._tag === 'UnionType') { - return { anyOf: type.types.map((t: any) => toJsonSchema(t, strict)) }; + return { + anyOf: type.types.map((t: any) => + toJsonSchema(t, strict, alwaysIncludeRequired), + ), + }; } - if (type._tag === 'IntersectionType') { - return { allOf: type.types.map((t: any) => toJsonSchema(t, strict)) }; + if (type._tag === 'IntersectionType' && !alwaysIncludeRequired) { + return { + allOf: type.types.map((t: any) => + toJsonSchema(t, strict, alwaysIncludeRequired), + ), + }; } + if (type._tag === 'IntersectionType' && alwaysIncludeRequired) { + const results = type.types.map((t: any) => + toJsonSchema(t, strict, alwaysIncludeRequired), + ); + if (!results.every((r: any) => r.type === 'object')) { + throw new Error('InterfaceType must have all children as type=object'); + } + return { + type: 'object', + required: results.map((r: any) => r.required).flat(), + properties: results.reduce( + (acc: any, r: any) => ({ ...acc, ...r.properties }), + {} as any, + ), + ...(strict ? { additionalProperties: false } : {}), + }; + } + if (type._tag === 'InterfaceType') { return { type: 'object', @@ -64,7 +95,7 @@ export const toJsonSchema = (_type: any, strict = false): JSONSchema7 => { properties: Object.fromEntries( Object.entries>(type.props).map(([key, subtype]) => [ key, - toJsonSchema(subtype, strict), + toJsonSchema(subtype, strict, alwaysIncludeRequired), ]), ), ...(strict ? { additionalProperties: false } : {}), @@ -73,17 +104,30 @@ export const toJsonSchema = (_type: any, strict = false): JSONSchema7 => { if (type._tag === 'DictionaryType') { return { type: 'object', - additionalProperties: toJsonSchema(type.codomain, strict), + additionalProperties: toJsonSchema( + type.codomain, + strict, + alwaysIncludeRequired, + ), }; } if (type._tag === 'PartialType') { return { type: 'object', + ...(alwaysIncludeRequired ? { required: Object.keys(type.props) } : {}), properties: Object.fromEntries( - Object.entries>(type.props).map(([key, subtype]) => [ - key, - toJsonSchema(subtype, strict), - ]), + Object.entries>(type.props).map(([key, subtype]) => { + const result = toJsonSchema(subtype, strict, alwaysIncludeRequired); + return [ + key, + alwaysIncludeRequired && result.type + ? { + ...result, + type: [result.type as any, 'null'], + } + : result, + ]; + }), ), ...(strict ? { additionalProperties: false } : {}), }; @@ -91,13 +135,15 @@ export const toJsonSchema = (_type: any, strict = false): JSONSchema7 => { if (type._tag === 'ArrayType') { return { type: 'array', - items: toJsonSchema(type.type, strict), + items: toJsonSchema(type.type, strict, alwaysIncludeRequired), }; } if (type._tag === 'TupleType') { return { type: 'array', - items: type.types.map((t: any) => toJsonSchema(t, strict)), + items: type.types.map((t: any) => + toJsonSchema(t, strict, alwaysIncludeRequired), + ), }; } if (type._tag === 'RefinementType') { @@ -105,12 +151,12 @@ export const toJsonSchema = (_type: any, strict = false): JSONSchema7 => { return { type: 'integer' }; } return { - ...toJsonSchema(type.type, strict), + ...toJsonSchema(type.type, strict, alwaysIncludeRequired), description: `Predicate: ${type.predicate.name || type.name}`, }; } // could add more here for DateFromISOString, etc. etc. - return unhandledType(type); + return unhandledType(type as never); }; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-unused-vars