// DTO fields that hold more than one value: a Kotlin List or Set (ARRAY), a // Map (MAP), a nested class (OBJECT) or Any (ANY), as GSF 8.15.29 describes // and checks them (genesis-process metadata/pal: PALMetadataUtils.kt // mapPropertyType, common/MetaFieldType.kt, jsonschema/JsonSchema.kt): // - META: the type sits in the field's FIELD entry (MetaField's // @JsonUnwrapped type), an ARRAY's items and a MAP's values as VALUE_TYPE, // a nested class as a REFERENCE to its DEFINITIONS entry, keyed by its // class name; // - JSON schema: a DETAILS property through toJsonSchemaType, a nested // class in $defs through mapJsonSchemaProperty, which orders keys // differently, puts null first in a nullable's oneOf and puts an array's // uniqueItems inside its items. // A DESCRIPTION / description is the property's class: kotlin.collections.Set // / List / Map, kotlin.Any, or the nested class's name. import type { FieldDef, FieldTypeDef, Row } from '../types.ts'; import { eventMetaTypeOf, jsonTypeOf, jvmClassOf } from './fieldTypes.ts'; export const isNestedType = (type: string) => type === 'ARRAY' || type === 'MAP' || type === 'OBJECT' || type === 'ANY'; // A type as declared (FieldTypeDef) or resolved (schema.ts ResolvedField). export interface TypeShape { type: string; nullable?: boolean; values?: string[]; maxSize?: number; minLength?: number; description?: string; items?: FieldTypeDef; unique?: boolean; className?: string; fields?: FieldDef[]; enumClass?: string; } const ANY: TypeShape = { type: 'ANY' }; // A nested class: its name and its fields. export interface NestedClass { className: string; fields: FieldDef[]; } // The class a nested field's DESCRIPTION names. export function nestedClassOf(def: TypeShape): string { if (def.type === 'ARRAY') return def.unique ? 'kotlin.collections.Set' : 'kotlin.collections.List'; if (def.type === 'MAP') return 'kotlin.collections.Map'; if (def.type === 'OBJECT') return def.className ?? 'kotlin.Any'; return 'kotlin.Any'; } // Every nested class the fields reach, the first time each is met, as GSF // adds them to the definitions while it walks the class (depth first). export function nestedClasses(defs: TypeShape[], found = new Map()) { for (const def of defs) { if (def.type === 'OBJECT' && def.className && !found.has(def.className)) { found.set(def.className, { className: def.className, fields: def.fields ?? [] }); nestedClasses(def.fields ?? [], found); } if (def.items) nestedClasses([def.items], found); } return found; } const simpleName = (className: string) => className.slice(className.lastIndexOf('.') + 1); // 'NotifyRoute' -> 'NOTIFY_ROUTE' (toUpperUnderscoreWithAcronyms). export function upperUnderscore(name: string): string { return name .replace(/([a-z0-9])([A-Z])/g, '$1_$2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') .toUpperCase(); } // --- META ---------------------------------------------------------------------- // A type as META writes it unwrapped: NULLABLE, the type's own fields, TYPE // and JSON_TYPE. Any is always nullable. export function metaType(def: TypeShape): Row { const NULLABLE = def.type === 'ANY' ? true : (def.nullable ?? false); switch (def.type) { case 'ARRAY': return { NULLABLE, VALUE_TYPE: metaType(def.items ?? ANY), UNIQUE: def.unique ?? false, TYPE: 'ARRAY', JSON_TYPE: 'array', }; case 'MAP': return { NULLABLE, KEY_TYPE: { NULLABLE: false, TYPE: 'STRING', JSON_TYPE: 'string' }, VALUE_TYPE: metaType(def.items ?? ANY), TYPE: 'MAP', JSON_TYPE: 'object', }; case 'OBJECT': if (!def.className) return metaType(ANY); return { NULLABLE, REFERENCE: def.className, TYPE: 'OBJECT', JSON_TYPE: 'object' }; // MetaFieldType.AnyType, an object with no constructor: Jackson writes its // properties in declaration order, its null default included (as a // recorded SYSTEM_ENTITY_FIELDS META shows). case 'ANY': return { TYPE: 'ANY', JSON_TYPE: jsonTypeOf('ANY'), NULLABLE, DEFAULT_VALUE: null }; case 'ENUM': return { NULLABLE, ...(def.values?.length ? { VALID_VALUES: def.values, DEFAULT_VALUE: def.values[0] } : {}), ENUM_CLASS: def.enumClass ?? 'kotlin.Enum', TYPE: 'ENUM', JSON_TYPE: 'string', }; default: return { NULLABLE, TYPE: eventMetaTypeOf(def.type), JSON_TYPE: jsonTypeOf(def.type) }; } } // DEFINITIONS: each nested class as {NAME, DESCRIPTION, PROPERTIES}, its // properties written as the event's own FIELD entries are. export function metaDefinitions( classes: Map, properties: (nested: NestedClass) => Row[], ): Row { return Object.fromEntries( [...classes.values()].map((nested) => [ nested.className, { NAME: upperUnderscore(simpleName(nested.className)), DESCRIPTION: nested.className, PROPERTIES: properties(nested), }, ]), ); } // --- JSON schema: DETAILS properties (toJsonSchemaType) ----------------------- const ref = (className: string) => ({ $ref: `#/$defs/${className}` }); const orNull = (schema: Row, nullable: boolean | undefined) => nullable ? { oneOf: [schema, { type: 'null' }] } : schema; // The bare type: an array's items or a map's values, with no annotations. function itemSchema(def: TypeShape): Row { switch (def.type) { case 'ARRAY': return orNull( { items: itemSchema(def.items ?? ANY), uniqueItems: def.unique ?? false, type: 'array' }, def.nullable, ); case 'MAP': return orNull({ additionalProperties: true, type: 'object' }, def.nullable); case 'OBJECT': return def.className ? orNull(ref(def.className), def.nullable) : itemSchema(ANY); case 'ANY': return anySchema({}); case 'ENUM': return orNull( { ...(def.values?.length ? { enum: def.values } : {}), type: 'string' }, def.nullable, ); case 'BIGDECIMAL': return orNull({ pattern: '[0-9]+(\\.[0-9]+)?', type: 'string' }, def.nullable); default: return orNull({ type: jsonTypeOf(def.type) }, def.nullable); } } // Any: an object, string, number or boolean, and null. function anySchema(annotations: Row): Row { return { oneOf: [ { oneOf: ['object', 'string', 'number', 'boolean'].map((type) => ({ ...annotations, type })), }, { type: 'null' }, ], }; } // A DETAILS property whose type is nested. export function nestedProperty(def: TypeShape): Row { const description = nestedClassOf(def); switch (def.type) { case 'ARRAY': return orNull( { items: itemSchema(def.items ?? ANY), uniqueItems: def.unique ?? false, readOnly: false, description, type: 'array', }, def.nullable, ); case 'MAP': return orNull({ additionalProperties: true, type: 'object' }, def.nullable); case 'OBJECT': return def.className ? orNull(ref(def.className), def.nullable) : nestedProperty(ANY); default: return anySchema({ readOnly: false, description }); } } // --- decoding ---------------------------------------------------------------------- const isRecord = (value: unknown): value is Row => typeof value === 'object' && value !== null && !Array.isArray(value); // A nested value as Jackson decodes it into its class: the keys a nested // class doesn't declare dropped (GSF's mapper doesn't fail on unknown // properties, GenesisJacksonMapper.kt:260 @v8.15.29), in its lists and maps // too. The value itself when nothing is dropped. export function decodedValue(def: TypeShape, value: unknown): unknown { if (def.type === 'OBJECT' && def.className && isRecord(value)) { const fields = new Map((def.fields ?? []).map((field) => [field.name, field])); let changed = false; const decoded: Row = {}; for (const [key, item] of Object.entries(value)) { const field = fields.get(key); if (!field) { changed = true; continue; } decoded[key] = decodedValue(field, item); changed ||= decoded[key] !== item; } return changed ? decoded : value; } const items = def.items; if (def.type === 'ARRAY' && items && Array.isArray(value)) { const decoded = value.map((item) => decodedValue(items, item)); return decoded.some((item, index) => item !== value[index]) ? decoded : value; } if (def.type === 'MAP' && items && isRecord(value)) { const decoded = Object.fromEntries( Object.entries(value).map(([key, item]) => [key, decodedValue(items, item)]), ); return Object.keys(value).some((key) => decoded[key] !== value[key]) ? decoded : value; } return value; } // --- JSON schema: $defs (mapJsonSchemaProperty) -------------------------------- function defsType(def: TypeShape, nullable: boolean): Row { if (nullable) return { oneOf: [{ type: 'null' }, defsType(def, false)] }; const type = def.type; const schema: Row = { type: jsonTypeOf(type) }; if (type === 'STRING') { if (def.maxSize !== undefined) schema.maxLength = def.maxSize; if (def.minLength !== undefined) schema.minLength = def.minLength; } if (type === 'OBJECT') { if (def.className) schema.$ref = `#/$defs/${def.className}`; else schema.additionalProperties = true; } if (type === 'MAP' && def.items && def.items.type !== 'ANY') { schema.additionalProperties = defsType(def.items, typeNullable(def.items)); } if (type === 'ENUM' && def.values?.length) schema.enum = def.values; if (type === 'ARRAY') { const items = def.items ?? ANY; schema.items = { ...defsType(items, typeNullable(items)), ...(def.unique ? { uniqueItems: true } : {}), }; } return schema; } const typeNullable = (def: TypeShape) => (def.type === 'ANY' ? true : (def.nullable ?? false)); // $defs: each nested class as an object schema of its properties, closed to // any other (no class here carries @AdditionalProperties), requiring the // ones that aren't optional. export function jsonDefs( classes: Map, resolve: (nested: NestedClass) => Array, ): Row { return Object.fromEntries( [...classes.values()].map((nested) => { const properties = resolve(nested); const required = properties.filter((field) => !field.optional).map((field) => field.name); return [ nested.className, { type: 'object', description: nested.className, properties: Object.fromEntries( properties.map((field) => [ field.name, { ...defsType(field, typeNullable(field)), description: isNestedType(field.type) ? nestedClassOf(field) : (field.description ?? jvmClassOf(field.type, () => field.enumClass ?? 'kotlin.Enum')), }, ]), ), additionalProperties: false, ...(required.length > 0 ? { required } : {}), }, ]; }), ); }