diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index dec08fb..c52780f 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -76,23 +76,88 @@ function extractInlineModelsFromProperties(schema: SchemaObject, results: Model[ } } - // oneOf containing objects — extract the first non-null variant as a model - // This handles: totp: { oneOf: [{ type: object, properties: {...} }, { type: 'null' }] } + // oneOf containing objects — extract every inline object variant as a + // model so each gets its own typed class. Variant 0 keeps the bare + // qualified inline name (e.g. `ApiKeyCreatedDataOwner`); subsequent + // variants are prefixed by their const-discriminator value via + // `nameOneOfVariant` (e.g. `UserApiKeyCreatedDataOwner`). When the + // oneOf doesn't have a const-discriminator (single object variant + null + // for nullable, or single object variant only), only variant 0 is + // extracted and the bare name pattern preserves backward compat. if (fieldSchema.oneOf) { - const objectVariant = fieldSchema.oneOf.find((v) => v.properties && (v.type === 'object' || !v.type)); - if (objectVariant) { + const inlineObjectVariants = fieldSchema.oneOf.filter( + (v) => !v.$ref && v.properties && (v.type === 'object' || !v.type), + ); + if (inlineObjectVariants.length > 0) { const baseName = toPascalCase(fieldName); const modelName = qualifyInlineModelName(baseName, parentName); const existingNames = new Set(results.map((r) => r.name)); - if (!existingNames.has(modelName)) { - results.push(buildInlineModel(modelName, objectVariant)); - extractInlineModelsFromProperties(objectVariant, results, modelName); + const namingDiscProp = deriveOneOfNamingDiscriminator(inlineObjectVariants); + const emittedNames: string[] = []; + for (const variant of inlineObjectVariants) { + const variantName = nameOneOfVariant(variant, modelName, emittedNames, namingDiscProp); + emittedNames.push(variantName); + if (!existingNames.has(variantName)) { + existingNames.add(variantName); + results.push(buildInlineModel(variantName, variant)); + extractInlineModelsFromProperties(variant, results, variantName); + } } } } } } +/** Find a single string-const-valued property shared by every variant whose + * values are all distinct — the implicit discriminator. Returns null when no + * such property exists. Mirrors `deriveConstNamingDiscriminator` in schemas.ts. */ +function deriveOneOfNamingDiscriminator(variants: SchemaObject[]): string | null { + if (variants.length < 2) return null; + const candidates = Object.keys(variants[0]?.properties ?? {}); + for (const propName of candidates) { + const values = variants.map((v) => readConstString(v.properties?.[propName])); + if (values.some((v) => v === null)) continue; + if (new Set(values).size !== values.length) continue; + return propName; + } + return null; +} + +function readConstString(prop: SchemaObject | undefined): string | null { + if (!prop) return null; + if (typeof prop.const === 'string') return prop.const; + if (Array.isArray(prop.enum) && prop.enum.length === 1 && typeof prop.enum[0] === 'string') { + return prop.enum[0]; + } + return null; +} + +/** Produce a per-variant model name. Variant 0 keeps the bare parent name; + * later variants are prefixed by the const-derived label. Mirrors + * `nameVariantModel` in schemas.ts. Falls back to a numeric suffix when no + * discriminator is available, the const value PascalCases to nothing, or + * the derived candidate collides with the parent or an already-emitted name. */ +function nameOneOfVariant( + variant: SchemaObject, + parentName: string, + alreadyEmitted: string[], + discriminatorProperty: string | null, +): string { + if (alreadyEmitted.length === 0) return parentName; + if (discriminatorProperty) { + const constValue = readConstString(variant.properties?.[discriminatorProperty]); + if (constValue) { + const prefix = toPascalCase(constValue); + if (prefix) { + const candidate = parentName.startsWith(prefix) ? parentName : `${prefix}${parentName}`; + const collision = candidate === parentName || alreadyEmitted.includes(candidate); + if (!collision) return candidate; + } + } + } + return `${parentName}${alreadyEmitted.length + 1}`; +} + function buildInlineModel(name: string, schema: SchemaObject): Model { const requiredSet = new Set(schema.required ?? []); const fields: Field[] = []; diff --git a/src/parser/schemas.ts b/src/parser/schemas.ts index 5b76d8c..a639b83 100644 --- a/src/parser/schemas.ts +++ b/src/parser/schemas.ts @@ -512,6 +512,59 @@ function resolveVariantModelName(schema: SchemaObject): string | null { return null; } +/** Look up the discriminator's mapped variant name for an inline object + * variant by reading its const-valued discriminator property. Returns null + * when the variant doesn't pin the property to a string const, or when + * the value isn't in the mapping. */ +function mapVariantToDiscriminatorEntry( + variant: SchemaObject, + discriminator: { property: string; mapping: Record }, +): string | null { + const value = getConstPropertyValue(variant, discriminator.property); + if (value === null) return null; + return discriminator.mapping[value] ?? null; +} + +/** + * Derive a discriminator value → variant model name mapping for an inline- + * variant `oneOf` whose explicit `discriminator:` has no `mapping:` clause. + * + * Each variant model name is reproduced via `nameVariantModel` so it matches + * what `extractNestedSchema` registered when it walked the same `oneOf` to + * pull variants out as named models. Variant 0 keeps the bare parent name + * (e.g. `ApiKeyOwner`); subsequent variants get a const-derived prefix + * (e.g. `UserApiKeyOwner` for the `type: const: user` variant). + * + * The "parent name" used here mirrors the one `collectNestedInlineModels` + * passes to `extractNestedSchema`: it's the qualified inline model name + * built from `contextName` (the field name) plus `parentModelName` (the + * containing model). Returns null when any non-object variant is present + * or when a variant lacks a const value on the discriminator property. + */ +function deriveInlineVariantMapping( + variants: SchemaObject[], + discriminatorProperty: string, + contextName: string | undefined, + parentModelName: string | undefined, +): Record | null { + if (variants.length === 0 || !contextName) return null; + const inlineObjectVariants = variants.filter((v) => !v.$ref && v.properties && (v.type === 'object' || !v.type)); + if (inlineObjectVariants.length !== variants.length) return null; + + const baseInlineName = qualifyInlineModelName(toPascalCase(contextName), parentModelName); + const namingDiscriminator = { property: discriminatorProperty }; + const mapping: Record = {}; + const emittedSoFar: Model[] = []; + for (const variant of inlineObjectVariants) { + const constValue = getConstPropertyValue(variant, discriminatorProperty); + if (constValue === null) return null; + const variantName = nameVariantModel(variant, baseInlineName, emittedSoFar, namingDiscriminator); + mapping[constValue] = variantName; + emittedSoFar.push({ name: variantName, fields: [] }); + } + return mapping; +} + function extractEnum(name: string, schema: SchemaObject): Enum { const values: EnumValue[] = (schema.enum ?? []).map((v) => ({ name: toUpperSnakeCase(String(v)), @@ -779,9 +832,18 @@ function extractInlineModelDeep(name: string, schema: SchemaObject): Model[] { } if (fieldSchema.oneOf) { - const objectVariant = fieldSchema.oneOf.find((v) => v.properties && (v.type === 'object' || !v.type)); - if (objectVariant) { - nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), objectVariant)); + const inlineObjectVariants = fieldSchema.oneOf.filter( + (v) => !v.$ref && v.properties && (v.type === 'object' || !v.type), + ); + if (inlineObjectVariants.length > 0) { + const baseQualified = qualifyNestedInlineName(name, fieldName); + const namingDisc = deriveConstNamingDiscriminator(inlineObjectVariants); + const emitted: Model[] = []; + for (const variant of inlineObjectVariants) { + const variantName = nameVariantModel(variant, baseQualified, emitted, namingDisc); + emitted.push({ name: variantName, fields: [] }); + nestedModels.push(...extractInlineModelDeep(variantName, variant)); + } } } } @@ -906,37 +968,81 @@ export function schemaToTypeRef(schema: SchemaObject, contextName?: string, pare return nullVariant ? { kind: 'nullable', inner: enumRef } : enumRef; } - // Synthesize a discriminator when all non-null variants are objects that - // share a property whose schema carries a `const` value. Covers the - // EventSchema-style pattern where each oneOf variant pins `event: - // const: "..."` instead of the spec using an explicit `discriminator:`. - const syntheticDiscriminator = - !schema.discriminator && compositionKind === 'oneOf' ? detectConstPropertyDiscriminator(nonNullVariants) : null; - - // General union - const variants = rawVariants - .filter((v: SchemaObject) => v.type !== 'null') - .map((v: SchemaObject) => schemaToTypeRef(v, contextName, parentModelName)); + // Resolve the discriminator. Three sources, in order: + // 1. Explicit `discriminator:` with explicit `mapping:` — trust the spec. + // 2. Explicit `discriminator:` with no `mapping:` — derive mapping from + // variants' const values on the discriminator property, pairing each + // to its inline-extracted variant model name (built the same way + // `extractNestedSchema` names them via `nameVariantModel`). This + // covers `ApiKey.owner`-shaped schemas where the spec uses + // `discriminator: { propertyName: type }` but lists inline anonymous + // `oneOf` variants instead of `$ref` links. + // 3. No explicit discriminator but every variant pins the same const- + // valued property AND the variants are nameable via $ref/title — + // `detectConstPropertyDiscriminator` covers this. + // 4. As a last resort, when (3) doesn't apply because variants are + // inline anonymous objects, derive the same const → variant-model- + // name mapping using `nameVariantModel`. This covers + // `ApiKeyCreatedData.owner`-shaped schemas (oneOf with inline + // const-discriminating variants but no `discriminator:` keyword). + let resolvedDiscriminator: { property: string; mapping: Record } | undefined; + if (schema.discriminator) { + const property = schema.discriminator.propertyName; + const explicitMapping = schema.discriminator.mapping ?? {}; + let mapping: Record = Object.fromEntries( + Object.entries(explicitMapping).map(([k, v]) => [k, v.replace(/^#\/components\/schemas\//, '')]), + ); + if (Object.keys(mapping).length === 0) { + const inlineMapping = deriveInlineVariantMapping(nonNullVariants, property, contextName, parentModelName); + if (inlineMapping) mapping = inlineMapping; + } + resolvedDiscriminator = { property, mapping }; + } else if (compositionKind === 'oneOf') { + const synthetic = detectConstPropertyDiscriminator(nonNullVariants); + if (synthetic) { + resolvedDiscriminator = synthetic; + } else { + const namingDisc = deriveConstNamingDiscriminator(nonNullVariants); + if (namingDisc) { + const inlineMapping = deriveInlineVariantMapping( + nonNullVariants, + namingDisc.property, + contextName, + parentModelName, + ); + if (inlineMapping) { + resolvedDiscriminator = { property: namingDisc.property, mapping: inlineMapping }; + } + } + } + } + + // Build variants. When we have a discriminator with a known mapping and + // every non-null variant is an inline object that pins the discriminator + // property to a const, route each variant's TypeRef through that mapping. + // This avoids the degenerate case where `qualifyInlineModelName` would + // assign the same parent-derived name (e.g. `ApiKeyOwner`) to every + // inline variant, collapsing the union to a single repeated model ref. + const variants: TypeRef[] = []; + for (const v of rawVariants) { + if (v === nullVariant) continue; + const inlineMappingName = + resolvedDiscriminator && !v.$ref && v.properties && (v.type === 'object' || !v.type) + ? mapVariantToDiscriminatorEntry(v, resolvedDiscriminator) + : null; + if (inlineMappingName) { + variants.push({ kind: 'model', name: inlineMappingName }); + } else { + variants.push(schemaToTypeRef(v, contextName, parentModelName)); + } + } const hasNull = !!nullVariant; + const union: TypeRef = { kind: 'union', variants, compositionKind, - ...(schema.discriminator - ? { - discriminator: { - property: schema.discriminator.propertyName, - mapping: Object.fromEntries( - Object.entries(schema.discriminator.mapping ?? {}).map(([k, v]) => [ - k, - v.replace(/^#\/components\/schemas\//, ''), - ]), - ), - }, - } - : syntheticDiscriminator - ? { discriminator: syntheticDiscriminator } - : {}), + ...(resolvedDiscriminator ? { discriminator: resolvedDiscriminator } : {}), }; return hasNull ? { kind: 'nullable', inner: union } : union; }