Skip to content
361 changes: 359 additions & 2 deletions packages/core/src/__tests__/generators/models-enum.test.ts

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions packages/core/src/__tests__/getters/enum-data.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 14 additions & 3 deletions packages/core/src/generators/endpoint-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -925,8 +931,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
Expand Down
115 changes: 46 additions & 69 deletions packages/core/src/generators/model-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { DartModel, DartProperty, GeneratedFile } from '../types';
import { TypeMapper } from '../utils';
import { ReferenceResolver } from '../resolvers';
import { TemplateManager } from '../templates/template-manager';
import { buildEnumMembers, scalarTypeOfEnumSchema, uniqueEnumMemberName } from '../getters/enum';

export class ModelGenerator {
private templateManager: TemplateManager;
Expand Down Expand Up @@ -98,6 +99,25 @@ ${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);
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};
`;

return {
path: `models/${fileName}.f.dart`,
content
};
}

/**
* Convert OpenAPI schema to DartModel
*/
Expand Down Expand Up @@ -289,7 +309,7 @@ ${schema.description ? `/// ${schema.description}\n` : ''}typedef ${className} =
*/
generateEnum(
name: string,
values: (string | number | null)[],
values: (string | number | boolean | null)[],
description?: string,
type?: string
): GeneratedFile {
Expand All @@ -306,84 +326,41 @@ ${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 === '') {
return {
name: 'empty',
value: '',
description: 'Empty string'
};
}

// 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
};
});
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
// 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'
});
}
}

// 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 render as bare Dart literals, strings stay quoted
isNumeric,
unknownName
};

const content = this.templateManager.render('freezed-enum', templateData);
Expand Down
31 changes: 24 additions & 7 deletions packages/core/src/generators/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`;

Expand All @@ -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`;

Expand Down Expand Up @@ -223,11 +223,21 @@ 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)
if (schema.enum && Array.isArray(schema.enum) && !('$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];
Expand Down Expand Up @@ -347,10 +357,17 @@ export async function generateModels(
// which generates typedef = Map<String, dynamic> 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,
schema.enum as (string | number | null)[],
schema.enum as (string | number | boolean | null)[],
schema.description,
schema.type as string | undefined
);
Expand Down
Loading
Loading