From 095aff4668b5016adc1b9c651be70096b3c9881d Mon Sep 17 00:00:00 2001 From: Barney Huang Date: Thu, 13 Aug 2026 01:26:35 +0800 Subject: [PATCH 1/9] fix(core): generate legal, unique enum member names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #5. Enum member names came out of a single sanitizing pass that could leave them illegal or colliding, and `dart analyze` rejected the result: - `-1` became `1` and `-2.5` became `25`, since the `value` prefix was only applied to names already starting with a digit, and the sign was stripped along with the other punctuation - `1.5` normalised onto integer `15`, because the decimal point was treated as a word separator - `new`, `class` and `default` were emitted as-is, as were `true` and `false` from boolean enums - `values` and `index` clash with members every Dart enum already has - values that sanitize down to nothing, like `日本` or `-`, produced empty identifiers - `Active`/`active` and `a-b`/`a_b` both reduced to one name Naming now runs in three passes: numbers are encoded so distinct values keep distinct names (`valueMinus1`, `value1Point5`), anything empty, digit-leading, reserved or enum-owned is prefixed, and a final pass suffixes whatever still collides. Values repeated after parsing - `[1.0, 1]`, `[0, -0]` - collapse onto one member rather than emitting two entries for the same @JsonValue. Boolean enums also serialized as strings, the same defect #4 fixed for numbers, so they now emit bare literals with a `bool`-based extension. Their switch covers every case, so it gets no default clause. A null enum value in a string enum no longer emits `@JsonValue('null')`; it gets no member at all, matching what numeric enums already do. --- .../__tests__/generators/models-enum.test.ts | 114 +++++++++- .../core/src/generators/model-generator.ts | 201 ++++++++++++------ packages/core/src/generators/models.ts | 2 +- packages/core/src/templates/freezed-enum.hbs | 10 +- 4 files changed, 258 insertions(+), 69 deletions(-) diff --git a/packages/core/src/__tests__/generators/models-enum.test.ts b/packages/core/src/__tests__/generators/models-enum.test.ts index 1cac7df..ad2ddfe 100644 --- a/packages/core/src/__tests__/generators/models-enum.test.ts +++ b/packages/core/src/__tests__/generators/models-enum.test.ts @@ -197,4 +197,116 @@ describe('Enum Generation', () => { expect(result.content).toContain("@JsonValue('two')"); expect(result.content).toContain('String get value'); }); -}); \ No newline at end of file + + describe('member names Dart would reject', () => { + it('should keep the sign and the decimal point of numeric values', () => { + const result = generator.generateEnum('Scale', [-1, -2.5, 1.5, 15], undefined, 'number'); + + // Sanitizing used to drop both, leaving -1 named '1' and landing 1.5 on 15 + expect(result.content).toContain('valueMinus1'); + expect(result.content).toContain('valueMinus2Point5'); + expect(result.content).toContain('value1Point5'); + expect(result.content).toContain('value15'); + }); + + it('should keep exponent notation legal', () => { + const result = generator.generateEnum('Big', [1e21], undefined, 'number'); + + expect(result.content).toContain('value1ePlus21'); + }); + + it('should prefix Dart reserved words', () => { + const result = generator.generateEnum('Reserved', ['new', 'class', 'default'], undefined, 'string'); + + expect(result.content).toContain('valueNew'); + expect(result.content).toContain('valueClass'); + expect(result.content).toContain('valueDefault'); + // The @JsonValue keeps the original spelling + expect(result.content).toContain("@JsonValue('new')"); + }); + + it('should prefix names an enum already declares', () => { + const result = generator.generateEnum('Builtins', ['values', 'index'], undefined, 'string'); + + // `values` is generated for every enum, `index` comes from Enum + expect(result.content).toContain('valueValues'); + expect(result.content).toContain('valueIndex'); + }); + + it('should leave a member named value alone', () => { + const result = generator.generateEnum('Named', ['value'], undefined, 'string'); + + // Enum members are static and the extension getter is an instance + // member, so these do not collide + expect(result.content).toContain('@JsonValue(\'value\')\n value'); + }); + + it('should give sanitized-away values a usable name', () => { + const result = generator.generateEnum('Unicode', ['日本', '-'], undefined, 'string'); + + // Both sanitize down to an empty string, which is not an identifier + expect(result.content).toContain('@JsonValue(\'日本\')\n value'); + expect(result.content).toContain('@JsonValue(\'-\')\n value2'); + }); + }); + + describe('colliding member names', () => { + it('should suffix values that differ only in case', () => { + const result = generator.generateEnum('Status', ['Active', 'active'], undefined, 'string'); + + expect(result.content).toContain(' active,'); + expect(result.content).toContain(' active2'); + }); + + it('should suffix values that differ only in separators', () => { + const result = generator.generateEnum('Sep', ['a-b', 'a_b'], undefined, 'string'); + + expect(result.content).toContain(' aB,'); + expect(result.content).toContain(' aB2'); + }); + + it('should collapse repeated values onto one member', () => { + const result = generator.generateEnum('Same', [1.0, 1], undefined, 'number'); + + // YAML parses both to the same number, and two members sharing a + // @JsonValue would make the generated map ambiguous + expect(result.content.match(/@JsonValue\(1\)/g)?.length).toBe(1); + expect(result.content).not.toContain('value12'); + }); + }); + + describe('boolean enums', () => { + it('should serialize boolean values as literals', () => { + const result = generator.generateEnum('Toggle', [true, false], undefined, 'boolean'); + + expect(result.content).toContain('@JsonValue(true)'); + expect(result.content).toContain('@JsonValue(false)'); + expect(result.content).not.toContain("@JsonValue('true')"); + + // true and false are keywords, so the members need prefixing + expect(result.content).toContain('valueTrue'); + expect(result.content).toContain('valueFalse'); + }); + + it('should expose boolean enums through a bool-based extension', () => { + const result = generator.generateEnum('Toggle', [true, false], undefined, 'boolean'); + + expect(result.content).toContain('bool get value'); + expect(result.content).toContain('static Toggle? fromValue(bool? value)'); + + // A switch over bool covers every case; a default clause would be + // reported as unreachable + expect(result.content).not.toContain('default:'); + expect(result.content).not.toContain('unknown'); + }); + }); + + it('should drop a null value from a string enum', () => { + const result = generator.generateEnum('Nullable', ['null', null], undefined, 'string'); + + // The string 'null' keeps its member; the actual null gets none, since + // json_serializable decodes a null source to Dart null regardless + expect(result.content.match(/@JsonValue\('null'\)/g)?.length).toBe(1); + expect(result.content).not.toContain('@JsonValue(null)'); + }); +}); diff --git a/packages/core/src/generators/model-generator.ts b/packages/core/src/generators/model-generator.ts index e08e407..3981291 100644 --- a/packages/core/src/generators/model-generator.ts +++ b/packages/core/src/generators/model-generator.ts @@ -8,6 +8,22 @@ import { TypeMapper } from '../utils'; import { ReferenceResolver } from '../resolvers'; import { TemplateManager } from '../templates/template-manager'; +/** + * Dart reserved words, which cannot be used as identifiers at all + */ +const DART_RESERVED_WORDS = new Set([ + 'assert', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', + 'do', 'else', 'enum', 'extends', 'false', 'final', 'finally', 'for', 'if', + 'in', 'is', 'new', 'null', 'rethrow', 'return', 'super', 'switch', 'this', + 'throw', 'true', 'try', 'var', 'void', 'while', 'with' +]); + +/** + * Names an enum cannot declare: `values` is generated for every enum and + * `index` is inherited from Enum + */ +const ENUM_MEMBER_CONFLICTS = new Set(['values', 'index']); + export class ModelGenerator { private templateManager: TemplateManager; private refResolver?: ReferenceResolver; @@ -284,12 +300,83 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = return null; } + /** + * Build a Dart enum member name for a numeric value. + * + * `String(value)` is canonical for JS numbers, so distinct values keep + * distinct names. The sign and the decimal point have to survive sanitizing: + * stripped, `1.5` lands on integer `15` and `-1` loses its sign. + */ + private numericEnumMemberName(value: number): string { + const encoded = String(value) + .replace(/-/g, 'Minus') + .replace(/\./g, 'Point') + .replace(/\+/g, 'Plus'); + + return `value${encoded.charAt(0).toUpperCase()}${encoded.slice(1)}`; + } + + /** + * Reduce an arbitrary string value to a camelCase Dart identifier. + * The result can still be empty or otherwise illegal - legalize it after. + */ + private sanitizeEnumMemberName(value: string): string { + const sanitized = value + .replace(/[^a-zA-Z0-9_]/g, '_') // Replace non-alphanumeric with underscore + .replace(/_+/g, '_') // Replace multiple underscores with single + .replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores + + const parts = sanitized.split('_'); + const camelCased = parts[0].toLowerCase() + parts.slice(1).map(p => + p.charAt(0).toUpperCase() + p.slice(1).toLowerCase() + ).join(''); + + // Ensure it doesn't start with uppercase + return camelCased.charAt(0).match(/[A-Z]/) + ? camelCased.charAt(0).toLowerCase() + camelCased.slice(1) + : camelCased; + } + + /** + * Turn a name Dart would reject into one it accepts. Sanitizing can empty a + * name out entirely, leave it starting with a digit, or land it on a keyword + * or on a member every enum already declares. + */ + private legalizeEnumMemberName(name: string): string { + const needsPrefix = + name === '' || + /^\d/.test(name) || + DART_RESERVED_WORDS.has(name) || + ENUM_MEMBER_CONFLICTS.has(name); + + return needsPrefix + ? `value${name.charAt(0).toUpperCase()}${name.slice(1)}` + : name; + } + + /** + * Distinct enum values can still reduce to the same legal identifier - + * `1.5` and `15`, `'Active'` and `'active'`, anything differing only in + * characters sanitizing drops. Suffix the later ones so the enum compiles. + */ + private uniqueEnumMemberName(name: string, usedNames: Set): string { + let unique = name; + let suffix = 2; + while (usedNames.has(unique)) { + unique = `${name}${suffix}`; + suffix++; + } + + usedNames.add(unique); + return unique; + } + /** * Generate enum from schema */ generateEnum( name: string, - values: (string | number | null)[], + values: (string | number | boolean | null)[], description?: string, type?: string ): GeneratedFile { @@ -306,65 +393,55 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = nonNullValues.length > 0 && nonNullValues.every(value => typeof value === 'number'); - // Convert enum values to valid Dart enum names - const enumValues = values.map(value => { - // Handle null values - if (value === null || value === 'null') { - return { - name: 'nullValue', - value: 'null', - description: 'Null value' - }; - } - - // Handle empty string - if (value === '') { + // A boolean enum has the same problem numeric ones had: quoting the values + // sends "true" where the server expects true + const isBoolean = + (type === undefined || type === 'boolean') && + nonNullValues.length > 0 && + nonNullValues.every(value => typeof value === 'boolean'); + + // A null enum value gets no member. json_serializable decodes a null + // source to Dart null without consulting the value map, so the member + // would be unreachable through fromJson. + // + // Repeated values get one member between them. YAML parses `[1.0, 1]` and + // `[0, -0]` to a single number each, and two members sharing a @JsonValue + // would make the generated map ambiguous. + const seenValues = new Set(); + const usedNames = new Set(); + const enumValues = values + .filter(value => { + if (value === null || seenValues.has(value)) { + return false; + } + seenValues.add(value); + return true; + }) + .map(value => { + let baseName: string; + if (value === '') { + baseName = 'empty'; + } else if (value === 'null') { + baseName = 'nullValue'; + } else if (typeof value === 'number') { + baseName = this.numericEnumMemberName(value); + } else { + baseName = this.sanitizeEnumMemberName(String(value)); + } + return { - name: 'empty', - value: '', - description: 'Empty string' + name: this.uniqueEnumMemberName(this.legalizeEnumMemberName(baseName), usedNames), + value, + description: value === '' ? 'Empty string' : undefined }; - } - - // Start with the original value - let dartName = String(value); - - // Handle numeric-only values or values starting with numbers - if (/^\d/.test(dartName)) { - dartName = `value${dartName.charAt(0).toUpperCase() + dartName.slice(1)}`; - } - - // Replace special characters with underscores - dartName = dartName - .replace(/[^a-zA-Z0-9_]/g, '_') // Replace non-alphanumeric with underscore - .replace(/_+/g, '_') // Replace multiple underscores with single - .replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores - - // Convert to camelCase for Dart enum convention - // Split by underscore and capitalize each part except first - const parts = dartName.split('_'); - dartName = parts[0].toLowerCase() + parts.slice(1).map(p => - p.charAt(0).toUpperCase() + p.slice(1).toLowerCase() - ).join(''); - - // Ensure it doesn't start with uppercase - if (dartName.charAt(0).match(/[A-Z]/)) { - dartName = dartName.charAt(0).toLowerCase() + dartName.slice(1); - } - - return { - name: dartName, - value: value, - description: undefined - }; - }); + }); // Add 'unknown' fallback value for forward compatibility // This ensures that if the backend adds new enum values, the client won't crash - // Numeric enums have no spare value to use as a sentinel, so fromValue - // returns null for them instead + // Numeric and boolean enums have no spare value to use as a sentinel, so + // fromValue returns null for them instead const hasUnknown = enumValues.some(v => v.name === 'unknown'); - if (!hasUnknown && !isNumeric) { + if (!hasUnknown && !isNumeric && !isBoolean) { enumValues.push({ name: 'unknown', value: 'unknown', @@ -372,18 +449,16 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = }); } - // json_serializable decodes a null source to Dart null without consulting - // the value map, so a nullValue member would be unreachable through - // fromJson. Dart's own null is the single spelling for absent instead. - const renderedValues = isNumeric - ? enumValues.filter(v => v.name !== 'nullValue') - : enumValues; - const templateData = { enumName, description, - values: renderedValues, - isNumeric + values: enumValues, + // Numbers and booleans render as bare Dart literals, strings stay quoted + isLiteral: isNumeric || isBoolean, + valueType: isBoolean ? 'bool' : 'num', + // A switch over bool covers every case, so a default clause would be + // flagged as unreachable + isBoolean }; const content = this.templateManager.render('freezed-enum', templateData); diff --git a/packages/core/src/generators/models.ts b/packages/core/src/generators/models.ts index 65ea62a..db9c355 100644 --- a/packages/core/src/generators/models.ts +++ b/packages/core/src/generators/models.ts @@ -350,7 +350,7 @@ export async function generateModels( if (isEnum(schema)) { const enumFile = generator.generateEnum( name, - schema.enum as (string | number | null)[], + schema.enum as (string | number | boolean | null)[], schema.description, schema.type as string | undefined ); diff --git a/packages/core/src/templates/freezed-enum.hbs b/packages/core/src/templates/freezed-enum.hbs index 0374e15..bdb733b 100644 --- a/packages/core/src/templates/freezed-enum.hbs +++ b/packages/core/src/templates/freezed-enum.hbs @@ -8,7 +8,7 @@ enum {{enumName}} { {{#if description}} /// {{description}} {{/if}} -{{#if ../isNumeric}} +{{#if ../isLiteral}} @JsonValue({{value}}) {{else}} @JsonValue('{{value}}') @@ -22,8 +22,8 @@ enum {{enumName}} { /// Extension methods for {{enumName}} extension {{enumName}}Extension on {{enumName}} { -{{#if isNumeric}} - num get value { +{{#if isLiteral}} + {{valueType}} get value { switch (this) { {{#each values}} case {{../enumName}}.{{name}}: @@ -32,15 +32,17 @@ extension {{enumName}}Extension on {{enumName}} { } } - static {{enumName}}? fromValue(num? value) { + static {{enumName}}? fromValue({{valueType}}? value) { if (value == null) return null; switch (value) { {{#each values}} case {{value}}: return {{../enumName}}.{{name}}; {{/each}} +{{#unless isBoolean}} default: return null; +{{/unless}} } } {{else}} From b852433e2cadc4e1458b62c58d1271ac526336c2 Mon Sep 17 00:00:00 2001 From: Barney Huang Date: Thu, 13 Aug 2026 17:16:41 +0800 Subject: [PATCH 2/9] fix(core): only generate enums json_serializable can express `@JsonValue` accepts String, int or null, so decimals, booleans and mixed value sets have no representation - build_runner refuses the file, whatever the member names are. Decimals already failed this way before this branch; booleans were failing on their names instead, and the boolean branch added here would have shipped a second way to fail. Those enums no longer become Dart enums. The schema keeps its scalar type and the allowed values stay a server-side constraint, the way a minimum/maximum would. A top-level one emits a typedef so `$ref`s to it still resolve, an inline one leaves the property scalar, and the boolean branch is gone with them. Integer and string enums are unchanged. Also from review: - `hashCode`, `runtimeType`, `toString` and `noSuchMethod` join the names an enum cannot declare - same conflicting_static_and_instance error as `values` and `index`, confirmed with dart analyze - the naming helpers move to getters/enum, where `enumValueToDartName` was a verbatim copy of the logic this branch is fixing, so there is one implementation rather than two - uniqueEnumMemberName documents that suffixes follow spec order, so reordering values renames members --- .../__tests__/generators/models-enum.test.ts | 105 ++++++++++--- .../core/src/generators/model-generator.ts | 142 +++++------------- packages/core/src/generators/models.ts | 15 +- packages/core/src/getters/enum.ts | 138 +++++++++++++---- packages/core/src/templates/freezed-enum.hbs | 10 +- packages/core/src/utils/assertion.ts | 26 ++++ 6 files changed, 267 insertions(+), 169 deletions(-) diff --git a/packages/core/src/__tests__/generators/models-enum.test.ts b/packages/core/src/__tests__/generators/models-enum.test.ts index ad2ddfe..501a9b9 100644 --- a/packages/core/src/__tests__/generators/models-enum.test.ts +++ b/packages/core/src/__tests__/generators/models-enum.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect } from 'vitest'; import { ModelGenerator } from '../../generators'; +import { generateModels } from '../../generators/models'; + +const specWith = (schemas: Record) => ({ + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: {}, + components: { schemas } +}) as any; + +const booleanSpec = specWith({ Toggle: { type: 'boolean', enum: [true, false] } }); +const decimalSpec = specWith({ Scale: { type: 'number', enum: [1.5, 2.5] } }); +const integerSpec = specWith({ DayOfWeek: { type: 'integer', enum: [-1, 1, 2] } }); +const inlineBooleanSpec = specWith({ + Holder: { type: 'object', properties: { flag: { type: 'boolean', enum: [true, false] } } } +}); describe('Enum Generation', () => { const generator = new ModelGenerator(); @@ -203,16 +218,18 @@ describe('Enum Generation', () => { const result = generator.generateEnum('Scale', [-1, -2.5, 1.5, 15], undefined, 'number'); // Sanitizing used to drop both, leaving -1 named '1' and landing 1.5 on 15 - expect(result.content).toContain('valueMinus1'); - expect(result.content).toContain('valueMinus2Point5'); - expect(result.content).toContain('value1Point5'); - expect(result.content).toContain('value15'); + // Separators matter here - a bare toContain('value15') is also satisfied + // by value150 + expect(result.content).toContain(' valueMinus1,'); + expect(result.content).toContain(' valueMinus2Point5,'); + expect(result.content).toContain(' value1Point5,'); + expect(result.content).toContain(' value15\n'); }); it('should keep exponent notation legal', () => { const result = generator.generateEnum('Big', [1e21], undefined, 'number'); - expect(result.content).toContain('value1ePlus21'); + expect(result.content).toContain(' value1ePlus21\n'); }); it('should prefix Dart reserved words', () => { @@ -275,29 +292,73 @@ describe('Enum Generation', () => { }); }); - describe('boolean enums', () => { - it('should serialize boolean values as literals', () => { - const result = generator.generateEnum('Toggle', [true, false], undefined, 'boolean'); + describe('values Dart cannot express', () => { + it('should not generate a Dart enum for a single boolean value', async () => { + // A one-value boolean enum is a common constant marker in specs + const spec = specWith({ Flag: { type: 'boolean', enum: [true] } }); + const files = await generateModels(spec, { + input: spec, + output: { target: './test', mode: 'split', client: 'dio' } + } as any); + + expect(files.find(f => f.path === 'models/flag.f.dart')!.content).toContain('typedef Flag = bool;'); + }); + + it('should not generate a Dart enum for mixed value types', async () => { + // Both members would render as @JsonValue('1'), which is ambiguous + const spec = specWith({ Mixed: { type: 'string', enum: [1, '1'] } }); + const files = await generateModels(spec, { + input: spec, + output: { target: './test', mode: 'split', client: 'dio' } + } as any); + + expect(files.find(f => f.path === 'models/mixed.f.dart')!.content).toContain('typedef Mixed = String;'); + }); + + it('should not generate a Dart enum for booleans', async () => { + // json_serializable only accepts String, int or null in a @JsonValue, + // so `@JsonValue(true)` fails the build + const files = await generateModels(booleanSpec, { + input: booleanSpec, + output: { target: './test', mode: 'split', client: 'dio' } + } as any); + + const file = files.find(f => f.path === 'models/toggle.f.dart'); + expect(file!.content).toContain('typedef Toggle = bool;'); + expect(file!.content).not.toContain('@JsonValue('); + }); - expect(result.content).toContain('@JsonValue(true)'); - expect(result.content).toContain('@JsonValue(false)'); - expect(result.content).not.toContain("@JsonValue('true')"); + it('should not generate a Dart enum for decimals', async () => { + const files = await generateModels(decimalSpec, { + input: decimalSpec, + output: { target: './test', mode: 'split', client: 'dio' } + } as any); - // true and false are keywords, so the members need prefixing - expect(result.content).toContain('valueTrue'); - expect(result.content).toContain('valueFalse'); + const file = files.find(f => f.path === 'models/scale.f.dart'); + expect(file!.content).toContain('typedef Scale = double;'); + expect(file!.content).not.toContain('@JsonValue('); }); - it('should expose boolean enums through a bool-based extension', () => { - const result = generator.generateEnum('Toggle', [true, false], undefined, 'boolean'); + it('should keep the property scalar for an inline unrepresentable enum', async () => { + const files = await generateModels(inlineBooleanSpec, { + input: inlineBooleanSpec, + output: { target: './test', mode: 'split', client: 'dio' } + } as any); + + const file = files.find(f => f.path === 'models/holder.f.dart'); + expect(file!.content).toContain('bool? flag,'); + expect(files.find(f => f.path.includes('holder_flag_enum'))).toBeUndefined(); + }); - expect(result.content).toContain('bool get value'); - expect(result.content).toContain('static Toggle? fromValue(bool? value)'); + it('should still generate integer enums', async () => { + const files = await generateModels(integerSpec, { + input: integerSpec, + output: { target: './test', mode: 'split', client: 'dio' } + } as any); - // A switch over bool covers every case; a default clause would be - // reported as unreachable - expect(result.content).not.toContain('default:'); - expect(result.content).not.toContain('unknown'); + const file = files.find(f => f.path === 'models/day_of_week.f.dart'); + expect(file!.content).toContain('@JsonValue(1)'); + expect(file!.content).toContain('valueMinus1'); }); }); diff --git a/packages/core/src/generators/model-generator.ts b/packages/core/src/generators/model-generator.ts index 3981291..f18ce4f 100644 --- a/packages/core/src/generators/model-generator.ts +++ b/packages/core/src/generators/model-generator.ts @@ -7,22 +7,12 @@ import { DartModel, DartProperty, GeneratedFile } from '../types'; import { TypeMapper } from '../utils'; import { ReferenceResolver } from '../resolvers'; import { TemplateManager } from '../templates/template-manager'; - -/** - * Dart reserved words, which cannot be used as identifiers at all - */ -const DART_RESERVED_WORDS = new Set([ - 'assert', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', - 'do', 'else', 'enum', 'extends', 'false', 'final', 'finally', 'for', 'if', - 'in', 'is', 'new', 'null', 'rethrow', 'return', 'super', 'switch', 'this', - 'throw', 'true', 'try', 'var', 'void', 'while', 'with' -]); - -/** - * Names an enum cannot declare: `values` is generated for every enum and - * `index` is inherited from Enum - */ -const ENUM_MEMBER_CONFLICTS = new Set(['values', 'index']); +import { + legalizeEnumMemberName, + numericEnumMemberName, + sanitizeEnumMemberName, + uniqueEnumMemberName +} from '../getters/enum'; export class ModelGenerator { private templateManager: TemplateManager; @@ -114,6 +104,28 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = }; } + /** + * Emit a typedef for an enum Dart cannot express, so `$ref`s to it still + * resolve and the value simply keeps its scalar type. + */ + generateScalarTypedef(name: string, schema: OpenAPIV3.SchemaObject): GeneratedFile { + const className = TypeMapper.toDartClassName(name); + const fileName = TypeMapper.toSnakeCase(name); + // mapType answers String for anything carrying an enum, so ask about the + // underlying scalar instead + const { enum: _values, ...scalarSchema } = schema as any; + const dartType = TypeMapper.mapType(scalarSchema); + + const content = `// Generated typedef: the enum values have no @JsonValue representation +${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = ${dartType}; +`; + + return { + path: `models/${fileName}.f.dart`, + content + }; + } + /** * Convert OpenAPI schema to DartModel */ @@ -300,77 +312,6 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = return null; } - /** - * Build a Dart enum member name for a numeric value. - * - * `String(value)` is canonical for JS numbers, so distinct values keep - * distinct names. The sign and the decimal point have to survive sanitizing: - * stripped, `1.5` lands on integer `15` and `-1` loses its sign. - */ - private numericEnumMemberName(value: number): string { - const encoded = String(value) - .replace(/-/g, 'Minus') - .replace(/\./g, 'Point') - .replace(/\+/g, 'Plus'); - - return `value${encoded.charAt(0).toUpperCase()}${encoded.slice(1)}`; - } - - /** - * Reduce an arbitrary string value to a camelCase Dart identifier. - * The result can still be empty or otherwise illegal - legalize it after. - */ - private sanitizeEnumMemberName(value: string): string { - const sanitized = value - .replace(/[^a-zA-Z0-9_]/g, '_') // Replace non-alphanumeric with underscore - .replace(/_+/g, '_') // Replace multiple underscores with single - .replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores - - const parts = sanitized.split('_'); - const camelCased = parts[0].toLowerCase() + parts.slice(1).map(p => - p.charAt(0).toUpperCase() + p.slice(1).toLowerCase() - ).join(''); - - // Ensure it doesn't start with uppercase - return camelCased.charAt(0).match(/[A-Z]/) - ? camelCased.charAt(0).toLowerCase() + camelCased.slice(1) - : camelCased; - } - - /** - * Turn a name Dart would reject into one it accepts. Sanitizing can empty a - * name out entirely, leave it starting with a digit, or land it on a keyword - * or on a member every enum already declares. - */ - private legalizeEnumMemberName(name: string): string { - const needsPrefix = - name === '' || - /^\d/.test(name) || - DART_RESERVED_WORDS.has(name) || - ENUM_MEMBER_CONFLICTS.has(name); - - return needsPrefix - ? `value${name.charAt(0).toUpperCase()}${name.slice(1)}` - : name; - } - - /** - * Distinct enum values can still reduce to the same legal identifier - - * `1.5` and `15`, `'Active'` and `'active'`, anything differing only in - * characters sanitizing drops. Suffix the later ones so the enum compiles. - */ - private uniqueEnumMemberName(name: string, usedNames: Set): string { - let unique = name; - let suffix = 2; - while (usedNames.has(unique)) { - unique = `${name}${suffix}`; - suffix++; - } - - usedNames.add(unique); - return unique; - } - /** * Generate enum from schema */ @@ -393,13 +334,6 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = nonNullValues.length > 0 && nonNullValues.every(value => typeof value === 'number'); - // A boolean enum has the same problem numeric ones had: quoting the values - // sends "true" where the server expects true - const isBoolean = - (type === undefined || type === 'boolean') && - nonNullValues.length > 0 && - nonNullValues.every(value => typeof value === 'boolean'); - // A null enum value gets no member. json_serializable decodes a null // source to Dart null without consulting the value map, so the member // would be unreachable through fromJson. @@ -424,13 +358,13 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = } else if (value === 'null') { baseName = 'nullValue'; } else if (typeof value === 'number') { - baseName = this.numericEnumMemberName(value); + baseName = numericEnumMemberName(value); } else { - baseName = this.sanitizeEnumMemberName(String(value)); + baseName = sanitizeEnumMemberName(String(value)); } return { - name: this.uniqueEnumMemberName(this.legalizeEnumMemberName(baseName), usedNames), + name: uniqueEnumMemberName(legalizeEnumMemberName(baseName), usedNames), value, description: value === '' ? 'Empty string' : undefined }; @@ -438,10 +372,10 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = // Add 'unknown' fallback value for forward compatibility // This ensures that if the backend adds new enum values, the client won't crash - // Numeric and boolean enums have no spare value to use as a sentinel, so - // fromValue returns null for them instead + // Numeric enums have no spare value to use as a sentinel, so fromValue + // returns null for them instead const hasUnknown = enumValues.some(v => v.name === 'unknown'); - if (!hasUnknown && !isNumeric && !isBoolean) { + if (!hasUnknown && !isNumeric) { enumValues.push({ name: 'unknown', value: 'unknown', @@ -453,12 +387,8 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = enumName, description, values: enumValues, - // Numbers and booleans render as bare Dart literals, strings stay quoted - isLiteral: isNumeric || isBoolean, - valueType: isBoolean ? 'bool' : 'num', - // A switch over bool covers every case, so a default clause would be - // flagged as unreachable - isBoolean + // Numbers render as bare Dart literals, strings stay quoted + isNumeric }; const content = this.templateManager.render('freezed-enum', templateData); diff --git a/packages/core/src/generators/models.ts b/packages/core/src/generators/models.ts index db9c355..88dd160 100644 --- a/packages/core/src/generators/models.ts +++ b/packages/core/src/generators/models.ts @@ -9,7 +9,7 @@ import { OpenAPIParser } from '../parser/openapi-parser'; import { ReferenceResolver } from '../resolvers'; import { combineSchemas } from '../getters/combine'; import { getObject } from '../getters/object'; -import { hasComposition, hasDiscriminatedUnion, isEnum, isEmpty } from '../utils/assertion'; +import { hasComposition, hasDiscriminatedUnion, isEnum, isRepresentableEnum, isEmpty } from '../utils/assertion'; import { TypeMapper } from '../utils'; // Helper functions @@ -115,7 +115,7 @@ function extractInlineEnums( if (subSchema.properties) { Object.entries(subSchema.properties).forEach(([propName, propSchema]: [string, any]) => { // Check if this is an inline enum (has enum array but no $ref) - if (propSchema.enum && Array.isArray(propSchema.enum) && !propSchema.$ref) { + if (isRepresentableEnum(propSchema) && !propSchema.$ref) { // Generate a name for the enum type const enumTypeName = `${parentName}${propName.charAt(0).toUpperCase()}${propName.slice(1)}Enum`; @@ -140,7 +140,7 @@ function extractInlineEnums( if (schema.properties) { Object.entries(schema.properties).forEach(([propName, propSchema]: [string, any]) => { // Check if this is an inline enum (has enum array but no $ref) - if (propSchema.enum && Array.isArray(propSchema.enum) && !propSchema.$ref) { + if (isRepresentableEnum(propSchema) && !propSchema.$ref) { // Generate a name for the enum type const enumTypeName = `${parentName}${propName.charAt(0).toUpperCase()}${propName.slice(1)}Enum`; @@ -227,7 +227,7 @@ function processParametersForEnums( const uniqueEnumTypeName = `${methodPrefix}${pathPart}${paramName}Enum`; // Check if this parameter has an inline enum (not a reference) - if (schema.enum && Array.isArray(schema.enum) && !('$ref' in schema)) { + if (isRepresentableEnum(schema) && !('$ref' in schema)) { // Check if this exact enum already exists if (schemas[uniqueEnumTypeName]) { const existing = schemas[uniqueEnumTypeName]; @@ -347,6 +347,13 @@ export async function generateModels( // which generates typedef = Map for them // Check if it's an enum + if (isEnum(schema) && !isRepresentableEnum(schema)) { + // Decimals and booleans have no @JsonValue representation, so the values + // stay a server-side constraint and the schema keeps its scalar type + files.push(generator.generateScalarTypedef(name, schema)); + return; + } + if (isEnum(schema)) { const enumFile = generator.generateEnum( name, diff --git a/packages/core/src/getters/enum.ts b/packages/core/src/getters/enum.ts index 414c32a..066420b 100644 --- a/packages/core/src/getters/enum.ts +++ b/packages/core/src/getters/enum.ts @@ -37,45 +37,121 @@ export function getEnumValues(schema: OpenAPIV3.SchemaObject): (string | number } /** - * Convert enum value to valid Dart identifier + * Dart reserved words, which cannot be used as identifiers at all */ -export function enumValueToDartName(value: string | number | null): string { - // Handle null values - if (value === null || value === 'null') { - return 'nullValue'; - } - - // Handle empty string - if (value === '') { - return 'empty'; - } - - // Start with the original value - let dartName = String(value); - - // Handle numeric-only values or values starting with numbers - if (/^\d/.test(dartName)) { - dartName = `value${dartName.charAt(0).toUpperCase() + dartName.slice(1)}`; - } - - // Replace special characters with underscores - dartName = dartName +const DART_RESERVED_WORDS = new Set([ + 'assert', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', + 'do', 'else', 'enum', 'extends', 'false', 'final', 'finally', 'for', 'if', + 'in', 'is', 'new', 'null', 'rethrow', 'return', 'super', 'switch', 'this', + 'throw', 'true', 'try', 'var', 'void', 'while', 'with' +]); + +/** + * Names an enum cannot declare: `values` is generated for every enum, `index` + * comes from Enum, and the rest are inherited from Object. Declaring any of + * them is a conflicting_static_and_instance error. `name` and `compareTo` are + * fine - those come from an extension and from Comparable. + */ +const ENUM_MEMBER_CONFLICTS = new Set([ + 'values', 'index', 'hashCode', 'runtimeType', 'toString', 'noSuchMethod' +]); + +/** + * Build a Dart enum member name for a numeric value. + * + * `String(value)` is canonical for JS numbers, so distinct values keep + * distinct names. The sign and the decimal point have to survive sanitizing: + * stripped, `1.5` lands on integer `15` and `-1` loses its sign. + */ +export function numericEnumMemberName(value: number): string { + const encoded = String(value) + .replace(/-/g, 'Minus') + .replace(/\./g, 'Point') + .replace(/\+/g, 'Plus'); + + return `value${encoded.charAt(0).toUpperCase()}${encoded.slice(1)}`; +} + +/** + * Reduce an arbitrary string value to a camelCase Dart identifier. + * The result can still be empty or otherwise illegal - legalize it after. + */ +export function sanitizeEnumMemberName(value: string): string { + const sanitized = value .replace(/[^a-zA-Z0-9_]/g, '_') // Replace non-alphanumeric with underscore .replace(/_+/g, '_') // Replace multiple underscores with single .replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores - - // Convert to camelCase for Dart enum convention - const parts = dartName.split('_'); - dartName = parts[0].toLowerCase() + parts.slice(1).map(p => + + const parts = sanitized.split('_'); + const camelCased = parts[0].toLowerCase() + parts.slice(1).map(p => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase() ).join(''); - + // Ensure it doesn't start with uppercase - if (dartName.charAt(0).match(/[A-Z]/)) { - dartName = dartName.charAt(0).toLowerCase() + dartName.slice(1); + return camelCased.charAt(0).match(/[A-Z]/) + ? camelCased.charAt(0).toLowerCase() + camelCased.slice(1) + : camelCased; +} + +/** + * Turn a name Dart would reject into one it accepts. Sanitizing can empty a + * name out entirely, leave it starting with a digit, or land it on a keyword + * or on a member every enum already declares. + */ +export function legalizeEnumMemberName(name: string): string { + const needsPrefix = + name === '' || + /^\d/.test(name) || + DART_RESERVED_WORDS.has(name) || + ENUM_MEMBER_CONFLICTS.has(name); + + return needsPrefix + ? `value${name.charAt(0).toUpperCase()}${name.slice(1)}` + : name; +} + +/** + * Distinct enum values can still reduce to the same legal identifier - + * `'Active'` and `'active'`, anything differing only in characters sanitizing + * drops. Suffix the later ones so the enum compiles. + * + * Which value keeps the unsuffixed name follows the order they appear in the + * spec, so reordering them renames members. Unavoidable without inventing + * names from the values themselves, but worth knowing before reshuffling an + * enum. + */ +export function uniqueEnumMemberName(name: string, usedNames: Set): string { + let unique = name; + let suffix = 2; + while (usedNames.has(unique)) { + unique = `${name}${suffix}`; + suffix++; } - - return dartName; + + usedNames.add(unique); + return unique; +} + +/** + * Convert enum value to valid Dart identifier. + * + * Names are not guaranteed unique on their own - run the result through + * uniqueEnumMemberName when building a whole enum. + */ +export function enumValueToDartName(value: string | number | null): string { + if (value === null || value === 'null') { + return 'nullValue'; + } + + if (value === '') { + return 'empty'; + } + + const baseName = typeof value === 'number' + ? numericEnumMemberName(value) + : sanitizeEnumMemberName(String(value)); + + return legalizeEnumMemberName(baseName); } /** diff --git a/packages/core/src/templates/freezed-enum.hbs b/packages/core/src/templates/freezed-enum.hbs index bdb733b..0374e15 100644 --- a/packages/core/src/templates/freezed-enum.hbs +++ b/packages/core/src/templates/freezed-enum.hbs @@ -8,7 +8,7 @@ enum {{enumName}} { {{#if description}} /// {{description}} {{/if}} -{{#if ../isLiteral}} +{{#if ../isNumeric}} @JsonValue({{value}}) {{else}} @JsonValue('{{value}}') @@ -22,8 +22,8 @@ enum {{enumName}} { /// Extension methods for {{enumName}} extension {{enumName}}Extension on {{enumName}} { -{{#if isLiteral}} - {{valueType}} get value { +{{#if isNumeric}} + num get value { switch (this) { {{#each values}} case {{../enumName}}.{{name}}: @@ -32,17 +32,15 @@ extension {{enumName}}Extension on {{enumName}} { } } - static {{enumName}}? fromValue({{valueType}}? value) { + static {{enumName}}? fromValue(num? value) { if (value == null) return null; switch (value) { {{#each values}} case {{value}}: return {{../enumName}}.{{name}}; {{/each}} -{{#unless isBoolean}} default: return null; -{{/unless}} } } {{else}} diff --git a/packages/core/src/utils/assertion.ts b/packages/core/src/utils/assertion.ts index f0443f3..c121511 100644 --- a/packages/core/src/utils/assertion.ts +++ b/packages/core/src/utils/assertion.ts @@ -58,6 +58,32 @@ export function isEnum(schema: any): boolean { return schema && Array.isArray(schema.enum) && schema.enum.length > 0; } +/** + * Whether an enum can be expressed as a Dart enum. + * + * json_serializable only accepts String, int or null in a `@JsonValue`, so a + * set of decimals or booleans has no representation - the file it produces + * cannot be built. Those schemas keep their scalar Dart type instead, and the + * allowed values stay a server-side constraint. + */ +export function isRepresentableEnum(schema: any): boolean { + if (!isEnum(schema)) { + return false; + } + + const values = schema.enum.filter((value: any) => value !== null); + if (values.length === 0) { + return false; + } + + const allStrings = values.every((value: any) => typeof value === 'string'); + // Beyond the safe range the literal either loses precision or overflows + // Dart's int, and JS starts printing it in exponent notation + const allIntegers = values.every((value: any) => Number.isSafeInteger(value)); + + return allStrings || allIntegers; +} + /** * Check if schema is an object type */ From 4a50018ce8d64823b7f8d55ce416d602215694fe Mon Sep 17 00:00:00 2001 From: Barney Huang Date: Thu, 13 Aug 2026 22:30:31 +0800 Subject: [PATCH 3/9] fix(core): keep a parameter's enum type resolvable A parameter's Dart type is `{Method}{Path}{Param}Enum` by convention, decided in endpoint-generator from nothing but the presence of `enum`. Gating registration on whether the enum is representable left that name and its import pointing at a file nobody writes: import '../get_r_ratio_enum.f.dart'; // no such file GetRRatioEnum? ratio, // no such class Register every enum instead and let the top-level branch turn the unrepresentable ones into typedefs, the way an array of unrepresentable enum items already does. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/generators/models-enum.test.ts | 37 ++++++++++++++++++- packages/core/src/generators/models.ts | 9 ++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/core/src/__tests__/generators/models-enum.test.ts b/packages/core/src/__tests__/generators/models-enum.test.ts index 501a9b9..57bc13b 100644 --- a/packages/core/src/__tests__/generators/models-enum.test.ts +++ b/packages/core/src/__tests__/generators/models-enum.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { ModelGenerator } from '../../generators'; +import { ModelGenerator, generateDartCode } from '../../generators'; import { generateModels } from '../../generators/models'; const specWith = (schemas: Record) => ({ @@ -362,6 +362,41 @@ describe('Enum Generation', () => { }); }); + describe('a parameter whose enum Dart cannot express', () => { + const paramSpec = { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/r': { + get: { + operationId: 'getR', + parameters: [ + { name: 'ratio', in: 'query', schema: { type: 'number', enum: [1.5, 2.5] } } + ], + responses: { '200': { description: 'ok' } } + } + } + }, + components: { schemas: {} } + } as any; + + it('should still emit the type the parameter model refers to', async () => { + const files = await generateDartCode({ + input: paramSpec, + output: { target: './test', mode: 'split', client: 'dio' } + } as any); + + // endpoint-generator names a parameter's enum type by convention rather + // than from what got generated, so the file has to exist either way + const params = files.find(f => f.path === 'models/params/get_r_params.f.dart'); + expect(params!.content).toContain('GetRRatioEnum? ratio,'); + expect(params!.content).toContain("import '../get_r_ratio_enum.f.dart';"); + + const enumFile = files.find(f => f.path === 'models/get_r_ratio_enum.f.dart'); + expect(enumFile!.content).toContain('typedef GetRRatioEnum = double;'); + }); + }); + it('should drop a null value from a string enum', () => { const result = generator.generateEnum('Nullable', ['null', null], undefined, 'string'); diff --git a/packages/core/src/generators/models.ts b/packages/core/src/generators/models.ts index 88dd160..30c48f7 100644 --- a/packages/core/src/generators/models.ts +++ b/packages/core/src/generators/models.ts @@ -227,7 +227,14 @@ function processParametersForEnums( const uniqueEnumTypeName = `${methodPrefix}${pathPart}${paramName}Enum`; // Check if this parameter has an inline enum (not a reference) - if (isRepresentableEnum(schema) && !('$ref' in schema)) { + // + // Every enum gets registered, representable or not. A parameter's Dart type + // is `{Method}{Path}{Param}Enum` by convention, decided independently in + // endpoint-generator, so skipping registration here would leave that name + // and its import pointing at a file nobody writes. Registered, it reaches + // the top-level branch below and comes out a typedef, the way an array of + // unrepresentable enum items already does. + if (isEnum(schema) && !('$ref' in schema)) { // Check if this exact enum already exists if (schemas[uniqueEnumTypeName]) { const existing = schemas[uniqueEnumTypeName]; From 15c3d295a2cb60c11c3e27e0657a4c6821897514 Mon Sep 17 00:00:00 2001 From: Barney Huang Date: Thu, 13 Aug 2026 22:32:39 +0800 Subject: [PATCH 4/9] fix(core): give the unknown sentinel a member of its own The forward-compatibility sentinel was found by member name, which de-duplication renames. `enum: ['Unknown']` sanitizes to a member spelled `unknown` that stands for a real value, so the sentinel was taken as already present and never added -- leaving fromValue(null) and the default arm decoding anything unrecognised as 'Unknown'. Look the sentinel up by the value it carries and, when the spec does not declare one, name the injected member through uniqueEnumMemberName. The template no longer hardcodes `unknown` either, since the sentinel can now be called something else. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/generators/models-enum.test.ts | 22 ++++++++++++++ .../core/src/generators/model-generator.ts | 29 ++++++++++++++----- packages/core/src/templates/freezed-enum.hbs | 6 ++-- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/core/src/__tests__/generators/models-enum.test.ts b/packages/core/src/__tests__/generators/models-enum.test.ts index 57bc13b..1b28b3c 100644 --- a/packages/core/src/__tests__/generators/models-enum.test.ts +++ b/packages/core/src/__tests__/generators/models-enum.test.ts @@ -362,6 +362,28 @@ describe('Enum Generation', () => { }); }); + describe('the unknown sentinel', () => { + it('should not hand the sentinel over to a value that spells out unknown', () => { + const result = generator.generateEnum('Cased', ['Unknown'], undefined, 'string'); + + // 'Unknown' sanitizes to `unknown`, but it is a real value - taking it + // for the sentinel would decode every unrecognised value as 'Unknown' + expect(result.content).toContain("@JsonValue('Unknown')\n unknown,"); + expect(result.content).toContain("@JsonValue('unknown')\n unknown2"); + expect(result.content).toContain('if (value == null) return Cased.unknown2;'); + expect(result.content).toContain('default:\n return Cased.unknown2;'); + }); + + it('should reuse a declared unknown value as the sentinel', () => { + const result = generator.generateEnum('Declared', ['unknown', 'active'], undefined, 'string'); + + expect(result.content.match(/@JsonValue\('unknown'\)/g)?.length).toBe(1); + // The sentinel is what the default arm returns, so it gets no case + expect(result.content).not.toContain("case 'unknown':"); + expect(result.content).toContain('default:\n return Declared.unknown;'); + }); + }); + describe('a parameter whose enum Dart cannot express', () => { const paramSpec = { openapi: '3.0.0', diff --git a/packages/core/src/generators/model-generator.ts b/packages/core/src/generators/model-generator.ts index f18ce4f..0f793c9 100644 --- a/packages/core/src/generators/model-generator.ts +++ b/packages/core/src/generators/model-generator.ts @@ -374,13 +374,25 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = // This ensures that if the backend adds new enum values, the client won't crash // Numeric enums have no spare value to use as a sentinel, so fromValue // returns null for them instead - const hasUnknown = enumValues.some(v => v.name === 'unknown'); - if (!hasUnknown && !isNumeric) { - enumValues.push({ - name: 'unknown', - value: 'unknown', - description: 'Unknown value for forward compatibility' - }); + // + // The sentinel is the member carrying the value 'unknown', which the spec + // may already declare. Looking it up by member name instead would miss it: + // de-duplication renames, so `enum: ['Unknown']` yields a member spelled + // `unknown` that stands for a real value, and mistaking it for the sentinel + // drops the fallback and decodes anything unrecognised as 'Unknown'. + let unknownName: string | undefined; + if (!isNumeric) { + const declared = enumValues.find(v => v.value === 'unknown'); + if (declared) { + unknownName = declared.name; + } else { + unknownName = uniqueEnumMemberName('unknown', usedNames); + enumValues.push({ + name: unknownName, + value: 'unknown', + description: 'Unknown value for forward compatibility' + }); + } } const templateData = { @@ -388,7 +400,8 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = description, values: enumValues, // Numbers render as bare Dart literals, strings stay quoted - isNumeric + isNumeric, + unknownName }; const content = this.templateManager.render('freezed-enum', templateData); diff --git a/packages/core/src/templates/freezed-enum.hbs b/packages/core/src/templates/freezed-enum.hbs index 0374e15..2b2c8ef 100644 --- a/packages/core/src/templates/freezed-enum.hbs +++ b/packages/core/src/templates/freezed-enum.hbs @@ -54,16 +54,16 @@ extension {{enumName}}Extension on {{enumName}} { } static {{enumName}} fromValue(String? value) { - if (value == null) return {{enumName}}.unknown; + if (value == null) return {{enumName}}.{{unknownName}}; switch (value) { {{#each values}} -{{#unless (eq name "unknown")}} +{{#unless (eq name ../unknownName)}} case '{{value}}': return {{../enumName}}.{{name}}; {{/unless}} {{/each}} default: - return {{enumName}}.unknown; + return {{enumName}}.{{unknownName}}; } } {{/if}} From a05fd402891cc2040a8ebbf859b81ddbdd512a37 Mon Sep 17 00:00:00 2001 From: Barney Huang Date: Thu, 13 Aug 2026 22:42:57 +0800 Subject: [PATCH 5/9] refactor(core): build enum members in one place generateEnum and getEnumData each turned a set of values into member names, and only generateEnum learned to drop nulls, collapse repeated values and de-duplicate names. Move the whole pipeline into buildEnumMembers and have both call it. Also read the scalar type off the values when a typedef'd enum declares none, so `enum: [true, false]` lands on bool rather than dynamic. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/generators/models-enum.test.ts | 11 +++ .../src/__tests__/getters/enum-data.test.ts | 22 ++++++ .../core/src/generators/model-generator.ts | 75 ++++++++----------- packages/core/src/getters/enum.ts | 42 +++++++++-- 4 files changed, 99 insertions(+), 51 deletions(-) create mode 100644 packages/core/src/__tests__/getters/enum-data.test.ts diff --git a/packages/core/src/__tests__/generators/models-enum.test.ts b/packages/core/src/__tests__/generators/models-enum.test.ts index 1b28b3c..7dac976 100644 --- a/packages/core/src/__tests__/generators/models-enum.test.ts +++ b/packages/core/src/__tests__/generators/models-enum.test.ts @@ -350,6 +350,17 @@ describe('Enum Generation', () => { expect(files.find(f => f.path.includes('holder_flag_enum'))).toBeUndefined(); }); + it('should read the scalar type off the values when the schema omits it', async () => { + const spec = specWith({ Flag: { enum: [true, false] }, Ratio: { enum: [1.5, 2.5] } }); + const files = await generateModels(spec, { + input: spec, + output: { target: './test', mode: 'split', client: 'dio' } + } as any); + + expect(files.find(f => f.path === 'models/flag.f.dart')!.content).toContain('typedef Flag = bool;'); + expect(files.find(f => f.path === 'models/ratio.f.dart')!.content).toContain('typedef Ratio = double;'); + }); + it('should still generate integer enums', async () => { const files = await generateModels(integerSpec, { input: integerSpec, diff --git a/packages/core/src/__tests__/getters/enum-data.test.ts b/packages/core/src/__tests__/getters/enum-data.test.ts new file mode 100644 index 0000000..ff07770 --- /dev/null +++ b/packages/core/src/__tests__/getters/enum-data.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { getEnumData } from '../../getters/enum'; + +describe('getEnumData', () => { + it('should name members the way the generated enum does', () => { + const data = getEnumData('Status', { type: 'string', enum: ['Active', 'active'] } as any); + + // Both sanitize to `active`, and two members cannot share a name + expect(data!.values.map(v => v.name)).toEqual(['active', 'active2']); + }); + + it('should drop null and repeated values', () => { + const data = getEnumData('Mixed', { type: 'number', enum: [1.0, 1, null] } as any); + + expect(data!.values).toHaveLength(1); + expect(data!.values[0].name).toBe('value1'); + }); + + it('should return null for a schema that is not an enum', () => { + expect(getEnumData('Plain', { type: 'string' } as any)).toBeNull(); + }); +}); diff --git a/packages/core/src/generators/model-generator.ts b/packages/core/src/generators/model-generator.ts index 0f793c9..52e638e 100644 --- a/packages/core/src/generators/model-generator.ts +++ b/packages/core/src/generators/model-generator.ts @@ -7,12 +7,29 @@ import { DartModel, DartProperty, GeneratedFile } from '../types'; import { TypeMapper } from '../utils'; import { ReferenceResolver } from '../resolvers'; import { TemplateManager } from '../templates/template-manager'; -import { - legalizeEnumMemberName, - numericEnumMemberName, - sanitizeEnumMemberName, - uniqueEnumMemberName -} from '../getters/enum'; +import { buildEnumMembers, uniqueEnumMemberName } from '../getters/enum'; + +const SCALAR_BY_VALUE_TYPE: Record = { + boolean: 'bool', + // An integer set Dart can express never reaches the typedef path, so a number + // left here is a decimal or outside int range + number: 'double', + string: 'String' +}; + +/** + * Dart type for a set of enum values, for schemas that declare no type. + */ +function scalarTypeOfValues(values: unknown[]): string { + const valueTypes = new Set((values ?? []).filter(value => value !== null).map(value => typeof value)); + + if (valueTypes.size !== 1) { + return 'dynamic'; + } + + const [valueType] = valueTypes; + return SCALAR_BY_VALUE_TYPE[valueType] ?? 'dynamic'; +} export class ModelGenerator { private templateManager: TemplateManager; @@ -112,9 +129,12 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = const className = TypeMapper.toDartClassName(name); const fileName = TypeMapper.toSnakeCase(name); // mapType answers String for anything carrying an enum, so ask about the - // underlying scalar instead - const { enum: _values, ...scalarSchema } = schema as any; - const dartType = TypeMapper.mapType(scalarSchema); + // underlying scalar instead. A spec is free to leave the type out, and then + // the values are all there is to go on. + const { enum: values, ...scalarSchema } = schema as any; + const dartType = scalarSchema.type + ? TypeMapper.mapType(scalarSchema) + : scalarTypeOfValues(values as unknown[]); const content = `// Generated typedef: the enum values have no @JsonValue representation ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = ${dartType}; @@ -334,41 +354,8 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = nonNullValues.length > 0 && nonNullValues.every(value => typeof value === 'number'); - // A null enum value gets no member. json_serializable decodes a null - // source to Dart null without consulting the value map, so the member - // would be unreachable through fromJson. - // - // Repeated values get one member between them. YAML parses `[1.0, 1]` and - // `[0, -0]` to a single number each, and two members sharing a @JsonValue - // would make the generated map ambiguous. - const seenValues = new Set(); - const usedNames = new Set(); - const enumValues = values - .filter(value => { - if (value === null || seenValues.has(value)) { - return false; - } - seenValues.add(value); - return true; - }) - .map(value => { - let baseName: string; - if (value === '') { - baseName = 'empty'; - } else if (value === 'null') { - baseName = 'nullValue'; - } else if (typeof value === 'number') { - baseName = numericEnumMemberName(value); - } else { - baseName = sanitizeEnumMemberName(String(value)); - } - - return { - name: uniqueEnumMemberName(legalizeEnumMemberName(baseName), usedNames), - value, - description: value === '' ? 'Empty string' : undefined - }; - }); + const enumValues = buildEnumMembers(values); + const usedNames = new Set(enumValues.map(member => member.name)); // Add 'unknown' fallback value for forward compatibility // This ensures that if the backend adds new enum values, the client won't crash diff --git a/packages/core/src/getters/enum.ts b/packages/core/src/getters/enum.ts index 066420b..5b32b93 100644 --- a/packages/core/src/getters/enum.ts +++ b/packages/core/src/getters/enum.ts @@ -7,7 +7,7 @@ import { OpenAPIV3 } from 'openapi-types'; export interface EnumValue { name: string; - value: string | number | null; + value: string | number | boolean | null; description?: string; } @@ -138,7 +138,7 @@ export function uniqueEnumMemberName(name: string, usedNames: Set): stri * Names are not guaranteed unique on their own - run the result through * uniqueEnumMemberName when building a whole enum. */ -export function enumValueToDartName(value: string | number | null): string { +export function enumValueToDartName(value: string | number | boolean | null): string { if (value === null || value === 'null') { return 'nullValue'; } @@ -154,6 +154,37 @@ export function enumValueToDartName(value: string | number | null): string { return legalizeEnumMemberName(baseName); } +/** + * Build the member list for an enum: one member per distinct value, each named + * legally and uniquely within the enum. + * + * A null value gets no member. json_serializable decodes a null source to Dart + * null without consulting the value map, so the member would be unreachable + * through fromJson. + * + * Repeated values get one member between them. YAML parses `[1.0, 1]` and + * `[0, -0]` to a single number each, and two members sharing a @JsonValue would + * make the generated map ambiguous. + */ +export function buildEnumMembers(values: (string | number | boolean | null)[]): EnumValue[] { + const seenValues = new Set(); + const usedNames = new Set(); + + return values + .filter(value => { + if (value === null || seenValues.has(value)) { + return false; + } + seenValues.add(value); + return true; + }) + .map(value => ({ + name: uniqueEnumMemberName(enumValueToDartName(value), usedNames), + value, + description: value === '' ? 'Empty string' : undefined + })); +} + /** * Process enum schema into structured data */ @@ -165,12 +196,9 @@ export function getEnumData( return null; } + // Could be extended with x-enum-descriptions const rawValues = getEnumValues(schema); - const values: EnumValue[] = rawValues.map(value => ({ - name: enumValueToDartName(value), - value: value, - description: undefined // Could be extended with x-enum-descriptions - })); + const values: EnumValue[] = buildEnumMembers(rawValues); // Determine enum type const isString = rawValues.every(v => typeof v === 'string' || v === null); From 12939cebc3830339bef92b30e0896a85e4f82a72 Mon Sep 17 00:00:00 2001 From: Barney Huang Date: Thu, 13 Aug 2026 23:49:26 +0800 Subject: [PATCH 6/9] fix(core): spell a parameter's enum type the same way on both sides endpoint-generator built the type name by concatenation and referred to it as-is, while the declaration went through toDartClassName, which reads a run of capitals as an acronym. A header named `X-Flag` came out GetRXFlagEnum at the reference and GetRxFlagEnum at the declaration, so the generated package did not compile. Normalise the assembled name on both sides. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/generators/models-enum.test.ts | 35 +++++++++++++++++++ .../core/src/generators/endpoint-generator.ts | 9 +++-- packages/core/src/generators/models.ts | 7 ++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/core/src/__tests__/generators/models-enum.test.ts b/packages/core/src/__tests__/generators/models-enum.test.ts index 7dac976..52d2f18 100644 --- a/packages/core/src/__tests__/generators/models-enum.test.ts +++ b/packages/core/src/__tests__/generators/models-enum.test.ts @@ -430,6 +430,41 @@ describe('Enum Generation', () => { }); }); + describe('a header parameter with an enum', () => { + const headerSpec = { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/s': { + get: { + operationId: 'getStatus', + parameters: [ + { name: 'X-Mode', in: 'header', schema: { type: 'string', enum: ['a', 'b'] } } + ], + responses: { '200': { description: 'ok' } } + } + } + }, + components: { schemas: {} } + } as any; + + it('should spell the type the same way the model declares it', async () => { + const files = await generateDartCode({ + input: headerSpec, + output: { target: './test', mode: 'split', client: 'dio' } + } as any); + + // The declaration goes through toDartClassName, which reads XMo as an + // acronym - the reference has to be normalised the same way + const headers = files.find(f => f.path === 'models/headers/get_status_headers.f.dart'); + expect(headers!.content).toContain('GetSxModeEnum? xMode,'); + expect(headers!.content).toContain("import '../get_sx_mode_enum.f.dart';"); + + const enumFile = files.find(f => f.path === 'models/get_sx_mode_enum.f.dart'); + expect(enumFile!.content).toContain('enum GetSxModeEnum {'); + }); + }); + it('should drop a null value from a string enum', () => { const result = generator.generateEnum('Nullable', ['null', null], undefined, 'string'); diff --git a/packages/core/src/generators/endpoint-generator.ts b/packages/core/src/generators/endpoint-generator.ts index 31f2ca2..bce27f5 100644 --- a/packages/core/src/generators/endpoint-generator.ts +++ b/packages/core/src/generators/endpoint-generator.ts @@ -925,8 +925,13 @@ export class EndpointGenerator { const pathPart = TypeMapper.toDartClassName(pathContext); const paramName = TypeMapper.toDartClassName(param.name); - // Format: {Method}{PathContext}{ParamName}Enum - const uniqueEnumTypeName = `${methodPrefix}${pathPart}${paramName}Enum`; + // Format: {Method}{PathContext}{ParamName}Enum, normalised as a whole + // because that is what the declaration goes through. A header named + // `X-Flag` would otherwise leave GetRXFlagEnum here against the + // GetRxFlagEnum the model file declares. + const uniqueEnumTypeName = TypeMapper.toDartClassName( + `${methodPrefix}${pathPart}${paramName}Enum` + ); // Check if this parameter has an inline enum // If so, return the enum type name that was generated in models.ts diff --git a/packages/core/src/generators/models.ts b/packages/core/src/generators/models.ts index 30c48f7..419357f 100644 --- a/packages/core/src/generators/models.ts +++ b/packages/core/src/generators/models.ts @@ -223,8 +223,11 @@ function processParametersForEnums( const pathPart = TypeMapper.toDartClassName(pathContext); const paramName = TypeMapper.toDartClassName(paramObj.name); - // Format: {Method}{PathContext}{ParamName}Enum - const uniqueEnumTypeName = `${methodPrefix}${pathPart}${paramName}Enum`; + // Format: {Method}{PathContext}{ParamName}Enum, normalised the same way + // endpoint-generator normalises the type it refers to + const uniqueEnumTypeName = TypeMapper.toDartClassName( + `${methodPrefix}${pathPart}${paramName}Enum` + ); // Check if this parameter has an inline enum (not a reference) // From c6ff624786df4e0a9c9fed73266fe0ce5d0f85ed Mon Sep 17 00:00:00 2001 From: Barney Huang Date: Fri, 14 Aug 2026 08:30:49 +0800 Subject: [PATCH 7/9] fix(core): emit enum values as Dart string literals Templates compile with escaping on, so a value reached @JsonValue HTML-escaped: `a=b` became `a=b`, which compiles and then sends the wrong thing over the wire. Dart's own escaping was missing too - a bare `'` closes the literal and `$` starts an interpolation, so `$foo` did not compile at all, and `C:\path` silently lost its backslash. Put the values through a dartString helper that emits raw and escapes what Dart reads as syntax. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/generators/models-enum.test.ts | 24 +++++++++++++++++++ packages/core/src/templates/freezed-enum.hbs | 6 ++--- .../core/src/templates/template-manager.ts | 19 +++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/core/src/__tests__/generators/models-enum.test.ts b/packages/core/src/__tests__/generators/models-enum.test.ts index 52d2f18..b6cb1f8 100644 --- a/packages/core/src/__tests__/generators/models-enum.test.ts +++ b/packages/core/src/__tests__/generators/models-enum.test.ts @@ -267,6 +267,30 @@ describe('Enum Generation', () => { }); }); + describe('values Dart or Handlebars would rewrite', () => { + it('should escape what Dart reads as syntax', () => { + const result = generator.generateEnum('Tricky', ["it's", '$foo', 'C:\\path'], undefined, 'string'); + + // A bare quote closes the literal and `$` starts an interpolation + expect(result.content).toContain("@JsonValue('it\\'s')"); + expect(result.content).toContain("@JsonValue('\\$foo')"); + expect(result.content).toContain("@JsonValue('C:\\\\path')"); + }); + + it('should keep values Handlebars would HTML-escape intact', () => { + const result = generator.generateEnum('Web', ['a=b', 'a&b', 'x>y'], undefined, 'string'); + + // Escaping is on for templates, and `a=b` compiles fine while + // sending the wrong thing over the wire + expect(result.content).toContain("@JsonValue('a=b')"); + expect(result.content).toContain("@JsonValue('a&b')"); + expect(result.content).toContain("@JsonValue('x>y')"); + expect(result.content).not.toContain('='); + expect(result.content).not.toContain('&'); + expect(result.content).not.toContain('>'); + }); + }); + describe('colliding member names', () => { it('should suffix values that differ only in case', () => { const result = generator.generateEnum('Status', ['Active', 'active'], undefined, 'string'); diff --git a/packages/core/src/templates/freezed-enum.hbs b/packages/core/src/templates/freezed-enum.hbs index 2b2c8ef..989257e 100644 --- a/packages/core/src/templates/freezed-enum.hbs +++ b/packages/core/src/templates/freezed-enum.hbs @@ -11,7 +11,7 @@ enum {{enumName}} { {{#if ../isNumeric}} @JsonValue({{value}}) {{else}} - @JsonValue('{{value}}') + @JsonValue('{{dartString value}}') {{/if}} {{name}}{{#unless @last}},{{/unless}} {{#unless @last}} @@ -48,7 +48,7 @@ extension {{enumName}}Extension on {{enumName}} { switch (this) { {{#each values}} case {{../enumName}}.{{name}}: - return '{{value}}'; + return '{{dartString value}}'; {{/each}} } } @@ -58,7 +58,7 @@ extension {{enumName}}Extension on {{enumName}} { switch (value) { {{#each values}} {{#unless (eq name ../unknownName)}} - case '{{value}}': + case '{{dartString value}}': return {{../enumName}}.{{name}}; {{/unless}} {{/each}} diff --git a/packages/core/src/templates/template-manager.ts b/packages/core/src/templates/template-manager.ts index dc1c479..62bef91 100644 --- a/packages/core/src/templates/template-manager.ts +++ b/packages/core/src/templates/template-manager.ts @@ -79,6 +79,25 @@ export class TemplateManager { .replace(/&/g, '&'); }); + // Helper to put a value inside a single-quoted Dart string literal. + // + // Templates are compiled with escaping on, so an interpolated value would + // otherwise arrive HTML-escaped - `a=b` as `a=b`, silently changing + // what goes over the wire. Dart's own escaping still has to happen here: + // `$` starts an interpolation and a bare `'` closes the literal, either of + // which stops the generated file from compiling. + this.handlebars.registerHelper('dartString', (value: unknown) => { + const escaped = String(value ?? '') + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\$/g, '\\$') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\t/g, '\\t'); + + return new this.handlebars.SafeString(escaped); + }); + // Helper to format Dart documentation comments this.handlebars.registerHelper('dartDoc', (text: string, options?: any) => { if (!text) return ''; From 0c64325e838a15ebfea8758b4df9159231cdd377 Mon Sep 17 00:00:00 2001 From: Barney Huang Date: Fri, 14 Aug 2026 09:08:27 +0800 Subject: [PATCH 8/9] fix(core): declare fromValue on the enum, not only the extension A static member of an extension is only reachable through the extension's own name, so Status.fromValue('active') -- what a caller writes -- did not compile. Move the declaration onto the enum and leave a forwarder behind so StatusExtension.fromValue keeps working. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/generators/models-enum.test.ts | 25 ++++++++- packages/core/src/templates/freezed-enum.hbs | 52 +++++++++++-------- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/packages/core/src/__tests__/generators/models-enum.test.ts b/packages/core/src/__tests__/generators/models-enum.test.ts index b6cb1f8..08e234c 100644 --- a/packages/core/src/__tests__/generators/models-enum.test.ts +++ b/packages/core/src/__tests__/generators/models-enum.test.ts @@ -223,13 +223,13 @@ describe('Enum Generation', () => { expect(result.content).toContain(' valueMinus1,'); expect(result.content).toContain(' valueMinus2Point5,'); expect(result.content).toContain(' value1Point5,'); - expect(result.content).toContain(' value15\n'); + expect(result.content).toContain(' value15;'); }); it('should keep exponent notation legal', () => { const result = generator.generateEnum('Big', [1e21], undefined, 'number'); - expect(result.content).toContain(' value1ePlus21\n'); + expect(result.content).toContain(' value1ePlus21;'); }); it('should prefix Dart reserved words', () => { @@ -267,6 +267,27 @@ describe('Enum Generation', () => { }); }); + describe('where fromValue lives', () => { + const enumBodyOf = (content: string, name: string) => + content.slice(content.indexOf(`enum ${name} {`), content.indexOf(`extension ${name}Extension`)); + + it('should declare fromValue on the enum itself', () => { + const result = generator.generateEnum('Reach', ['a'], undefined, 'string'); + + // A static on the extension is only reachable as ReachExtension.fromValue - + // Reach.fromValue, which is what a caller writes, does not resolve to it + expect(enumBodyOf(result.content, 'Reach')).toContain('static Reach fromValue(String? value)'); + // and the old spelling keeps working + expect(result.content).toContain('static Reach fromValue(String? value) => Reach.fromValue(value);'); + }); + + it('should declare fromValue on a numeric enum too', () => { + const result = generator.generateEnum('Code', [1, 2], undefined, 'integer'); + + expect(enumBodyOf(result.content, 'Code')).toContain('static Code? fromValue(num? value)'); + }); + }); + describe('values Dart or Handlebars would rewrite', () => { it('should escape what Dart reads as syntax', () => { const result = generator.generateEnum('Tricky', ["it's", '$foo', 'C:\\path'], undefined, 'string'); diff --git a/packages/core/src/templates/freezed-enum.hbs b/packages/core/src/templates/freezed-enum.hbs index 989257e..4fe1db8 100644 --- a/packages/core/src/templates/freezed-enum.hbs +++ b/packages/core/src/templates/freezed-enum.hbs @@ -13,25 +13,13 @@ enum {{enumName}} { {{else}} @JsonValue('{{dartString value}}') {{/if}} - {{name}}{{#unless @last}},{{/unless}} + {{name}}{{#if @last}};{{else}},{{/if}} {{#unless @last}} {{/unless}} {{/each}} -} -/// Extension methods for {{enumName}} -extension {{enumName}}Extension on {{enumName}} { {{#if isNumeric}} - num get value { - switch (this) { -{{#each values}} - case {{../enumName}}.{{name}}: - return {{value}}; -{{/each}} - } - } - static {{enumName}}? fromValue(num? value) { if (value == null) return null; switch (value) { @@ -44,15 +32,6 @@ extension {{enumName}}Extension on {{enumName}} { } } {{else}} - String get value { - switch (this) { -{{#each values}} - case {{../enumName}}.{{name}}: - return '{{dartString value}}'; -{{/each}} - } - } - static {{enumName}} fromValue(String? value) { if (value == null) return {{enumName}}.{{unknownName}}; switch (value) { @@ -68,3 +47,32 @@ extension {{enumName}}Extension on {{enumName}} { } {{/if}} } + +/// Extension methods for {{enumName}} +extension {{enumName}}Extension on {{enumName}} { +{{#if isNumeric}} + num get value { + switch (this) { +{{#each values}} + case {{../enumName}}.{{name}}: + return {{value}}; +{{/each}} + } + } + + /// Only reachable as {{enumName}}Extension.fromValue - prefer {{enumName}}.fromValue + static {{enumName}}? fromValue(num? value) => {{enumName}}.fromValue(value); +{{else}} + String get value { + switch (this) { +{{#each values}} + case {{../enumName}}.{{name}}: + return '{{dartString value}}'; +{{/each}} + } + } + + /// Only reachable as {{enumName}}Extension.fromValue - prefer {{enumName}}.fromValue + static {{enumName}} fromValue(String? value) => {{enumName}}.fromValue(value); +{{/if}} +} From 604f46e1790000418218b30ee0400d25b504146b Mon Sep 17 00:00:00 2001 From: Barney Huang Date: Fri, 14 Aug 2026 09:24:54 +0800 Subject: [PATCH 9/9] fix(core): give an enum response the type its values have mapType answers String for any schema carrying an enum, so a response referencing `type: number, enum: [1.5, 2.5]` was declared Future and decoded with `response.data as String`, which throws once a number arrives. Take the enum off before asking, the way the typedef path does. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/generators/models-enum.test.ts | 36 ++++++++++++++++++ .../core/src/generators/endpoint-generator.ts | 8 +++- .../core/src/generators/model-generator.ts | 32 +--------------- packages/core/src/getters/enum.ts | 37 +++++++++++++++++++ 4 files changed, 82 insertions(+), 31 deletions(-) diff --git a/packages/core/src/__tests__/generators/models-enum.test.ts b/packages/core/src/__tests__/generators/models-enum.test.ts index 08e234c..56dcfe0 100644 --- a/packages/core/src/__tests__/generators/models-enum.test.ts +++ b/packages/core/src/__tests__/generators/models-enum.test.ts @@ -475,6 +475,42 @@ describe('Enum Generation', () => { }); }); + describe('an enum as a response type', () => { + const jsonResponse = (ref: string) => ({ + description: 'ok', + content: { 'application/json': { schema: { $ref: ref } } } + }); + const responseSpec = { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/ratio': { get: { operationId: 'getRatio', responses: { '200': jsonResponse('#/components/schemas/RatioScale') } } }, + '/flag': { get: { operationId: 'getFlag', responses: { '200': jsonResponse('#/components/schemas/Bools') } } } + }, + components: { + schemas: { + RatioScale: { type: 'number', enum: [1.5, 2.5] }, + Bools: { type: 'boolean', enum: [true, false] } + } + } + } as any; + + it('should keep the type the values have', async () => { + const files = await generateDartCode({ + input: responseSpec, + output: { target: './test', mode: 'split', client: 'dio' } + } as any); + + // Calling a decimal enum a String made the cast throw at runtime + const service = files.find(f => f.path === 'services/default_service.dart'); + expect(service!.content).toContain('Future getRatio('); + expect(service!.content).toContain('return response.data as double;'); + expect(service!.content).toContain('Future getFlag('); + expect(service!.content).toContain('return response.data as bool;'); + expect(service!.content).not.toContain('as String;'); + }); + }); + describe('a header parameter with an enum', () => { const headerSpec = { openapi: '3.0.0', diff --git a/packages/core/src/generators/endpoint-generator.ts b/packages/core/src/generators/endpoint-generator.ts index bce27f5..a3cb279 100644 --- a/packages/core/src/generators/endpoint-generator.ts +++ b/packages/core/src/generators/endpoint-generator.ts @@ -5,6 +5,7 @@ import type { OpenAPIV3 } from 'openapi-types'; import { TypeMapper } from '../utils'; import { ReferenceResolver } from '../resolvers'; +import { isEnum, scalarTypeOfEnumSchema } from '../getters/enum'; export interface EndpointMethod { methodName: string; @@ -785,7 +786,12 @@ export class EndpointGenerator { }; } - const dartType = TypeMapper.mapType(schemaObj); + // An enum response keeps the type its values have. mapType answers String + // for anything carrying an enum, so a `type: number` enum came back as a + // String the generated cast then threw on. + const dartType = isEnum(schemaObj) + ? scalarTypeOfEnumSchema(schemaObj, TypeMapper.mapType.bind(TypeMapper)) + : TypeMapper.mapType(schemaObj); const type = isNullable ? `${dartType}?` : dartType; // Check if it's a primitive diff --git a/packages/core/src/generators/model-generator.ts b/packages/core/src/generators/model-generator.ts index 52e638e..d809676 100644 --- a/packages/core/src/generators/model-generator.ts +++ b/packages/core/src/generators/model-generator.ts @@ -7,29 +7,7 @@ import { DartModel, DartProperty, GeneratedFile } from '../types'; import { TypeMapper } from '../utils'; import { ReferenceResolver } from '../resolvers'; import { TemplateManager } from '../templates/template-manager'; -import { buildEnumMembers, uniqueEnumMemberName } from '../getters/enum'; - -const SCALAR_BY_VALUE_TYPE: Record = { - boolean: 'bool', - // An integer set Dart can express never reaches the typedef path, so a number - // left here is a decimal or outside int range - number: 'double', - string: 'String' -}; - -/** - * Dart type for a set of enum values, for schemas that declare no type. - */ -function scalarTypeOfValues(values: unknown[]): string { - const valueTypes = new Set((values ?? []).filter(value => value !== null).map(value => typeof value)); - - if (valueTypes.size !== 1) { - return 'dynamic'; - } - - const [valueType] = valueTypes; - return SCALAR_BY_VALUE_TYPE[valueType] ?? 'dynamic'; -} +import { buildEnumMembers, scalarTypeOfEnumSchema, uniqueEnumMemberName } from '../getters/enum'; export class ModelGenerator { private templateManager: TemplateManager; @@ -128,13 +106,7 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = generateScalarTypedef(name: string, schema: OpenAPIV3.SchemaObject): GeneratedFile { const className = TypeMapper.toDartClassName(name); const fileName = TypeMapper.toSnakeCase(name); - // mapType answers String for anything carrying an enum, so ask about the - // underlying scalar instead. A spec is free to leave the type out, and then - // the values are all there is to go on. - const { enum: values, ...scalarSchema } = schema as any; - const dartType = scalarSchema.type - ? TypeMapper.mapType(scalarSchema) - : scalarTypeOfValues(values as unknown[]); + const dartType = scalarTypeOfEnumSchema(schema, TypeMapper.mapType.bind(TypeMapper)); const content = `// Generated typedef: the enum values have no @JsonValue representation ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} = ${dartType}; diff --git a/packages/core/src/getters/enum.ts b/packages/core/src/getters/enum.ts index 5b32b93..1a7b7ef 100644 --- a/packages/core/src/getters/enum.ts +++ b/packages/core/src/getters/enum.ts @@ -154,6 +154,43 @@ export function enumValueToDartName(value: string | number | boolean | null): st return legalizeEnumMemberName(baseName); } +const SCALAR_BY_VALUE_TYPE: Record = { + boolean: 'bool', + // An integer set Dart can express never reaches the typedef path, so a number + // left here is a decimal or outside int range + number: 'double', + string: 'String' +}; + +/** + * Dart type for a set of enum values, for schemas that declare no type. + */ +export function scalarTypeOfEnumValues(values: unknown[]): string { + const valueTypes = new Set((values ?? []).filter(value => value !== null).map(value => typeof value)); + + if (valueTypes.size !== 1) { + return 'dynamic'; + } + + const [valueType] = valueTypes; + return SCALAR_BY_VALUE_TYPE[valueType] ?? 'dynamic'; +} + +/** + * Dart type an enum schema carries underneath its values. + * + * TypeMapper.mapType answers String for anything carrying an enum, so the enum + * has to come off before asking. A `type: number` enum is a double, and calling + * it a String makes the generated cast throw at runtime. + */ +export function scalarTypeOfEnumSchema(schema: OpenAPIV3.SchemaObject, mapType: (s: OpenAPIV3.SchemaObject) => string): string { + const { enum: values, ...scalarSchema } = schema as any; + + return scalarSchema.type + ? mapType(scalarSchema as OpenAPIV3.SchemaObject) + : scalarTypeOfEnumValues(values as unknown[]); +} + /** * Build the member list for an enum: one member per distinct value, each named * legally and uniquely within the enum.