diff --git a/packages/core/src/getters/combine.test.ts b/packages/core/src/getters/combine.test.ts index 2a35f40632..04d5b1f308 100644 --- a/packages/core/src/getters/combine.test.ts +++ b/packages/core/src/getters/combine.test.ts @@ -38,6 +38,10 @@ const context = { name: { type: 'string' }, }, }, + Status: { + type: 'string', + enum: ['new', 'in_progress'], + }, }, }, }, @@ -85,6 +89,185 @@ describe('combineSchemas (allOf required handling)', () => { expect(result.value).toContain('Required { + it('flags anyOf [enum, null] as a nullable enum', () => { + const schema: OpenApiSchemaObject = { + anyOf: [{ enum: ['new', 'in_progress'] }, { type: 'null' }], + }; + + const result = combineSchemas({ + schema, + name: 'Status', + separator: 'anyOf', + context, + nullable: '', + }); + + expect(result.isEnum).toBe(true); + expect(result.value).toContain(`'new' | 'in_progress'`); + expect(result.value).toContain('null'); + }); + + it('flags oneOf [enum, null] as a nullable enum', () => { + const schema: OpenApiSchemaObject = { + oneOf: [{ enum: ['new', 'in_progress'] }, { type: 'null' }], + }; + + const result = combineSchemas({ + schema, + name: 'Status', + separator: 'oneOf', + context, + nullable: '', + }); + + expect(result.isEnum).toBe(true); + expect(result.value).toContain(`'new' | 'in_progress'`); + expect(result.value).toContain('null'); + }); + + // Detection must not depend on the order of subschemas — `{type: 'null'}` + // can appear before or after the enum. + it('flags anyOf [null, enum] (null first) as a nullable enum', () => { + const schema: OpenApiSchemaObject = { + anyOf: [{ type: 'null' }, { enum: ['new', 'in_progress'] }], + }; + + const result = combineSchemas({ + schema, + name: 'Status', + separator: 'anyOf', + context, + nullable: '', + }); + + expect(result.isEnum).toBe(true); + }); + + // The pattern is type-agnostic: numeric enums combined with null should + // also be recognized. + it('flags anyOf [numeric enum, null] as a nullable enum', () => { + const schema: OpenApiSchemaObject = { + anyOf: [{ type: 'integer', enum: [1, 2, 3] }, { type: 'null' }], + }; + + const result = combineSchemas({ + schema, + name: 'Code', + separator: 'anyOf', + context, + nullable: '', + }); + + expect(result.isEnum).toBe(true); + expect(result.value).toContain('1 | 2 | 3'); + expect(result.value).toContain('null'); + }); + + // Pin behavior for the multi-enum + null variant. Each enum branch + // contributes its values; the result is still a nullable enum union. + it('flags multiple inline enums + null as a nullable enum', () => { + const schema: OpenApiSchemaObject = { + anyOf: [{ enum: ['a', 'b'] }, { enum: ['c', 'd'] }, { type: 'null' }], + }; + + const result = combineSchemas({ + schema, + name: 'Status', + separator: 'anyOf', + context, + nullable: '', + }); + + expect(result.isEnum).toBe(true); + expect(result.value).toContain(`'a' | 'b'`); + expect(result.value).toContain(`'c' | 'd'`); + expect(result.value).toContain('null'); + }); + + // Negative: a plain nullable string (no enum) must stay a generic union + // and not be flagged as an enum. This is the case the existing + // query-params.test.ts:169 test already pins at the integration level. + it('does not flag anyOf [non-enum scalar, null] as a nullable enum', () => { + const schema: OpenApiSchemaObject = { + anyOf: [{ type: 'string', format: 'uuid' }, { type: 'null' }], + }; + + const result = combineSchemas({ + schema, + name: 'AffiliationId', + separator: 'anyOf', + context, + nullable: '', + }); + + expect(result.isEnum).toBe(false); + }); + + // Negative: a `$ref` branch already resolves to an existing named enum + // schema. Treating this composition as an inline-enum would route the + // caller through `getEnum`, which emits a parallel const that nests the + // original ref (e.g. `{Status: Status}`) instead of reusing it. + it('does not flag anyOf [$ref enum, null] as a nullable enum', () => { + const schema: OpenApiSchemaObject = { + anyOf: [{ $ref: '#/components/schemas/Status' }, { type: 'null' }], + }; + + const result = combineSchemas({ + schema, + name: 'Status', + separator: 'anyOf', + context, + nullable: '', + }); + + expect(result.isEnum).toBe(false); + }); + + // Negative: `allOf` is an intersection, not a union. `allOf: [{enum}, {null}]` + // is semantically empty (no value can satisfy both); regardless, it must + // not be misclassified as a nullable enum union. + it('does not flag allOf [enum, null] as a nullable enum', () => { + const schema: OpenApiSchemaObject = { + allOf: [{ enum: ['new', 'in_progress'] }, { type: 'null' }], + }; + + const result = combineSchemas({ + schema, + name: 'Status', + separator: 'allOf', + context, + nullable: '', + }); + + expect(result.isEnum).toBe(false); + }); + + // Negative: an enum combined with a non-null scalar is a genuine union, + // not a nullable enum. Extracting it as a named enum would change the + // semantics (the other branch's values would be lost). + it('does not flag anyOf [enum, non-null scalar] as a nullable enum', () => { + const schema: OpenApiSchemaObject = { + anyOf: [{ enum: ['new', 'in_progress'] }, { type: 'string' }], + }; + + const result = combineSchemas({ + schema, + name: 'Status', + separator: 'anyOf', + context, + nullable: '', + }); + + expect(result.isEnum).toBe(false); + }); + }); + it('normalizes inline object in allOf to match parent object form', () => { const variantA: OpenApiSchemaObject = { allOf: [ diff --git a/packages/core/src/getters/combine.ts b/packages/core/src/getters/combine.ts index 61b8aebce8..f2b53f9dfc 100644 --- a/packages/core/src/getters/combine.ts +++ b/packages/core/src/getters/combine.ts @@ -344,6 +344,32 @@ export function combineSchemas({ } const isAllEnums = resolvedData.isEnum.every(Boolean); + // OAS 3.1 spells a nullable enum as `anyOf: [{enum: [...]}, {type: 'null'}]`. + // Without this, the {type: 'null'} variant flips `isEnum` to false and the + // enum gets inlined instead of extracted as a named type — the + // {type: ['string','null'], enum: [...]} spelling already extracts. Treat + // null-only variants as transparent so the caller's `isEnum && !isRef` + // branch (query-params, schema-definition, resolvers/object) extracts via + // getEnum, whose `stripNullUnion` handling already preserves the trailing + // ` | null`. See issue #2710. + // + // Guards: + // - `allOf` semantics are intersection, not union — `allOf: [{enum}, {null}]` + // does not describe a nullable enum, so restrict to `anyOf`/`oneOf`. + // - Non-null branches must be inline enums (`!isRef`). For `$ref + null` + // the existing referenced enum should be reused; routing through + // `getEnum` would emit a parallel const that nests the original ref + // (e.g. `{Status: Status}`) instead of spreading or aliasing it. + const isUnionLikeSeparator = separator === 'anyOf' || separator === 'oneOf'; + const isNullableEnumComposition = + isUnionLikeSeparator && + !isAllEnums && + resolvedData.isEnum.some(Boolean) && + resolvedData.isEnum.every( + (isEnum, index) => + (isEnum && !resolvedData.isRef[index]) || + resolvedData.types[index] === 'null', + ); const isAvailableToGenerateCombinedEnum = isAllEnums && name && @@ -460,7 +486,7 @@ export function combineSchemas({ dependencies: resolvedValue ? [...resolvedData.dependencies, ...resolvedValue.dependencies] : resolvedData.dependencies, - isEnum: false, + isEnum: isNullableEnumComposition, type: 'object' as SchemaType, isRef: false, hasReadonlyProps: diff --git a/packages/core/src/getters/query-params.test.ts b/packages/core/src/getters/query-params.test.ts index 3d726d052b..38a0b919bb 100644 --- a/packages/core/src/getters/query-params.test.ts +++ b/packages/core/src/getters/query-params.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createTestContextSpec } from '../test-utils/context'; -import type { OpenApiParameterObject } from '../types'; +import { EnumGeneration, type OpenApiParameterObject } from '../types'; import { getQueryParams } from './query-params'; // Fully-typed context via the shared factory — no unsafe cast, so missing @@ -198,6 +198,194 @@ describe('getQueryParams getter', () => { ); }); + // OpenAPI 3.1 lets users express a nullable enum either as + // `{type: ['string','null'], enum: [...]}` (already extracted) or as + // `anyOf: [{enum: [...]}, {type: 'null'}]`. Both spellings should produce + // a named parameter type. See issue #2710. + it('queryParam with anyOf containing enum and null extracts a named nullable enum type', () => { + const result = getQueryParams({ + queryParams: [ + { + parameter: { + name: 'status', + in: 'query', + required: false, + schema: { + anyOf: [{ enum: ['new', 'in_progress'] }, { type: 'null' }], + title: 'Status', + }, + }, + imports: [], + }, + ], + operationName: '', + context, + }); + + expect(result?.schema.model.trim()).toBe( + `export type Params = {\nstatus?: Status;\n};`, + ); + + const statusEnum = result?.deps.find((schema) => schema.name === 'Status'); + expect(statusEnum).toBeDefined(); + expect(statusEnum?.model).toContain(`'new' | 'in_progress' | null`); + }); + + // Under enumGenerationType: 'const', getEnum's stripNullUnion handling moves + // `| null` off the const body onto the type alias, producing the same + // typeof+const pattern as a non-nullable enum. Pins that #2710's fix routes + // through that same helper rather than emitting a broken `null: null` member. + it('queryParam with anyOf [enum, null] under const enum mode emits typeof+const pattern', () => { + const constContext = createTestContextSpec({ + override: { + enumGenerationType: EnumGeneration.CONST, + }, + }); + + const result = getQueryParams({ + queryParams: [ + { + parameter: { + name: 'status', + in: 'query', + required: false, + schema: { + anyOf: [{ enum: ['new', 'in_progress'] }, { type: 'null' }], + }, + }, + imports: [], + }, + ], + operationName: '', + context: constContext, + }); + + expect(result?.schema.model.trim()).toBe( + `export type Params = {\nstatus?: Status;\n};`, + ); + + const statusEnum = result?.deps.find((schema) => schema.name === 'Status'); + expect(statusEnum).toBeDefined(); + expect(statusEnum?.model).toContain( + `export type Status = typeof Status[keyof typeof Status] | null;`, + ); + expect(statusEnum?.model).toContain(`export const Status = {`); + // The const body must contain the enum members (not just the wrapper), + // so we know the values made it across the nullable composition. + expect(statusEnum?.model).toContain(`new:`); + expect(statusEnum?.model).toContain(`in_progress:`); + // The null variant must not leak into the const body as a `null: null` + // member — that would emit invalid TypeScript. + expect(statusEnum?.model).not.toContain('null: null'); + }); + + // Parallel integration coverage for `oneOf`. Same processing path as anyOf + // but worth pinning so future combine.ts refactors don't accidentally + // narrow the fix. + it('queryParam with oneOf containing enum and null extracts a named nullable enum type', () => { + const result = getQueryParams({ + queryParams: [ + { + parameter: { + name: 'priority', + in: 'query', + required: false, + schema: { + oneOf: [{ enum: ['low', 'high'] }, { type: 'null' }], + }, + }, + imports: [], + }, + ], + operationName: '', + context, + }); + + expect(result?.schema.model.trim()).toBe( + `export type Params = {\npriority?: Priority;\n};`, + ); + + const priorityEnum = result?.deps.find((s) => s.name === 'Priority'); + expect(priorityEnum).toBeDefined(); + expect(priorityEnum?.model).toContain(`'low' | 'high' | null`); + }); + + // Negative: a `$ref` enum branch should reuse the referenced component, not + // be re-extracted as a parallel inline enum. Without the isRef guard in + // combine.ts the caller would emit a nested `{Status: Status}` const. + it('queryParam with anyOf [$ref enum, null] reuses the referenced type', () => { + const refContext = createTestContextSpec({ + spec: { + components: { + schemas: { + Status: { + type: 'string', + enum: ['new', 'in_progress'], + }, + }, + }, + }, + }); + + const result = getQueryParams({ + queryParams: [ + { + parameter: { + name: 'status', + in: 'query', + required: false, + schema: { + anyOf: [ + { $ref: '#/components/schemas/Status' }, + { type: 'null' }, + ], + }, + }, + imports: [], + }, + ], + operationName: '', + context: refContext, + }); + + // The param should reference the existing Status type (with null), not + // a freshly extracted inline enum named after the parameter. + expect(result?.schema.model.trim()).toBe( + `export type Params = {\nstatus?: Status | null;\n};`, + ); + // No parameter-scoped enum should be emitted. + expect(result?.deps.find((s) => s.name === 'Status')).toBeUndefined(); + }); + + // Negative regression: anyOf with multiple non-null variants is a genuine + // union, not a nullable enum, and must stay inlined. This guards the + // #2710 fix from over-matching. + it('queryParam with anyOf [enum, non-null scalar] stays inlined', () => { + const result = getQueryParams({ + queryParams: [ + { + parameter: { + name: 'status', + in: 'query', + required: false, + schema: { + anyOf: [{ enum: ['new', 'in_progress'] }, { type: 'string' }], + }, + }, + imports: [], + }, + ], + operationName: '', + context, + }); + + expect(result?.schema.model.trim()).toBe( + `export type Params = {\nstatus?: 'new' | 'in_progress' | string;\n};`, + ); + // No named Status type should be emitted for this case. + expect(result?.deps.find((s) => s.name === 'Status')).toBeUndefined(); + }); + it('tracks required nullable keys for downstream generators', () => { const result = getQueryParams({ queryParams: [