Skip to content

Commit

Permalink
Implement support for discriminated unions (#287)
Browse files Browse the repository at this point in the history
  • Loading branch information
CallMeSH authored Jan 16, 2025
1 parent 9ef123d commit 53a90fc
Show file tree
Hide file tree
Showing 20 changed files with 448 additions and 19 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,35 @@ 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
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
88 changes: 86 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,27 @@ 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 +955,7 @@ class OpenApiParser {
final (:type, :import) = _findType(
arrayItems,
name: name,
additionalName: name,
additionalName: additionalName,
root: false,
isRequired: isRequired,
);
Expand Down Expand Up @@ -1122,9 +1146,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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, unused_import

import 'package:freezed_annotation/freezed_annotation.dart';

@JsonEnum()
enum CatType {
@JsonValue('Cat')
cat('Cat'),

/// Default value for all unparsed values, allows backward compatibility when adding new values on the backend.
$unknown(null);

const CatType(this.json);

factory CatType.fromJson(String json) => values.firstWhere(
(e) => e.json == json,
orElse: () => $unknown,
);

final String? json;
}
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 'dog_type.dart';

part 'dog.freezed.dart';
part 'dog.g.dart';

@Freezed()
class Dog with _$Dog {
const factory Dog({
required DogType type,

/// The sound of the dog's bark.
required String barkSound,
}) = _Dog;

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

0 comments on commit 53a90fc

Please sign in to comment.