Skip to content

Commit

Permalink
feat: implement support for discriminated unions in Freezed DTO gener…
Browse files Browse the repository at this point in the history
…ation

- Added discriminator handling in `UniversalComponentClass` to support oneOf variants.
- Updated `dart_freezed_dto_template.dart` to generate sealed classes and union factories based on discriminator values.
- Introduced new test cases for discriminated oneOf schemas, including a sample JSON schema and expected output files.
- Enhanced OpenApiParser to correctly parse and map discriminator properties from OpenAPI specifications.
  • Loading branch information
CallMeSH committed Jan 10, 2025
1 parent 7e863f7 commit 720791f
Show file tree
Hide file tree
Showing 20 changed files with 452 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,9 @@ ${dartImports(imports: dataClass.imports)}
part '${dataClass.name.toSnake}.freezed.dart';
part '${dataClass.name.toSnake}.g.dart';
${descriptionComment(dataClass.description)}@Freezed()
class $className with _\$$className {
const factory $className(${dataClass.parameters.isNotEmpty ? '{' : ''}${_parametersToString(
dataClass.parameters,
)}${dataClass.parameters.isNotEmpty ? '\n }' : ''}) = _$className;
${descriptionComment(dataClass.description)}@Freezed(${dataClass.discriminator != null ? "unionKey: '${dataClass.discriminator!.propertyName}'" : ''})
${dataClass.discriminator != null ? 'sealed ' : ''}class $className with _\$$className {
${_factories(dataClass, className)}
\n factory $className.fromJson(Map<String, Object?> json) => _\$${className}FromJson(json);
${generateValidator ? dataClass.parameters.map(_validationString).nonNulls.join() : ''}}
${generateValidator ? _validateMethod(className, dataClass.parameters) : ''}''';
Expand Down Expand Up @@ -154,6 +152,33 @@ String _validateMethod(String className, List<UniversalType> types) {
return funcBuffer.toString();
}

String _factories(UniversalComponentClass dataClass, String className) {
if (dataClass.discriminator == null) {
return '''
const factory $className(${dataClass.parameters.isNotEmpty ? '{' : ''}${_parametersToString(
dataClass.parameters,
)}${dataClass.parameters.isNotEmpty ? '\n }' : ''}) = _$className;
''';
}

final factories = <String>[];
for (final discriminatorValue in dataClass.discriminator!.discriminatorValueToRefMapping.keys) {
final factoryName = discriminatorValue.toCamel;
final discriminatorRef = dataClass.discriminator!.discriminatorValueToRefMapping[discriminatorValue]!;
final factoryParameters = dataClass.discriminator!.refProperties[discriminatorRef]!;
final unionItemClassName = discriminatorRef.toPascal;

factories.add('''
@FreezedUnionValue('$discriminatorValue')
const factory $className.$factoryName(${factoryParameters.isNotEmpty ? '{' : ''}${_parametersToString(
factoryParameters,
)}${factoryParameters.isNotEmpty ? '\n }' : ''}) = $unionItemClassName;
''');
}

return factories.join('\n');
}

String? _validationString(UniversalType type) {
final sb = StringBuffer();
if (type.min != null) {
Expand Down Expand Up @@ -200,12 +225,10 @@ String? _validationString(UniversalType type) {
}

String _parametersToString(List<UniversalType> parameters) {
final sortedByRequired =
List<UniversalType>.from(parameters.sorted((a, b) => a.compareTo(b)));
final sortedByRequired = List<UniversalType>.from(parameters.sorted((a, b) => a.compareTo(b)));
return sortedByRequired
.mapIndexed(
(i, e) =>
'\n${i != 0 && (e.description?.isNotEmpty ?? false) ? '\n' : ''}${descriptionComment(e.description, tab: ' ')}'
(i, e) => '\n${i != 0 && (e.description?.isNotEmpty ?? false) ? '\n' : ''}${descriptionComment(e.description, tab: ' ')}'
'${_jsonKey(e)} ${_required(e)}'
'${e.toSuitableType(ProgrammingLanguage.dart)} ${e.name},',
)
Expand All @@ -228,12 +251,10 @@ String _jsonKey(UniversalType t) {
}

/// return required if isRequired
String _required(UniversalType t) =>
t.isRequired && t.defaultValue == null ? 'required ' : '';
String _required(UniversalType t) => t.isRequired && t.defaultValue == null ? 'required ' : '';

/// return defaultValue if have
String _defaultValue(UniversalType t) =>
'${t.enumType != null ? '${t.type}.${protectDefaultEnum(t.defaultValue)?.toCamel}' : protectDefaultValue(
t.defaultValue,
type: t.type,
)}';
String _defaultValue(UniversalType t) => '${t.enumType != null ? '${t.type}.${protectDefaultEnum(t.defaultValue)?.toCamel}' : protectDefaultValue(
t.defaultValue,
type: t.type,
)}';
13 changes: 13 additions & 0 deletions swagger_parser/lib/src/parser/model/universal_component_class.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ final class UniversalComponentClass extends UniversalDataClass {
required this.parameters,
this.allOf,
this.typeDef = false,
this.discriminator,
super.description,
});

Expand All @@ -22,6 +23,18 @@ final class UniversalComponentClass extends UniversalDataClass {
/// Temp field for containing info about `allOf` for future processing
final ({List<String> refs, List<UniversalType> properties})? allOf;

/// When using a discriminated oneOf, this contains the information about the property name, the mapping of the ref to the property name, and the properties of each of the oneOf variants
final ({
// The name of the property that is used to discriminate the oneOf variants
String propertyName,

// The mapping of the property value to the ref
Map<String, String> discriminatorValueToRefMapping,

// The list of properties stored for each ref
Map<String, List<UniversalType>> refProperties,
})? discriminator;

/// Whether or not this schema is a basic type
/// "Date": {
/// "type": "string",
Expand Down
87 changes: 85 additions & 2 deletions swagger_parser/lib/src/parser/parser/open_api_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ class OpenApiParser {
static const _definitionsConst = 'definitions';
static const _descriptionConst = 'description';
static const _deprecatedConst = 'deprecated';
static const _discriminatorConst = 'discriminator';
static const _enumConst = 'enum';
static const _formatConst = 'format';
static const _formUrlEncodedConst = 'application/x-www-form-urlencoded';
static const _inConst = 'in';
static const _infoConst = 'info';
static const _itemsConst = 'items';
static const _mappingConst = 'mapping';
static const _multipartFormDataConst = 'multipart/form-data';
static const _nameConst = 'name';
static const _nullableConst = 'nullable';
Expand All @@ -70,6 +72,7 @@ class OpenApiParser {
static const _parametersConst = 'parameters';
static const _pathsConst = 'paths';
static const _propertiesConst = 'properties';
static const _propertyNameConst = 'propertyName';
static const _refConst = r'$ref';
static const _requestBodyConst = 'requestBody';
static const _requestBodiesConst = 'requestBodies';
Expand Down Expand Up @@ -895,6 +898,26 @@ class OpenApiParser {
}
allOfClass.parameters.addAll(allOfClass.allOf!.properties);
}

// check for discriminated oneOf
final discriminatedOneOfClasses = dataClasses.where(
(dc) => dc is UniversalComponentClass && dc.discriminator != null);
for (final discriminatedOneOfClass in discriminatedOneOfClasses) {
if (discriminatedOneOfClass is! UniversalComponentClass) {
continue;
}
final discriminator = discriminatedOneOfClass.discriminator!;
// for each ref, we lookup the matching dataclass and add its properties to the discriminator mapping, its imports are added to the discriminatedOneOfClass's imports
for (final ref in discriminator.discriminatorValueToRefMapping.values) {
final refedClass = dataClasses.firstWhere((dc) => dc.name == ref);
if (refedClass is! UniversalComponentClass) {
continue;
}
discriminator.refProperties[ref] = refedClass.parameters;
discriminatedOneOfClass.imports.addAll(refedClass.imports);
}
}

return dataClasses;
}

Expand Down Expand Up @@ -931,7 +954,7 @@ class OpenApiParser {
final (:type, :import) = _findType(
arrayItems,
name: name,
additionalName: name,
additionalName: additionalName,
root: false,
isRequired: isRequired,
);
Expand Down Expand Up @@ -1122,9 +1145,69 @@ class OpenApiParser {
map.containsKey(_anyOfConst) ||
map.containsKey(_oneOfConst) ||
map[_typeConst] is List) {
// Handle discriminated oneOf
if (map.containsKey(_oneOfConst) &&
map.containsKey(_discriminatorConst) &&
(map[_discriminatorConst] as Map<String, dynamic>)
.containsKey(_propertyNameConst) &&
(map[_discriminatorConst] as Map<String, dynamic>)
.containsKey(_mappingConst)) {
final discriminator = map[_discriminatorConst] as Map<String, dynamic>;
final propertyName = discriminator[_propertyNameConst] as String;
final refMapping = discriminator[_mappingConst] as Map<String, dynamic>;

// Create a base union class for the discriminated types
final baseClassName =
'${additionalName ?? ''} ${name ?? ''} Union'.toPascal;
final (newName, description) = protectName(
baseClassName,
uniqueIfNull: true,
description: map[_descriptionConst]?.toString(),
);

// Cleanup the refMapping to contain only the class name
final cleanedRefMapping = <String, String>{};
for (final key in refMapping.keys) {
final refMap = <String, dynamic>{_refConst: refMapping[key]};
cleanedRefMapping[key] = _formatRef(refMap);
}

// Create a sealed class to represent the discriminated union
_objectClasses.add(
UniversalComponentClass(
name: newName!.toPascal,
imports: SplayTreeSet<String>(),
parameters: [
UniversalType(
type: 'String',
name: propertyName,
isRequired: true,
),
],
discriminator: (
propertyName: propertyName,
discriminatorValueToRefMapping: cleanedRefMapping,
// This property is populated by the parser after all the data classes are created
refProperties: <String, List<UniversalType>>{},
),
),
);

return (
type: UniversalType(
type: newName.toPascal,
name: name?.toCamel,
description: description,
isRequired: isRequired,
nullable: map[_nullableConst].toString().toBool() ??
(root && !isRequired),
),
import: newName.toPascal,
);
}

String? ofImport;
UniversalType? ofType;

final ofList = map[_allOfConst] ??
map[_anyOfConst] ??
map[_oneOfConst] ??
Expand Down
13 changes: 13 additions & 0 deletions swagger_parser/test/e2e/e2e_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,19 @@ void main() {
);
});

test('discriminated_one_of.3.0', () async {
await e2eTest(
'basic/discriminated_one_of.3.0',
(outputDirectory, schemaPath) => SWPConfig(
outputDirectory: outputDirectory,
schemaPath: schemaPath,
jsonSerializer: JsonSerializer.freezed,
putClientsInFolder: true,
),
schemaFileName: 'discriminated_one_of.3.0.json',
);
});

test('empty_class.2.0', () async {
await e2eTest(
'basic/empty_class.2.0',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{
"openapi": "3.1.0",
"info": {
"title": "Family API",
"version": "1.0.0"
},
"paths": {},
"components": {
"schemas": {
"Family": {
"type": "object",
"properties": {
"members": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/Cat"
},
{
"$ref": "#/components/schemas/Dog"
},
{
"$ref": "#/components/schemas/Human"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"Cat": "#/components/schemas/Cat",
"Dog": "#/components/schemas/Dog",
"Human": "#/components/schemas/Human"
}
}
}
}
}
},
"Cat": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["Cat"]
},
"mewCount": {
"type": "integer",
"description": "Number of times the cat meows."
}
},
"required": ["type", "mewCount"]
},
"Dog": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["Dog"]
},
"barkSound": {
"type": "string",
"description": "The sound of the dog's bark."
}
},
"required": ["type", "barkSound"]
},
"Human": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["Human"]
},
"job": {
"type": "string",
"description": "The job of the human."
}
},
"required": ["type", "job"]
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, unused_import

// Data classes
export 'models/family.dart';
export 'models/cat.dart';
export 'models/dog.dart';
export 'models/human.dart';
export 'models/family_members_union.dart';
export 'models/cat_type.dart';
export 'models/dog_type.dart';
export 'models/human_type.dart';
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, unused_import

import 'package:freezed_annotation/freezed_annotation.dart';

import 'cat_type.dart';

part 'cat.freezed.dart';
part 'cat.g.dart';

@Freezed()
class Cat with _$Cat {
const factory Cat({
required CatType type,

/// Number of times the cat meows.
required int mewCount,
}) = _Cat;

factory Cat.fromJson(Map<String, Object?> json) => _$CatFromJson(json);
}
Loading

0 comments on commit 720791f

Please sign in to comment.