From c7493c0acea8b5782e74334ecb76c53c7f821e09 Mon Sep 17 00:00:00 2001 From: Mike Cornwell Date: Thu, 2 Oct 2025 13:53:55 -0400 Subject: [PATCH 1/8] fix(description): fix openapi description bug --- package.json | 2 +- src/lib.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4f5cc88..91052c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "functional-models", - "version": "3.4.1", + "version": "3.4.2", "description": "Functional models is ooey gooey framework for building and using awesome models EVERYWHERE.", "main": "index.js", "types": "index.d.ts", diff --git a/src/lib.ts b/src/lib.ts index f5e8abe..5c3a755 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -319,7 +319,20 @@ const createZodForProperty = ? s.default(myConfig.defaultValue) : s, s => (myConfig.required ? s : s.optional()), - s => (myConfig.description ? s.describe(myConfig.description) : s), + // Attach description for Zod consumers and OpenAPI generators. + s => { + if (myConfig.description) { + // zod's describe helps Zod introspection; some zod-openapi versions expect metadata via .meta or .openapi + // Use .describe and also attach .meta with openapi description if available. + if (typeof s.openapi === 'function') { + return s.openapi({ description: myConfig.description }) + } + return s.meta + ? s.meta({ description: myConfig.description }) + : s.describe(myConfig.description) + } + return s + }, ])(schemaFromChoices) return finalSchema as ZodType From 07dd71a59568ef271aa1355d53fe8df51f066ec1 Mon Sep 17 00:00:00 2001 From: Mike Cornwell Date: Thu, 2 Oct 2025 14:51:54 -0400 Subject: [PATCH 2/8] fix(description): fix description --- package.json | 2 +- src/models.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 91052c2..4232b66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "functional-models", - "version": "3.4.2", + "version": "3.4.3", "description": "Functional models is ooey gooey framework for building and using awesome models EVERYWHERE.", "main": "index.js", "types": "index.d.ts", diff --git a/src/models.ts b/src/models.ts index c53e4f0..003e462 100644 --- a/src/models.ts +++ b/src/models.ts @@ -62,7 +62,11 @@ const _createZod = ( }, {} as Record ) - return z.object(properties) as ZodObject + const obj = z.object(properties) as ZodObject + if (modelDefinition.description) { + return obj.describe(modelDefinition.description) + } + return obj } const _toModelDefinition = ( From 7ef83ea0a9b731c082decea3a44b06d4b1d0d10d Mon Sep 17 00:00:00 2001 From: Mike Cornwell Date: Thu, 2 Oct 2025 14:55:23 -0400 Subject: [PATCH 3/8] fix(description): fix description --- src/models.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/models.ts b/src/models.ts index 003e462..2b001c2 100644 --- a/src/models.ts +++ b/src/models.ts @@ -44,14 +44,28 @@ const _convertOptions = ( return r } +const _addDescription = ( + schema: ZodObject, + description?: string +) => { + if (!description) { + return schema + } + // @ts-ignore + if (typeof schema.openapi === 'function') { + // @ts-ignore + return schema.openapi({ description: description }) + } + return schema.meta + ? schema.meta({ description: description }) + : schema.describe(description) +} + const _createZod = ( modelDefinition: MinimalModelDefinition ): ZodObject => { if (modelDefinition.schema) { - if (modelDefinition.description) { - return modelDefinition.schema.describe(modelDefinition.description) - } - return modelDefinition.schema + return _addDescription(modelDefinition.schema, modelDefinition.description) } const properties = Object.entries(modelDefinition.properties).reduce( (acc, [key, property]) => { @@ -63,10 +77,7 @@ const _createZod = ( {} as Record ) const obj = z.object(properties) as ZodObject - if (modelDefinition.description) { - return obj.describe(modelDefinition.description) - } - return obj + return _addDescription(obj, modelDefinition.description) } const _toModelDefinition = ( From 833a8a6b3e4108a932c89913cf3d4e9394effa2b Mon Sep 17 00:00:00 2001 From: Mike Cornwell Date: Thu, 2 Oct 2025 14:56:19 -0400 Subject: [PATCH 4/8] chore(version): bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4232b66..f148798 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "functional-models", - "version": "3.4.3", + "version": "3.4.4", "description": "Functional models is ooey gooey framework for building and using awesome models EVERYWHERE.", "main": "index.js", "types": "index.d.ts", From d4da1305a5db5d83f2e6701c4db1205e3a597200 Mon Sep 17 00:00:00 2001 From: Mike Cornwell Date: Thu, 2 Oct 2025 19:42:27 -0400 Subject: [PATCH 5/8] feat(openapi): add function to convert zod to openapi objects --- package.json | 2 +- src/lib.ts | 647 +++++++++++++++++++++++++++++++++++++++++++ test/src/lib.test.ts | 411 ++++++++++++++++++++++++++- 3 files changed, 1058 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f148798..659f54d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "functional-models", - "version": "3.4.4", + "version": "3.5.0", "description": "Functional models is ooey gooey framework for building and using awesome models EVERYWHERE.", "main": "index.js", "types": "index.d.ts", diff --git a/src/lib.ts b/src/lib.ts index 5c3a755..95d21bd 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -12,6 +12,7 @@ import { DataDescription, DataValue, ModelInstance, + ModelType, PrimaryKeyType, PropertyConfig, PropertyValidatorComponent, @@ -33,6 +34,10 @@ const NULL_ENDPOINT = 'NULL' const NULL_METHOD = HttpMethods.HEAD const ID_KEY = ':id' +const _checkAB = (check: boolean, a: any, b: any) => { + return check ? a : b +} + const getValueForReferencedModel = async ( modelInstance: ModelInstance, path: string @@ -270,6 +275,9 @@ const createZodForProperty = } const _getZodForPropertyType = (pt: any) => { + if (myConfig.choices) { + return z.enum(myConfig.choices as any) + } switch (pt) { case 'UniqueId': return z.string() @@ -338,6 +346,644 @@ const createZodForProperty = return finalSchema as ZodType } +const isWrapperType = ( + explicitType: string | undefined, + typeString: string | undefined +) => { + return ( + explicitType === 'ZodOptional' || + explicitType === 'ZodNullable' || + explicitType === 'ZodDefault' || + typeString === 'optional' || + typeString === 'nullable' || + typeString === 'nullish' || + typeString === 'default' + ) +} + +const getInnerType = (defRef: any) => { + return ( + defRef.innerType || + defRef.type || + defRef.schema || + defRef.payload || + defRef.value || + defRef.inner || + (defRef._def && (defRef._def.inner || defRef._def.type)) || + (defRef && defRef.innerType) + ) +} + +const unwrapOnce = (s: any) => { + const defRef = s && (s._def || s.def) + if (!defRef) { + return s + } + const explicitType = + defRef && defRef.typeName ? String(defRef.typeName) : undefined + const typeString = + !explicitType && defRef && typeof defRef.type === 'string' + ? String(defRef.type) + : undefined + const isWrapper = isWrapperType(explicitType, typeString) + if (!isWrapper) { + return s + } + return getInnerType(defRef) || s +} + +const recur = (s: any): any => { + const next = unwrapOnce(s) + return next === s ? s : recur(next) +} + +const modelToOpenApi = < + TData extends DataDescription, + TModel extends ModelType, +>( + model: TModel +) => { + const zodSchema: any = model.getModelDefinition().schema + const modelProps = (model.getModelDefinition() as any).properties || {} + const propConfigMap: Record = Object.keys(modelProps).reduce( + (acc: Record, k) => + merge(acc, { + [k]: merge(modelProps[k].getConfig ? modelProps[k].getConfig() : {}, { + propertyType: modelProps[k].getPropertyType + ? modelProps[k].getPropertyType() + : undefined, + }), + }), + {} + ) + + const unwrap = (schema: ZodType): ZodType => { + // Functional recursive unwrapping of common Zod wrappers. + return recur(schema) as ZodType + } + + // --- small helpers extracted to reduce complexity --- + const getTypeName = (defRef: any) => { + if (!defRef) { + return '' + } + if (typeof defRef.type === 'string') { + return ( + 'Zod' + + String(defRef.type).charAt(0).toUpperCase() + + String(defRef.type).slice(1) + ) + } + if (defRef.typeName) { + return String(defRef.typeName) + } + return '' + } + + const extractStringBounds = (defRef: any, s: any) => { + const checks = + (defRef && defRef.checks) || (s && s._def && (s._def as any).checks) || [] + return Array.isArray(checks) + ? checks.reduce( + (acc: { min?: number; max?: number }, c: any) => { + if (!c || typeof c !== 'object') { + return acc + } + const candidateMin = + (c.kind === 'min' || + c.check === 'min' || + (c.def && c.def.type === 'min')) && + typeof c.value === 'number' + ? c.value + : c.def && typeof c.def.minLength === 'number' + ? c.def.minLength + : undefined + const candidateMax = + (c.kind === 'max' || + c.check === 'max' || + (c.def && c.def.type === 'max')) && + typeof c.value === 'number' + ? c.value + : c.def && typeof c.def.maxLength === 'number' + ? c.def.maxLength + : undefined + return { + min: candidateMin !== undefined ? candidateMin : acc.min, + max: candidateMax !== undefined ? candidateMax : acc.max, + } + }, + {} as { min?: number; max?: number } + ) + : {} + } + + const getCandidateMin = (c: any) => { + return (c.kind === 'min' || + c.check === 'min' || + (c.def && c.def.type === 'min')) && + typeof c.value === 'number' + ? c.value + : c.def && typeof c.def.minValue === 'number' + ? c.def.minValue + : undefined + } + + const getCandidateMax = (c: any) => { + return (c.kind === 'max' || + c.check === 'max' || + (c.def && c.def.type === 'max')) && + typeof c.value === 'number' + ? c.value + : c.def && typeof c.def.maxValue === 'number' + ? c.def.maxValue + : undefined + } + + const isIntegerCheck = (c: any, acc: { isInteger?: boolean }) => { + return ( + acc.isInteger || + (c && + (c.kind === 'int' || + c.check === 'int' || + c === 'int' || + JSON.stringify(c).includes('int'))) + ) + } + + const extractNumberInfo = (defRef: any, s: any) => { + const checks = + (defRef && defRef.checks) || (s && s._def && (s._def as any).checks) || [] + return Array.isArray(checks) + ? checks.reduce( + ( + acc: { min?: number; max?: number; isInteger?: boolean }, + c: any + ) => { + if (!c || typeof c !== 'object') { + return acc + } + const candidateMin = getCandidateMin(c) + const candidateMax = getCandidateMax(c) + const isInt = isIntegerCheck(c, acc) + return { + min: candidateMin !== undefined ? candidateMin : acc.min, + max: candidateMax !== undefined ? candidateMax : acc.max, + isInteger: isInt, + } + }, + {} as { min?: number; max?: number; isInteger?: boolean } + ) + : {} + } + + const getLiteralOpenApi = (defRef: any, s: any) => { + const candidates = [ + defRef && (defRef.value !== undefined ? defRef.value : undefined), + defRef && + defRef._def && + (defRef._def.value !== undefined ? defRef._def.value : undefined), + s && s._def && (s._def.value !== undefined ? s._def.value : undefined), + (s as any) && (s as any).value, + ] + const val: any = candidates.find((c: any) => c !== undefined) + const t = typeof val + if (t === 'string') { + return { type: 'string', enum: [val] } + } + /* + if (t === 'number') { + return { type: 'number', enum: [val] } + } + if (t === 'boolean') { + return { type: 'boolean', enum: [val] } + } + */ + return { enum: [val] } + } + + const getEnumValues = (defRef: any, s: any) => { + const candidates = [ + s && (s as any)._def && (s as any)._def.values, + s && (s as any)._def && (s as any)._def.options, + (s as any).values, + (s as any).options, + defRef && defRef.values, + defRef && defRef.options, + defRef && defRef._def && defRef._def.values, + defRef && defRef._def && defRef._def.options, + ] + const found = candidates.find((c: any) => Array.isArray(c) && c.length > 0) + return Array.isArray(found) ? found : [] + } + + const handleZodObject = (s: any, defRef: any, depth: number) => { + const asAny = s as any + const shape: Record> = asAny && + typeof asAny.shape === 'function' + ? asAny.shape() + : //: defRef && typeof defRef.shape === 'function' + //? defRef.shape() + defRef.shape || defRef.properties || {} + if (!shape || Object.keys(shape).length === 0) { + return { type: 'object' } + } + + const keys = Object.keys(shape || {}) + const getChildDescription = (sChild: any, childSchema: any) => { + return ( + (sChild && + (sChild.description || + (sChild._def && sChild._def.description) || + (sChild.def && sChild.def.description))) || + ((childSchema as any) && + (((childSchema as any)._def && + (childSchema as any)._def.description) || + ((childSchema as any).def && (childSchema as any).def.description))) + ) + } + + const getWithDescription = ( + childOpen: any, + modelProp: any, + descFromDef: any, + depth: number + ) => { + return modelProp && modelProp.description && depth === 0 + ? merge({}, childOpen, { description: modelProp.description }) + : descFromDef + ? merge({}, childOpen, { description: descFromDef }) + : childOpen + } + + const getAdjustedProperties = (modelProp: any, withDesc: any) => { + return modelProp + ? withDesc && + (withDesc.type === 'number' || withDesc.type === 'integer') + ? merge( + {}, + withDesc, + typeof modelProp.minValue === 'number' + ? { minimum: modelProp.minValue } + : {}, + typeof modelProp.maxValue === 'number' + ? { maximum: modelProp.maxValue } + : {} + ) + : withDesc && withDesc.type === 'string' + ? merge( + {}, + withDesc, + typeof modelProp.minLength === 'number' + ? { minimum: modelProp.minLength } + : {}, + typeof modelProp.maxLength === 'number' + ? { maximum: modelProp.maxLength } + : {} + ) + : withDesc + : withDesc + } + + const _getRequired = (childTypeName: string, acc: any, key: string) => { + return childTypeName !== 'ZodOptional' && + childTypeName !== 'ZodNullable' && + childTypeName !== 'ZodDefault' + ? (acc.required || []).concat(key) + : acc.required || [] + } + + const _getChildTypeName = (childDef: any) => { + return ( + 'Zod' + childDef.type.charAt(0).toUpperCase() + childDef.type.slice(1) + ) + } + + const handleChildSchema = ( + key: string, + childSchema: any, + depth: number, + acc: any + ) => { + const childOpen = zodToOpenApi(childSchema, depth + 1) || {} + const sChild = unwrap(childSchema) as any + const descFromDef = getChildDescription(sChild, childSchema) + const modelProp = + propConfigMap && propConfigMap[key] ? propConfigMap[key] : undefined + + const withDesc = getWithDescription( + childOpen, + modelProp, + descFromDef, + depth + ) + + const childIsEmptyObject = + withDesc && + withDesc.type === 'object' && + (!withDesc.properties || + Object.keys(withDesc.properties).length === 0) && + !withDesc.additionalProperties + + if ( + (!withDesc || + Object.keys(withDesc).length === 0 || + childIsEmptyObject) && + modelProp && + modelProp.propertyType === 'Object' + ) { + const newProp = _checkAB( + modelProp.required, + { type: 'object' }, + { type: 'object', nullable: true } + ) + return { + properties: merge(acc.properties, { [key]: newProp }), + required: _checkAB( + modelProp.required && depth === 0, + (acc.required || []).concat(key), + acc.required || [] + ), + } + } + + const adjusted = getAdjustedProperties(modelProp, withDesc) + + const childDef = (childSchema && + ((childSchema as any)._def || (childSchema as any).def)) as any + const childTypeName = _getChildTypeName(childDef) + const newRequired = _getRequired(childTypeName, acc, key) + + return { + properties: merge(acc.properties, { [key]: adjusted }), + required: newRequired, + } + } + + const reduced = keys.reduce((acc, key) => { + const childSchema = shape[key] + /* + if (!childSchema) { + return acc + } + */ + return handleChildSchema(key, childSchema, depth, acc) + }, {}) + + const out: any = { + type: 'object', + properties: reduced.properties, + additionalProperties: false, + } + const objectDesc = + (defRef && + ((defRef._def && defRef._def.description) || + (defRef.def && defRef.def.description) || + defRef.description)) || + undefined + const finalOut = merge( + {}, + out, + objectDesc ? { description: objectDesc } : {}, + _checkAB( + reduced.required.length && depth === 0, + { required: reduced.required }, + {} + ) + ) + return finalOut + } + + const handleZodUnion = (s: any, defRef: any) => { + const options = defRef && defRef.options + //(defRef._def && defRef._def.options) || + //(s && (s._def as any).options)) + const arr = Array.isArray(options) ? options : Object.values(options || {}) + + const reduced = arr.reduce( + (acc, opt: any) => { + if (!acc.allLiterals) { + return acc + } + const sOpt = unwrap(opt) as any + const dOpt = (sOpt && (sOpt._def || sOpt.def)) as any + const optTypeName = + 'Zod' + dOpt.type.charAt(0).toUpperCase() + dOpt.type.slice(1) + if (optTypeName === 'ZodLiteral' || optTypeName === 'ZodEnum') { + const candidates = [ + dOpt && (dOpt.value !== undefined ? dOpt.value : undefined), + dOpt && + dOpt._def && + (dOpt._def.value !== undefined ? dOpt._def.value : undefined), + sOpt && + sOpt._def && + (sOpt._def.value !== undefined ? sOpt._def.value : undefined), + (sOpt as any) && (sOpt as any).value, + (opt as any) && (opt as any).options, + ] + const val = candidates.find((c: any) => c !== undefined) + if (val !== '__array__') { + return { + allLiterals: true, + literalValues: acc.literalValues.concat(val), + } + } + return { allLiterals: true, literalValues: acc.literalValues } + } + return { allLiterals: false, literalValues: acc.literalValues } + }, + { allLiterals: true, literalValues: [] as any[] } + ) + + if (reduced.allLiterals && reduced.literalValues.length > 0) { + const unique = Array.from(new Set(reduced.literalValues)) + const allStrings = unique.every(v => typeof v === 'string') + if (allStrings) { + return { type: 'string', enum: unique } + } + /* + if (allNumbers) { + return { type: 'number', enum: unique } + } + */ + } + + const optionTypes = arr.map((opt: any) => { + const od = opt && ((opt._def || opt.def) as any) + /* + if (!od) { + return undefined + } + if (od.typeName) { + return od.typeName + } + */ + if (typeof od.type === 'string') { + return 'Zod' + od.type.charAt(0).toUpperCase() + od.type.slice(1) + } + return undefined + }) + + if ( + optionTypes.includes('ZodDate') || + (optionTypes.includes('ZodString') && optionTypes.includes('ZodNumber')) + ) { + return { type: 'string' } + } + + return undefined + } + + const handleZodString = (defRef: any, s: any) => { + const reduced = extractStringBounds(defRef, s) + return merge( + {}, + { type: 'string' }, + typeof reduced.min === 'number' ? { minimum: reduced.min } : {}, + typeof reduced.max === 'number' ? { maximum: reduced.max } : {} + ) + } + + const _handleZodArray = (defRef: any, s: any, depth: number) => { + // Zod stores the item schema in different places depending on version. + // Prefer candidates that look like Zod schema objects (have _def/def). + const candidates = [ + defRef.element, + defRef.inner, + defRef.schema, + defRef.type, + s && s._def && s._def.element, + s && s._def && s._def.inner, + s && s._def && s._def.schema, + ] + const itemsSchema: any = candidates.find( + (c: any) => c && typeof c === 'object' && (c._def || c.def) + ) + + // If we didn't find an object schema, but there's a non-object candidate (string), skip it. + const openItems = itemsSchema ? zodToOpenApi(itemsSchema, depth + 1) : {} + + return { type: 'array', items: openItems } + } + + const getCandidates = (defRef: any, s: any) => { + return [ + s && s._def && (s._def as any).valueType, + s && s._def && (s._def as any).value, + s && s._def && (s._def as any).type, + defRef && defRef.valueType, + defRef && defRef.value, + defRef && defRef.type, + defRef && defRef._def && defRef._def.valueType, + defRef && defRef._def && defRef._def.value, + defRef && defRef._def && defRef._def.type, + defRef && defRef._def && defRef._def.element, + defRef && defRef.element, + ] + } + + const _handleZodRecord = (defRef: any, s: any) => { + const candidates = getCandidates(defRef, s) + const valueType: any = candidates.find((c: any) => c) + const resolved = + valueType && + _checkAB( + valueType._def || valueType.def || typeof valueType === 'object', + valueType, + undefined + ) + return { + type: 'object', + additionalProperties: zodToOpenApi(resolved || z.any()), + } + } + + const zodToOpenApi = (schema: ZodType, depth = 0): any => { + const s = unwrap(schema) as any + const defRef = (s && (s._def || s.def)) as any + const typeName = getTypeName(defRef) + switch (typeName) { + case 'ZodString': + return handleZodString(defRef, s) + case 'ZodNumber': { + const info = extractNumberInfo(defRef, s) + return merge( + {}, + { type: _checkAB(info.isInteger, 'integer', 'number') }, + _checkAB(typeof info.min === 'number', { minimum: info.min }, {}), + _checkAB(typeof info.max === 'number', { maximum: info.max }, {}) + ) + } + /* + case 'ZodBigInt': + return { type: 'integer' } + */ + case 'ZodBoolean': + return { type: 'boolean' } + /* + case 'ZodDate': + return { type: 'string', format: 'date-time' } + */ + case 'ZodLiteral': { + return getLiteralOpenApi(defRef, s) + } + case 'ZodEnum': { + return { type: 'string', enum: getEnumValues(defRef, s) } + } + /* + case 'ZodNativeEnum': { + // Native enum extraction - handle array, object map, or _def enum + const candidates = [ + s && (s as any)._def && (s as any)._def.values, + s && (s as any)._def && (s as any)._def.options, + s && (s as any)._def && (s as any)._def.enum, + (s as any).values, + (s as any).options, + defRef && defRef.values, + defRef && defRef.options, + defRef && defRef.enum, + defRef && defRef._def && defRef._def.enum, + ] + const raw = candidates.find((c: any) => c !== undefined && c !== null) + const values = Array.isArray(raw) ? raw : Object.values(raw || {}) + const unique = Array.from(new Set(values)) + // prefer string enums when possible + const allStrings = unique.every(v => typeof v === 'string') + return allStrings ? { type: 'string', enum: unique } : { enum: unique } + } + */ + case 'ZodArray': { + return _handleZodArray(defRef, s, depth) + } + case 'ZodObject': { + return handleZodObject(defRef, s, depth) + } + case 'ZodUnion': { + return handleZodUnion(defRef, s) + } + case 'ZodRecord': { + return _handleZodRecord(defRef, s) + } + + /* + case 'ZodAny': + //case 'ZodUnknown': + */ + default: + return {} + } + } + + // Build top-level schema + const result = zodToOpenApi(zodSchema) + // Ensure top-level result is an object schema + const normalized = + //!result || result.type !== 'object' + // ? { type: 'object', properties: {}, required: [], ...(result || {}) } + result + + return normalized +} + export { isReferencedProperty, getValueForModelInstance, @@ -353,4 +999,5 @@ export { NULL_ENDPOINT, NULL_METHOD, createZodForProperty, + modelToOpenApi, } diff --git a/test/src/lib.test.ts b/test/src/lib.test.ts index 13183ff..50e4a51 100644 --- a/test/src/lib.test.ts +++ b/test/src/lib.test.ts @@ -1,12 +1,27 @@ import { assert } from 'chai' +import { Model } from '../../src/models' +import { + ArrayProperty, + BooleanProperty, + DateProperty, + DatetimeProperty, + EmailProperty, + IntegerProperty, + ModelReferenceProperty, + NumberProperty, + ObjectProperty, + TextProperty, + PrimaryKeyUuidProperty, +} from '../../src/properties' import { buildValidEndpoint, isModelInstance, populateApiInformation, createZodForProperty, + modelToOpenApi, } from '../../src/lib' import { ApiInfo, ApiMethod } from '../../src/index' -import z from 'zod' +import { z } from 'zod' describe('/src/lib.ts', () => { describe('#populateApiInformation', () => { @@ -340,4 +355,398 @@ describe('/src/lib.ts', () => { assert.equal(schema, zod) }) }) + + describe('#modelToOpenApi()', () => { + it('maps TextProperty to OpenAPI schema via model', () => { + const M = Model({ + pluralName: 'TextModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + field: TextProperty({ + description: 'Description of the field', + required: true, + maxLength: 5, + minLength: 2, + }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + const expected = { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string' }, + field: { + type: 'string', + description: 'Description of the field', + maximum: 5, + minimum: 2, + }, + }, + required: ['id', 'field'], + } + assert.deepEqual(actual, expected) + }) + + it('maps IntegerProperty to OpenAPI schema via model', () => { + const M = Model({ + pluralName: 'IntModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + field: IntegerProperty({ required: true }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + const expected = { + type: 'object', + additionalProperties: false, + properties: { id: { type: 'string' }, field: { type: 'integer' } }, + required: ['id', 'field'], + } + assert.deepEqual(actual, expected) + }) + + it('maps BooleanProperty to OpenAPI schema via model', () => { + const M = Model({ + pluralName: 'BoolModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + field: BooleanProperty({ required: true }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + const expected = { + type: 'object', + additionalProperties: false, + properties: { id: { type: 'string' }, field: { type: 'boolean' } }, + required: ['id', 'field'], + } + assert.deepEqual(actual, expected) + }) + + it('maps DateProperty to OpenAPI schema via model', () => { + const M = Model({ + pluralName: 'DateModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + field: DateProperty({ required: true }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + const expected = { + type: 'object', + additionalProperties: false, + properties: { id: { type: 'string' }, field: { type: 'string' } }, + required: ['id', 'field'], + } + assert.deepEqual(actual, expected) + }) + + it('maps DatetimeProperty to OpenAPI schema via model', () => { + const M = Model({ + pluralName: 'DatetimeModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + field: DatetimeProperty({ required: true }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + const expected = { + type: 'object', + additionalProperties: false, + properties: { id: { type: 'string' }, field: { type: 'string' } }, + required: ['id', 'field'], + } + assert.deepEqual(actual, expected) + }) + + it('maps EmailProperty to OpenAPI schema via model', () => { + const M = Model({ + pluralName: 'EmailModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + field: EmailProperty({ required: true }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + const expected = { + type: 'object', + additionalProperties: false, + properties: { id: { type: 'string' }, field: { type: 'string' } }, + required: ['id', 'field'], + } + assert.deepEqual(actual, expected) + }) + + it('maps NumberProperty to OpenAPI schema via model', () => { + const M = Model({ + pluralName: 'NumberModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + myNumber: NumberProperty({ + required: true, + minValue: 5, + maxValue: 10, + }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + const expected = { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string' }, + myNumber: { type: 'number', minimum: 5, maximum: 10 }, + }, + required: ['id', 'myNumber'], + } + assert.deepEqual(actual, expected) + }) + + it('maps ObjectProperty to OpenAPI schema via model', () => { + const M = Model({ + pluralName: 'ObjectModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + field: ObjectProperty({ required: true }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + const expected = { + type: 'object', + additionalProperties: false, + properties: { id: { type: 'string' }, field: { type: 'object' } }, + required: ['id', 'field'], + } + assert.deepEqual(actual, expected) + }) + + it('maps ModelReferenceProperty to OpenAPI schema via model', () => { + const Ref = Model({ + pluralName: 'Refs', + namespace: 'functional-models-orm-mcp-test', + properties: { id: PrimaryKeyUuidProperty(), name: TextProperty({}) }, + }) + + const M = Model({ + pluralName: 'RefModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + ref: ModelReferenceProperty(() => Ref, { required: true }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + const expected = { + type: 'object', + additionalProperties: false, + properties: { id: { type: 'string' }, ref: { type: 'string' } }, + required: ['id', 'ref'], + } + assert.deepEqual(actual, expected) + }) + + it('allows nullable for non-required ObjectProperty', () => { + const M = Model({ + pluralName: 'NullableModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + meta: ObjectProperty({}), + requiredMeta: ObjectProperty({ required: true }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + // Not required: should have nullable true + assert.deepEqual(actual.properties.meta, { + type: 'object', + nullable: true, + }) + // Required: should not have nullable + assert.deepEqual(actual.properties.requiredMeta, { type: 'object' }) + }) + + it('parses complex model schema with nested objects, arrays, required and optional properties', () => { + const Ref = Model({ + pluralName: 'RefsComplex', + namespace: 'functional-models-orm-mcp-test', + properties: { id: PrimaryKeyUuidProperty(), name: TextProperty({}) }, + }) + + const M = Model({ + pluralName: 'ComplexModels', + namespace: 'functional-models-orm-mcp-test', + description: 'Complex model description', + properties: { + id: PrimaryKeyUuidProperty(), + title: TextProperty({ description: 'title desc', required: true }), + counts: ArrayProperty({ zod: z.array(z.number()), required: true }), + nestedObject: ObjectProperty({ + description: 'nested object desc', + required: true, + zod: z.object({ + nested: z.string().describe('inner desc'), + optionalNested: z.number().optional(), + }), + }), + ref: ModelReferenceProperty(() => Ref, { required: true }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + const expected = { + type: 'object', + description: 'Complex model description', + additionalProperties: false, + properties: { + id: { type: 'string' }, + title: { type: 'string', description: 'title desc' }, + counts: { type: 'array', items: { type: 'number' } }, + nestedObject: { + type: 'object', + description: 'nested object desc', + properties: { + nested: { type: 'string', description: 'inner desc' }, + optionalNested: { type: 'number' }, + }, + additionalProperties: false, + }, + ref: { type: 'string' }, + }, + required: ['id', 'title', 'counts', 'nestedObject', 'ref'], + } + + assert.deepEqual(actual, expected) + }) + + it('maps ZodLiteral to OpenAPI schema via model', () => { + const M = Model({ + pluralName: 'LiteralModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + lit: TextProperty({ zod: z.literal('X'), required: true }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + const expected = { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string' }, + lit: { enum: ['X'], type: 'string' }, + }, + required: ['id', 'lit'], + } + assert.deepEqual(actual, expected) + }) + + it('maps ZodEnum and ZodNativeEnum to OpenAPI schema via model', () => { + enum NativeE { + A = 'a', + B = 'b', + } + + const M = Model({ + pluralName: 'EnumModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + regular: TextProperty({ choices: Object.values(NativeE) }), + en: TextProperty({ zod: z.enum(['ONE', 'TWO']), required: true }), + nen: TextProperty({ zod: z.nativeEnum(NativeE) }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + const expected = { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string' }, + regular: { type: 'string', enum: ['a', 'b'] }, + en: { type: 'string', enum: ['ONE', 'TWO'] }, + nen: { type: 'string', enum: ['a', 'b'] }, + }, + required: ['id', 'en', 'nen'], + } + assert.deepEqual(actual, expected) + }) + + it('maps ZodUnion of string|number to string via model', () => { + const M = Model({ + pluralName: 'UnionModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + u: TextProperty({ + zod: z.union([z.string(), z.number()]), + required: true, + }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + assert.deepEqual(actual.properties.u, { type: 'string' }) + }) + + it('maps ZodRecord to OpenAPI additionalProperties schema', () => { + const M = Model({ + pluralName: 'RecordModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + map: ObjectProperty({ + zod: z.record(z.string(), z.number()), + required: true, + }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + assert.deepEqual(actual.properties.map, { + type: 'object', + additionalProperties: { type: 'number' }, + }) + }) + + it('maps Array of integer numbers to OpenAPI integer items', () => { + const M = Model({ + pluralName: 'ArrayIntModels', + namespace: 'functional-models-orm-mcp-test', + properties: { + id: PrimaryKeyUuidProperty(), + arr: ArrayProperty({ + zod: z.array(z.number().int()), + required: true, + }), + }, + }) + + const actual = modelToOpenApi(M as any) as any + assert.deepEqual(actual.properties.arr, { + type: 'array', + items: { type: 'integer' }, + }) + }) + }) }) From 08509c4e210bcb4a0ddfa23af7dfc064f7894cbb Mon Sep 17 00:00:00 2001 From: Mike Cornwell Date: Thu, 2 Oct 2025 19:43:02 -0400 Subject: [PATCH 6/8] fix(ignore): ignore issue --- src/lib.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib.ts b/src/lib.ts index 95d21bd..69630de 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -731,6 +731,7 @@ const modelToOpenApi = < const out: any = { type: 'object', + // @ts-ignore properties: reduced.properties, additionalProperties: false, } @@ -745,7 +746,9 @@ const modelToOpenApi = < out, objectDesc ? { description: objectDesc } : {}, _checkAB( + // @ts-ignore reduced.required.length && depth === 0, + // @ts-ignore { required: reduced.required }, {} ) From dae5faee09a713135cfeadb2b111a912fe5db32b Mon Sep 17 00:00:00 2001 From: Mike Cornwell Date: Fri, 3 Oct 2025 14:31:42 -0400 Subject: [PATCH 7/8] feat(openapi): remove openapi code --- src/lib.ts | 647 ------------------------------------------- test/src/lib.test.ts | 409 --------------------------- 2 files changed, 1056 deletions(-) diff --git a/src/lib.ts b/src/lib.ts index 69630de..897dff8 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -12,7 +12,6 @@ import { DataDescription, DataValue, ModelInstance, - ModelType, PrimaryKeyType, PropertyConfig, PropertyValidatorComponent, @@ -34,10 +33,6 @@ const NULL_ENDPOINT = 'NULL' const NULL_METHOD = HttpMethods.HEAD const ID_KEY = ':id' -const _checkAB = (check: boolean, a: any, b: any) => { - return check ? a : b -} - const getValueForReferencedModel = async ( modelInstance: ModelInstance, path: string @@ -346,647 +341,6 @@ const createZodForProperty = return finalSchema as ZodType } -const isWrapperType = ( - explicitType: string | undefined, - typeString: string | undefined -) => { - return ( - explicitType === 'ZodOptional' || - explicitType === 'ZodNullable' || - explicitType === 'ZodDefault' || - typeString === 'optional' || - typeString === 'nullable' || - typeString === 'nullish' || - typeString === 'default' - ) -} - -const getInnerType = (defRef: any) => { - return ( - defRef.innerType || - defRef.type || - defRef.schema || - defRef.payload || - defRef.value || - defRef.inner || - (defRef._def && (defRef._def.inner || defRef._def.type)) || - (defRef && defRef.innerType) - ) -} - -const unwrapOnce = (s: any) => { - const defRef = s && (s._def || s.def) - if (!defRef) { - return s - } - const explicitType = - defRef && defRef.typeName ? String(defRef.typeName) : undefined - const typeString = - !explicitType && defRef && typeof defRef.type === 'string' - ? String(defRef.type) - : undefined - const isWrapper = isWrapperType(explicitType, typeString) - if (!isWrapper) { - return s - } - return getInnerType(defRef) || s -} - -const recur = (s: any): any => { - const next = unwrapOnce(s) - return next === s ? s : recur(next) -} - -const modelToOpenApi = < - TData extends DataDescription, - TModel extends ModelType, ->( - model: TModel -) => { - const zodSchema: any = model.getModelDefinition().schema - const modelProps = (model.getModelDefinition() as any).properties || {} - const propConfigMap: Record = Object.keys(modelProps).reduce( - (acc: Record, k) => - merge(acc, { - [k]: merge(modelProps[k].getConfig ? modelProps[k].getConfig() : {}, { - propertyType: modelProps[k].getPropertyType - ? modelProps[k].getPropertyType() - : undefined, - }), - }), - {} - ) - - const unwrap = (schema: ZodType): ZodType => { - // Functional recursive unwrapping of common Zod wrappers. - return recur(schema) as ZodType - } - - // --- small helpers extracted to reduce complexity --- - const getTypeName = (defRef: any) => { - if (!defRef) { - return '' - } - if (typeof defRef.type === 'string') { - return ( - 'Zod' + - String(defRef.type).charAt(0).toUpperCase() + - String(defRef.type).slice(1) - ) - } - if (defRef.typeName) { - return String(defRef.typeName) - } - return '' - } - - const extractStringBounds = (defRef: any, s: any) => { - const checks = - (defRef && defRef.checks) || (s && s._def && (s._def as any).checks) || [] - return Array.isArray(checks) - ? checks.reduce( - (acc: { min?: number; max?: number }, c: any) => { - if (!c || typeof c !== 'object') { - return acc - } - const candidateMin = - (c.kind === 'min' || - c.check === 'min' || - (c.def && c.def.type === 'min')) && - typeof c.value === 'number' - ? c.value - : c.def && typeof c.def.minLength === 'number' - ? c.def.minLength - : undefined - const candidateMax = - (c.kind === 'max' || - c.check === 'max' || - (c.def && c.def.type === 'max')) && - typeof c.value === 'number' - ? c.value - : c.def && typeof c.def.maxLength === 'number' - ? c.def.maxLength - : undefined - return { - min: candidateMin !== undefined ? candidateMin : acc.min, - max: candidateMax !== undefined ? candidateMax : acc.max, - } - }, - {} as { min?: number; max?: number } - ) - : {} - } - - const getCandidateMin = (c: any) => { - return (c.kind === 'min' || - c.check === 'min' || - (c.def && c.def.type === 'min')) && - typeof c.value === 'number' - ? c.value - : c.def && typeof c.def.minValue === 'number' - ? c.def.minValue - : undefined - } - - const getCandidateMax = (c: any) => { - return (c.kind === 'max' || - c.check === 'max' || - (c.def && c.def.type === 'max')) && - typeof c.value === 'number' - ? c.value - : c.def && typeof c.def.maxValue === 'number' - ? c.def.maxValue - : undefined - } - - const isIntegerCheck = (c: any, acc: { isInteger?: boolean }) => { - return ( - acc.isInteger || - (c && - (c.kind === 'int' || - c.check === 'int' || - c === 'int' || - JSON.stringify(c).includes('int'))) - ) - } - - const extractNumberInfo = (defRef: any, s: any) => { - const checks = - (defRef && defRef.checks) || (s && s._def && (s._def as any).checks) || [] - return Array.isArray(checks) - ? checks.reduce( - ( - acc: { min?: number; max?: number; isInteger?: boolean }, - c: any - ) => { - if (!c || typeof c !== 'object') { - return acc - } - const candidateMin = getCandidateMin(c) - const candidateMax = getCandidateMax(c) - const isInt = isIntegerCheck(c, acc) - return { - min: candidateMin !== undefined ? candidateMin : acc.min, - max: candidateMax !== undefined ? candidateMax : acc.max, - isInteger: isInt, - } - }, - {} as { min?: number; max?: number; isInteger?: boolean } - ) - : {} - } - - const getLiteralOpenApi = (defRef: any, s: any) => { - const candidates = [ - defRef && (defRef.value !== undefined ? defRef.value : undefined), - defRef && - defRef._def && - (defRef._def.value !== undefined ? defRef._def.value : undefined), - s && s._def && (s._def.value !== undefined ? s._def.value : undefined), - (s as any) && (s as any).value, - ] - const val: any = candidates.find((c: any) => c !== undefined) - const t = typeof val - if (t === 'string') { - return { type: 'string', enum: [val] } - } - /* - if (t === 'number') { - return { type: 'number', enum: [val] } - } - if (t === 'boolean') { - return { type: 'boolean', enum: [val] } - } - */ - return { enum: [val] } - } - - const getEnumValues = (defRef: any, s: any) => { - const candidates = [ - s && (s as any)._def && (s as any)._def.values, - s && (s as any)._def && (s as any)._def.options, - (s as any).values, - (s as any).options, - defRef && defRef.values, - defRef && defRef.options, - defRef && defRef._def && defRef._def.values, - defRef && defRef._def && defRef._def.options, - ] - const found = candidates.find((c: any) => Array.isArray(c) && c.length > 0) - return Array.isArray(found) ? found : [] - } - - const handleZodObject = (s: any, defRef: any, depth: number) => { - const asAny = s as any - const shape: Record> = asAny && - typeof asAny.shape === 'function' - ? asAny.shape() - : //: defRef && typeof defRef.shape === 'function' - //? defRef.shape() - defRef.shape || defRef.properties || {} - if (!shape || Object.keys(shape).length === 0) { - return { type: 'object' } - } - - const keys = Object.keys(shape || {}) - const getChildDescription = (sChild: any, childSchema: any) => { - return ( - (sChild && - (sChild.description || - (sChild._def && sChild._def.description) || - (sChild.def && sChild.def.description))) || - ((childSchema as any) && - (((childSchema as any)._def && - (childSchema as any)._def.description) || - ((childSchema as any).def && (childSchema as any).def.description))) - ) - } - - const getWithDescription = ( - childOpen: any, - modelProp: any, - descFromDef: any, - depth: number - ) => { - return modelProp && modelProp.description && depth === 0 - ? merge({}, childOpen, { description: modelProp.description }) - : descFromDef - ? merge({}, childOpen, { description: descFromDef }) - : childOpen - } - - const getAdjustedProperties = (modelProp: any, withDesc: any) => { - return modelProp - ? withDesc && - (withDesc.type === 'number' || withDesc.type === 'integer') - ? merge( - {}, - withDesc, - typeof modelProp.minValue === 'number' - ? { minimum: modelProp.minValue } - : {}, - typeof modelProp.maxValue === 'number' - ? { maximum: modelProp.maxValue } - : {} - ) - : withDesc && withDesc.type === 'string' - ? merge( - {}, - withDesc, - typeof modelProp.minLength === 'number' - ? { minimum: modelProp.minLength } - : {}, - typeof modelProp.maxLength === 'number' - ? { maximum: modelProp.maxLength } - : {} - ) - : withDesc - : withDesc - } - - const _getRequired = (childTypeName: string, acc: any, key: string) => { - return childTypeName !== 'ZodOptional' && - childTypeName !== 'ZodNullable' && - childTypeName !== 'ZodDefault' - ? (acc.required || []).concat(key) - : acc.required || [] - } - - const _getChildTypeName = (childDef: any) => { - return ( - 'Zod' + childDef.type.charAt(0).toUpperCase() + childDef.type.slice(1) - ) - } - - const handleChildSchema = ( - key: string, - childSchema: any, - depth: number, - acc: any - ) => { - const childOpen = zodToOpenApi(childSchema, depth + 1) || {} - const sChild = unwrap(childSchema) as any - const descFromDef = getChildDescription(sChild, childSchema) - const modelProp = - propConfigMap && propConfigMap[key] ? propConfigMap[key] : undefined - - const withDesc = getWithDescription( - childOpen, - modelProp, - descFromDef, - depth - ) - - const childIsEmptyObject = - withDesc && - withDesc.type === 'object' && - (!withDesc.properties || - Object.keys(withDesc.properties).length === 0) && - !withDesc.additionalProperties - - if ( - (!withDesc || - Object.keys(withDesc).length === 0 || - childIsEmptyObject) && - modelProp && - modelProp.propertyType === 'Object' - ) { - const newProp = _checkAB( - modelProp.required, - { type: 'object' }, - { type: 'object', nullable: true } - ) - return { - properties: merge(acc.properties, { [key]: newProp }), - required: _checkAB( - modelProp.required && depth === 0, - (acc.required || []).concat(key), - acc.required || [] - ), - } - } - - const adjusted = getAdjustedProperties(modelProp, withDesc) - - const childDef = (childSchema && - ((childSchema as any)._def || (childSchema as any).def)) as any - const childTypeName = _getChildTypeName(childDef) - const newRequired = _getRequired(childTypeName, acc, key) - - return { - properties: merge(acc.properties, { [key]: adjusted }), - required: newRequired, - } - } - - const reduced = keys.reduce((acc, key) => { - const childSchema = shape[key] - /* - if (!childSchema) { - return acc - } - */ - return handleChildSchema(key, childSchema, depth, acc) - }, {}) - - const out: any = { - type: 'object', - // @ts-ignore - properties: reduced.properties, - additionalProperties: false, - } - const objectDesc = - (defRef && - ((defRef._def && defRef._def.description) || - (defRef.def && defRef.def.description) || - defRef.description)) || - undefined - const finalOut = merge( - {}, - out, - objectDesc ? { description: objectDesc } : {}, - _checkAB( - // @ts-ignore - reduced.required.length && depth === 0, - // @ts-ignore - { required: reduced.required }, - {} - ) - ) - return finalOut - } - - const handleZodUnion = (s: any, defRef: any) => { - const options = defRef && defRef.options - //(defRef._def && defRef._def.options) || - //(s && (s._def as any).options)) - const arr = Array.isArray(options) ? options : Object.values(options || {}) - - const reduced = arr.reduce( - (acc, opt: any) => { - if (!acc.allLiterals) { - return acc - } - const sOpt = unwrap(opt) as any - const dOpt = (sOpt && (sOpt._def || sOpt.def)) as any - const optTypeName = - 'Zod' + dOpt.type.charAt(0).toUpperCase() + dOpt.type.slice(1) - if (optTypeName === 'ZodLiteral' || optTypeName === 'ZodEnum') { - const candidates = [ - dOpt && (dOpt.value !== undefined ? dOpt.value : undefined), - dOpt && - dOpt._def && - (dOpt._def.value !== undefined ? dOpt._def.value : undefined), - sOpt && - sOpt._def && - (sOpt._def.value !== undefined ? sOpt._def.value : undefined), - (sOpt as any) && (sOpt as any).value, - (opt as any) && (opt as any).options, - ] - const val = candidates.find((c: any) => c !== undefined) - if (val !== '__array__') { - return { - allLiterals: true, - literalValues: acc.literalValues.concat(val), - } - } - return { allLiterals: true, literalValues: acc.literalValues } - } - return { allLiterals: false, literalValues: acc.literalValues } - }, - { allLiterals: true, literalValues: [] as any[] } - ) - - if (reduced.allLiterals && reduced.literalValues.length > 0) { - const unique = Array.from(new Set(reduced.literalValues)) - const allStrings = unique.every(v => typeof v === 'string') - if (allStrings) { - return { type: 'string', enum: unique } - } - /* - if (allNumbers) { - return { type: 'number', enum: unique } - } - */ - } - - const optionTypes = arr.map((opt: any) => { - const od = opt && ((opt._def || opt.def) as any) - /* - if (!od) { - return undefined - } - if (od.typeName) { - return od.typeName - } - */ - if (typeof od.type === 'string') { - return 'Zod' + od.type.charAt(0).toUpperCase() + od.type.slice(1) - } - return undefined - }) - - if ( - optionTypes.includes('ZodDate') || - (optionTypes.includes('ZodString') && optionTypes.includes('ZodNumber')) - ) { - return { type: 'string' } - } - - return undefined - } - - const handleZodString = (defRef: any, s: any) => { - const reduced = extractStringBounds(defRef, s) - return merge( - {}, - { type: 'string' }, - typeof reduced.min === 'number' ? { minimum: reduced.min } : {}, - typeof reduced.max === 'number' ? { maximum: reduced.max } : {} - ) - } - - const _handleZodArray = (defRef: any, s: any, depth: number) => { - // Zod stores the item schema in different places depending on version. - // Prefer candidates that look like Zod schema objects (have _def/def). - const candidates = [ - defRef.element, - defRef.inner, - defRef.schema, - defRef.type, - s && s._def && s._def.element, - s && s._def && s._def.inner, - s && s._def && s._def.schema, - ] - const itemsSchema: any = candidates.find( - (c: any) => c && typeof c === 'object' && (c._def || c.def) - ) - - // If we didn't find an object schema, but there's a non-object candidate (string), skip it. - const openItems = itemsSchema ? zodToOpenApi(itemsSchema, depth + 1) : {} - - return { type: 'array', items: openItems } - } - - const getCandidates = (defRef: any, s: any) => { - return [ - s && s._def && (s._def as any).valueType, - s && s._def && (s._def as any).value, - s && s._def && (s._def as any).type, - defRef && defRef.valueType, - defRef && defRef.value, - defRef && defRef.type, - defRef && defRef._def && defRef._def.valueType, - defRef && defRef._def && defRef._def.value, - defRef && defRef._def && defRef._def.type, - defRef && defRef._def && defRef._def.element, - defRef && defRef.element, - ] - } - - const _handleZodRecord = (defRef: any, s: any) => { - const candidates = getCandidates(defRef, s) - const valueType: any = candidates.find((c: any) => c) - const resolved = - valueType && - _checkAB( - valueType._def || valueType.def || typeof valueType === 'object', - valueType, - undefined - ) - return { - type: 'object', - additionalProperties: zodToOpenApi(resolved || z.any()), - } - } - - const zodToOpenApi = (schema: ZodType, depth = 0): any => { - const s = unwrap(schema) as any - const defRef = (s && (s._def || s.def)) as any - const typeName = getTypeName(defRef) - switch (typeName) { - case 'ZodString': - return handleZodString(defRef, s) - case 'ZodNumber': { - const info = extractNumberInfo(defRef, s) - return merge( - {}, - { type: _checkAB(info.isInteger, 'integer', 'number') }, - _checkAB(typeof info.min === 'number', { minimum: info.min }, {}), - _checkAB(typeof info.max === 'number', { maximum: info.max }, {}) - ) - } - /* - case 'ZodBigInt': - return { type: 'integer' } - */ - case 'ZodBoolean': - return { type: 'boolean' } - /* - case 'ZodDate': - return { type: 'string', format: 'date-time' } - */ - case 'ZodLiteral': { - return getLiteralOpenApi(defRef, s) - } - case 'ZodEnum': { - return { type: 'string', enum: getEnumValues(defRef, s) } - } - /* - case 'ZodNativeEnum': { - // Native enum extraction - handle array, object map, or _def enum - const candidates = [ - s && (s as any)._def && (s as any)._def.values, - s && (s as any)._def && (s as any)._def.options, - s && (s as any)._def && (s as any)._def.enum, - (s as any).values, - (s as any).options, - defRef && defRef.values, - defRef && defRef.options, - defRef && defRef.enum, - defRef && defRef._def && defRef._def.enum, - ] - const raw = candidates.find((c: any) => c !== undefined && c !== null) - const values = Array.isArray(raw) ? raw : Object.values(raw || {}) - const unique = Array.from(new Set(values)) - // prefer string enums when possible - const allStrings = unique.every(v => typeof v === 'string') - return allStrings ? { type: 'string', enum: unique } : { enum: unique } - } - */ - case 'ZodArray': { - return _handleZodArray(defRef, s, depth) - } - case 'ZodObject': { - return handleZodObject(defRef, s, depth) - } - case 'ZodUnion': { - return handleZodUnion(defRef, s) - } - case 'ZodRecord': { - return _handleZodRecord(defRef, s) - } - - /* - case 'ZodAny': - //case 'ZodUnknown': - */ - default: - return {} - } - } - - // Build top-level schema - const result = zodToOpenApi(zodSchema) - // Ensure top-level result is an object schema - const normalized = - //!result || result.type !== 'object' - // ? { type: 'object', properties: {}, required: [], ...(result || {}) } - result - - return normalized -} - export { isReferencedProperty, getValueForModelInstance, @@ -1002,5 +356,4 @@ export { NULL_ENDPOINT, NULL_METHOD, createZodForProperty, - modelToOpenApi, } diff --git a/test/src/lib.test.ts b/test/src/lib.test.ts index 50e4a51..61bb9e7 100644 --- a/test/src/lib.test.ts +++ b/test/src/lib.test.ts @@ -1,24 +1,9 @@ import { assert } from 'chai' -import { Model } from '../../src/models' -import { - ArrayProperty, - BooleanProperty, - DateProperty, - DatetimeProperty, - EmailProperty, - IntegerProperty, - ModelReferenceProperty, - NumberProperty, - ObjectProperty, - TextProperty, - PrimaryKeyUuidProperty, -} from '../../src/properties' import { buildValidEndpoint, isModelInstance, populateApiInformation, createZodForProperty, - modelToOpenApi, } from '../../src/lib' import { ApiInfo, ApiMethod } from '../../src/index' import { z } from 'zod' @@ -355,398 +340,4 @@ describe('/src/lib.ts', () => { assert.equal(schema, zod) }) }) - - describe('#modelToOpenApi()', () => { - it('maps TextProperty to OpenAPI schema via model', () => { - const M = Model({ - pluralName: 'TextModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - field: TextProperty({ - description: 'Description of the field', - required: true, - maxLength: 5, - minLength: 2, - }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - const expected = { - type: 'object', - additionalProperties: false, - properties: { - id: { type: 'string' }, - field: { - type: 'string', - description: 'Description of the field', - maximum: 5, - minimum: 2, - }, - }, - required: ['id', 'field'], - } - assert.deepEqual(actual, expected) - }) - - it('maps IntegerProperty to OpenAPI schema via model', () => { - const M = Model({ - pluralName: 'IntModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - field: IntegerProperty({ required: true }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - const expected = { - type: 'object', - additionalProperties: false, - properties: { id: { type: 'string' }, field: { type: 'integer' } }, - required: ['id', 'field'], - } - assert.deepEqual(actual, expected) - }) - - it('maps BooleanProperty to OpenAPI schema via model', () => { - const M = Model({ - pluralName: 'BoolModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - field: BooleanProperty({ required: true }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - const expected = { - type: 'object', - additionalProperties: false, - properties: { id: { type: 'string' }, field: { type: 'boolean' } }, - required: ['id', 'field'], - } - assert.deepEqual(actual, expected) - }) - - it('maps DateProperty to OpenAPI schema via model', () => { - const M = Model({ - pluralName: 'DateModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - field: DateProperty({ required: true }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - const expected = { - type: 'object', - additionalProperties: false, - properties: { id: { type: 'string' }, field: { type: 'string' } }, - required: ['id', 'field'], - } - assert.deepEqual(actual, expected) - }) - - it('maps DatetimeProperty to OpenAPI schema via model', () => { - const M = Model({ - pluralName: 'DatetimeModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - field: DatetimeProperty({ required: true }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - const expected = { - type: 'object', - additionalProperties: false, - properties: { id: { type: 'string' }, field: { type: 'string' } }, - required: ['id', 'field'], - } - assert.deepEqual(actual, expected) - }) - - it('maps EmailProperty to OpenAPI schema via model', () => { - const M = Model({ - pluralName: 'EmailModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - field: EmailProperty({ required: true }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - const expected = { - type: 'object', - additionalProperties: false, - properties: { id: { type: 'string' }, field: { type: 'string' } }, - required: ['id', 'field'], - } - assert.deepEqual(actual, expected) - }) - - it('maps NumberProperty to OpenAPI schema via model', () => { - const M = Model({ - pluralName: 'NumberModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - myNumber: NumberProperty({ - required: true, - minValue: 5, - maxValue: 10, - }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - const expected = { - type: 'object', - additionalProperties: false, - properties: { - id: { type: 'string' }, - myNumber: { type: 'number', minimum: 5, maximum: 10 }, - }, - required: ['id', 'myNumber'], - } - assert.deepEqual(actual, expected) - }) - - it('maps ObjectProperty to OpenAPI schema via model', () => { - const M = Model({ - pluralName: 'ObjectModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - field: ObjectProperty({ required: true }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - const expected = { - type: 'object', - additionalProperties: false, - properties: { id: { type: 'string' }, field: { type: 'object' } }, - required: ['id', 'field'], - } - assert.deepEqual(actual, expected) - }) - - it('maps ModelReferenceProperty to OpenAPI schema via model', () => { - const Ref = Model({ - pluralName: 'Refs', - namespace: 'functional-models-orm-mcp-test', - properties: { id: PrimaryKeyUuidProperty(), name: TextProperty({}) }, - }) - - const M = Model({ - pluralName: 'RefModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - ref: ModelReferenceProperty(() => Ref, { required: true }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - const expected = { - type: 'object', - additionalProperties: false, - properties: { id: { type: 'string' }, ref: { type: 'string' } }, - required: ['id', 'ref'], - } - assert.deepEqual(actual, expected) - }) - - it('allows nullable for non-required ObjectProperty', () => { - const M = Model({ - pluralName: 'NullableModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - meta: ObjectProperty({}), - requiredMeta: ObjectProperty({ required: true }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - // Not required: should have nullable true - assert.deepEqual(actual.properties.meta, { - type: 'object', - nullable: true, - }) - // Required: should not have nullable - assert.deepEqual(actual.properties.requiredMeta, { type: 'object' }) - }) - - it('parses complex model schema with nested objects, arrays, required and optional properties', () => { - const Ref = Model({ - pluralName: 'RefsComplex', - namespace: 'functional-models-orm-mcp-test', - properties: { id: PrimaryKeyUuidProperty(), name: TextProperty({}) }, - }) - - const M = Model({ - pluralName: 'ComplexModels', - namespace: 'functional-models-orm-mcp-test', - description: 'Complex model description', - properties: { - id: PrimaryKeyUuidProperty(), - title: TextProperty({ description: 'title desc', required: true }), - counts: ArrayProperty({ zod: z.array(z.number()), required: true }), - nestedObject: ObjectProperty({ - description: 'nested object desc', - required: true, - zod: z.object({ - nested: z.string().describe('inner desc'), - optionalNested: z.number().optional(), - }), - }), - ref: ModelReferenceProperty(() => Ref, { required: true }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - const expected = { - type: 'object', - description: 'Complex model description', - additionalProperties: false, - properties: { - id: { type: 'string' }, - title: { type: 'string', description: 'title desc' }, - counts: { type: 'array', items: { type: 'number' } }, - nestedObject: { - type: 'object', - description: 'nested object desc', - properties: { - nested: { type: 'string', description: 'inner desc' }, - optionalNested: { type: 'number' }, - }, - additionalProperties: false, - }, - ref: { type: 'string' }, - }, - required: ['id', 'title', 'counts', 'nestedObject', 'ref'], - } - - assert.deepEqual(actual, expected) - }) - - it('maps ZodLiteral to OpenAPI schema via model', () => { - const M = Model({ - pluralName: 'LiteralModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - lit: TextProperty({ zod: z.literal('X'), required: true }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - const expected = { - type: 'object', - additionalProperties: false, - properties: { - id: { type: 'string' }, - lit: { enum: ['X'], type: 'string' }, - }, - required: ['id', 'lit'], - } - assert.deepEqual(actual, expected) - }) - - it('maps ZodEnum and ZodNativeEnum to OpenAPI schema via model', () => { - enum NativeE { - A = 'a', - B = 'b', - } - - const M = Model({ - pluralName: 'EnumModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - regular: TextProperty({ choices: Object.values(NativeE) }), - en: TextProperty({ zod: z.enum(['ONE', 'TWO']), required: true }), - nen: TextProperty({ zod: z.nativeEnum(NativeE) }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - const expected = { - type: 'object', - additionalProperties: false, - properties: { - id: { type: 'string' }, - regular: { type: 'string', enum: ['a', 'b'] }, - en: { type: 'string', enum: ['ONE', 'TWO'] }, - nen: { type: 'string', enum: ['a', 'b'] }, - }, - required: ['id', 'en', 'nen'], - } - assert.deepEqual(actual, expected) - }) - - it('maps ZodUnion of string|number to string via model', () => { - const M = Model({ - pluralName: 'UnionModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - u: TextProperty({ - zod: z.union([z.string(), z.number()]), - required: true, - }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - assert.deepEqual(actual.properties.u, { type: 'string' }) - }) - - it('maps ZodRecord to OpenAPI additionalProperties schema', () => { - const M = Model({ - pluralName: 'RecordModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - map: ObjectProperty({ - zod: z.record(z.string(), z.number()), - required: true, - }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - assert.deepEqual(actual.properties.map, { - type: 'object', - additionalProperties: { type: 'number' }, - }) - }) - - it('maps Array of integer numbers to OpenAPI integer items', () => { - const M = Model({ - pluralName: 'ArrayIntModels', - namespace: 'functional-models-orm-mcp-test', - properties: { - id: PrimaryKeyUuidProperty(), - arr: ArrayProperty({ - zod: z.array(z.number().int()), - required: true, - }), - }, - }) - - const actual = modelToOpenApi(M as any) as any - assert.deepEqual(actual.properties.arr, { - type: 'array', - items: { type: 'integer' }, - }) - }) - }) }) From d6c6bea01b657abf42ea7e8f2555d56484262723 Mon Sep 17 00:00:00 2001 From: Mike Cornwell Date: Fri, 3 Oct 2025 14:31:54 -0400 Subject: [PATCH 8/8] chore(version): bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 659f54d..d7a7292 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "functional-models", - "version": "3.5.0", + "version": "3.5.1", "description": "Functional models is ooey gooey framework for building and using awesome models EVERYWHERE.", "main": "index.js", "types": "index.d.ts",