diff --git a/packages/tonik/README.md b/packages/tonik/README.md
index 097e1cf..73867e7 100644
--- a/packages/tonik/README.md
+++ b/packages/tonik/README.md
@@ -12,6 +12,9 @@
+
+
+
diff --git a/packages/tonik_core/lib/src/model/request_body.dart b/packages/tonik_core/lib/src/model/request_body.dart
index 9e35d4c..db23f77 100644
--- a/packages/tonik_core/lib/src/model/request_body.dart
+++ b/packages/tonik_core/lib/src/model/request_body.dart
@@ -115,30 +115,16 @@ class RequestBodyObject extends RequestBody {
'isRequired: $isRequired, content: $content)';
}
-@immutable
class RequestContent {
- const RequestContent({
+ RequestContent({
required this.model,
required this.contentType,
required this.rawContentType,
});
- final Model model;
- final ContentType contentType;
- final String rawContentType;
-
- @override
- bool operator ==(Object other) {
- if (identical(this, other)) return true;
- if (other is! RequestContent) return false;
-
- return model == other.model &&
- contentType == other.contentType &&
- rawContentType == other.rawContentType;
- }
-
- @override
- int get hashCode => Object.hash(model, contentType, rawContentType);
+ Model model;
+ ContentType contentType;
+ String rawContentType;
@override
String toString() =>
diff --git a/packages/tonik_core/lib/src/model/response.dart b/packages/tonik_core/lib/src/model/response.dart
index efa2843..c075605 100644
--- a/packages/tonik_core/lib/src/model/response.dart
+++ b/packages/tonik_core/lib/src/model/response.dart
@@ -2,7 +2,6 @@ import 'package:collection/collection.dart';
import 'package:meta/meta.dart';
import 'package:tonik_core/tonik_core.dart';
-@immutable
sealed class Response {
const Response({required this.name, required this.context});
@@ -128,29 +127,16 @@ class ResponseObject extends Response {
'description: $description, bodies: $bodies)';
}
-@immutable
class ResponseBody {
- const ResponseBody({
+ ResponseBody({
required this.model,
required this.rawContentType,
required this.contentType,
});
- final Model model;
- final String rawContentType;
- final ContentType contentType;
-
- @override
- bool operator ==(Object other) {
- if (identical(this, other)) return true;
- if (other is! ResponseBody) return false;
- return model == other.model &&
- rawContentType == other.rawContentType &&
- contentType == other.contentType;
- }
-
- @override
- int get hashCode => Object.hash(model, rawContentType);
+ Model model;
+ String rawContentType;
+ ContentType contentType;
@override
String toString() => 'ResponseBody(model: $model, contentType: $contentType)';
diff --git a/packages/tonik_core/lib/src/model/response_header.dart b/packages/tonik_core/lib/src/model/response_header.dart
index 4449199..f314dc7 100644
--- a/packages/tonik_core/lib/src/model/response_header.dart
+++ b/packages/tonik_core/lib/src/model/response_header.dart
@@ -69,9 +69,8 @@ class ResponseHeaderAlias extends ResponseHeader {
int get hashCode => Object.hash(name, header, description);
}
-@immutable
class ResponseHeaderObject extends ResponseHeader {
- const ResponseHeaderObject({
+ ResponseHeaderObject({
required super.name,
required super.context,
required this.description,
@@ -85,41 +84,15 @@ class ResponseHeaderObject extends ResponseHeader {
@override
final String? description;
- final bool explode;
- final Model model;
- final bool isRequired;
- final bool isDeprecated;
- final ResponseHeaderEncoding encoding;
+ bool explode;
+ Model model;
+ bool isRequired;
+ bool isDeprecated;
+ ResponseHeaderEncoding encoding;
@override
String toString() =>
'HeaderObject{name: $name, description: $description, '
'explode: $explode, model: $model, isRequired: $isRequired, '
'isDeprecated: $isDeprecated, encoding: $encoding, context: $context}';
-
- @override
- bool operator ==(Object other) =>
- identical(this, other) ||
- other is ResponseHeaderObject &&
- runtimeType == other.runtimeType &&
- name == other.name &&
- description == other.description &&
- explode == other.explode &&
- model == other.model &&
- isRequired == other.isRequired &&
- isDeprecated == other.isDeprecated &&
- encoding == other.encoding &&
- context == other.context;
-
- @override
- int get hashCode => Object.hash(
- name,
- description,
- explode,
- model,
- isRequired,
- isDeprecated,
- encoding,
- context,
- );
}
diff --git a/packages/tonik_core/lib/src/transformer/allof_normalizer.dart b/packages/tonik_core/lib/src/transformer/allof_normalizer.dart
new file mode 100644
index 0000000..aa3b3c2
--- /dev/null
+++ b/packages/tonik_core/lib/src/transformer/allof_normalizer.dart
@@ -0,0 +1,252 @@
+import 'package:meta/meta.dart';
+import 'package:tonik_core/tonik_core.dart';
+
+/// Normalizes AllOfModel instances with a single contained model to AliasModel.
+///
+/// This transformer performs in-place replacement throughout the entire
+/// document, ensuring referential consistency by memoizing transformations.
+@immutable
+class AllOfNormalizer {
+ const AllOfNormalizer();
+
+ /// Normalizes allOf schemas with a single model to type aliases.
+ ///
+ /// This simplifies patterns like `allOf: [$ref, {description: ...}]` used
+ /// by Spotify and others to add descriptions to referenced schemas.
+ ApiDocument apply(ApiDocument document) {
+ final cache = {};
+
+ final transformedModels = {};
+ for (final model in document.models) {
+ transformedModels.add(_transformModel(model, cache));
+ }
+ document.models = transformedModels;
+
+ for (final model in document.models) {
+ if (model is ClassModel) {
+ for (final prop in model.properties) {
+ final transformed = cache[prop.model];
+ if (transformed != null && transformed != prop.model) {
+ prop.model = transformed;
+ }
+ }
+ }
+ }
+
+ for (final response in document.responses) {
+ _updateResponseModels(response, cache);
+ }
+
+ for (final operation in document.operations) {
+ for (final response in operation.responses.values) {
+ _updateResponseModels(response, cache);
+ }
+
+ final requestBody = operation.requestBody;
+ if (requestBody != null) {
+ _updateRequestBodyModels(requestBody, cache);
+ }
+
+ for (final header in operation.headers) {
+ _updateRequestHeaderModel(header, cache);
+ }
+
+ for (final param in operation.queryParameters) {
+ _updateQueryParameterModel(param, cache);
+ }
+
+ for (final param in operation.pathParameters) {
+ _updatePathParameterModel(param, cache);
+ }
+ }
+
+ for (final requestBody in document.requestBodies) {
+ _updateRequestBodyModels(requestBody, cache);
+ }
+
+ for (final header in document.responseHeaders) {
+ _updateResponseHeaderModel(header, cache);
+ }
+
+ for (final header in document.requestHeaders) {
+ _updateRequestHeaderModel(header, cache);
+ }
+
+ for (final param in document.queryParameters) {
+ _updateQueryParameterModel(param, cache);
+ }
+ for (final param in document.pathParameters) {
+ _updatePathParameterModel(param, cache);
+ }
+
+ return document;
+ }
+
+ /// Transforms a model, normalizing single-model AllOfModels to AliasModels.
+ Model _transformModel(Model model, Map cache) {
+ if (cache.containsKey(model)) {
+ return cache[model]!;
+ }
+
+ // Placeholder to handle cycles
+ cache[model] = model;
+
+ final Model result;
+
+ if (model is AllOfModel && model.models.length == 1) {
+ final containedModel = _transformModel(model.models.first, cache);
+ result = AliasModel(
+ name: model.name,
+ model: containedModel,
+ context: model.context,
+ description: model.description,
+ isDeprecated: model.isDeprecated,
+ isNullable: model.isNullable,
+ nameOverride: model.nameOverride,
+ );
+ } else if (model is AllOfModel) {
+ final newModels = {};
+ for (final m in model.models) {
+ newModels.add(_transformModel(m, cache));
+ }
+ model.models
+ ..clear()
+ ..addAll(newModels);
+ result = model;
+ } else if (model is ClassModel) {
+ for (final prop in model.properties) {
+ prop.model = _transformModel(prop.model, cache);
+ }
+ result = model;
+ } else if (model is OneOfModel) {
+ final newModels = <({String? discriminatorValue, Model model})>{};
+ for (final m in model.models) {
+ newModels.add((
+ discriminatorValue: m.discriminatorValue,
+ model: _transformModel(m.model, cache),
+ ));
+ }
+ model.models
+ ..clear()
+ ..addAll(newModels);
+ result = model;
+ } else if (model is AnyOfModel) {
+ final newModels = <({String? discriminatorValue, Model model})>{};
+ for (final m in model.models) {
+ newModels.add((
+ discriminatorValue: m.discriminatorValue,
+ model: _transformModel(m.model, cache),
+ ));
+ }
+ model.models
+ ..clear()
+ ..addAll(newModels);
+ result = model;
+ } else if (model is ListModel) {
+ model.content = _transformModel(model.content, cache);
+ result = model;
+ } else if (model is AliasModel) {
+ model.model = _transformModel(model.model, cache);
+ result = model;
+ } else {
+ result = model;
+ }
+
+ cache[model] = result;
+ return result;
+ }
+
+ void _updateResponseModels(Response response, Map cache) {
+ switch (response) {
+ case ResponseAlias():
+ _updateResponseModels(response.response, cache);
+ case ResponseObject():
+ for (final body in response.bodies) {
+ final transformed = cache[body.model];
+ if (transformed != null && transformed != body.model) {
+ body.model = transformed;
+ }
+ }
+ for (final header in response.headers.values) {
+ _updateResponseHeaderModel(header, cache);
+ }
+ }
+ }
+
+ void _updateRequestBodyModels(
+ RequestBody requestBody,
+ Map cache,
+ ) {
+ switch (requestBody) {
+ case RequestBodyAlias():
+ _updateRequestBodyModels(requestBody.requestBody, cache);
+ case RequestBodyObject():
+ for (final content in requestBody.content) {
+ final transformed = cache[content.model];
+ if (transformed != null && transformed != content.model) {
+ content.model = transformed;
+ }
+ }
+ }
+ }
+
+ void _updateResponseHeaderModel(
+ ResponseHeader header,
+ Map cache,
+ ) {
+ switch (header) {
+ case ResponseHeaderAlias():
+ _updateResponseHeaderModel(header.header, cache);
+ case ResponseHeaderObject():
+ final transformed = cache[header.model];
+ if (transformed != null && transformed != header.model) {
+ header.model = transformed;
+ }
+ }
+ }
+
+ void _updateRequestHeaderModel(
+ RequestHeader header,
+ Map cache,
+ ) {
+ switch (header) {
+ case RequestHeaderAlias():
+ _updateRequestHeaderModel(header.header, cache);
+ case RequestHeaderObject():
+ final transformed = cache[header.model];
+ if (transformed != null && transformed != header.model) {
+ header.model = transformed;
+ }
+ }
+ }
+
+ void _updateQueryParameterModel(
+ QueryParameter param,
+ Map cache,
+ ) {
+ switch (param) {
+ case QueryParameterAlias():
+ _updateQueryParameterModel(param.parameter, cache);
+ case QueryParameterObject():
+ final transformed = cache[param.model];
+ if (transformed != null && transformed != param.model) {
+ param.model = transformed;
+ }
+ }
+ }
+
+ void _updatePathParameterModel(
+ PathParameter param,
+ Map cache,
+ ) {
+ switch (param) {
+ case PathParameterAlias():
+ _updatePathParameterModel(param.parameter, cache);
+ case PathParameterObject():
+ final transformed = cache[param.model];
+ if (transformed != null && transformed != param.model) {
+ param.model = transformed;
+ }
+ }
+ }
+}
diff --git a/packages/tonik_core/lib/tonik_core.dart b/packages/tonik_core/lib/tonik_core.dart
index 598ff2b..222088f 100644
--- a/packages/tonik_core/lib/tonik_core.dart
+++ b/packages/tonik_core/lib/tonik_core.dart
@@ -24,6 +24,7 @@ export 'src/model/security_scheme.dart';
export 'src/model/server.dart';
export 'src/model/server_variable.dart';
export 'src/model/tag.dart';
+export 'src/transformer/allof_normalizer.dart';
export 'src/transformer/config_transformer.dart';
export 'src/transformer/content_type_normalizer.dart';
export 'src/util/context.dart';
diff --git a/packages/tonik_core/test/src/transformer/allof_normalizer_test.dart b/packages/tonik_core/test/src/transformer/allof_normalizer_test.dart
new file mode 100644
index 0000000..5da906c
--- /dev/null
+++ b/packages/tonik_core/test/src/transformer/allof_normalizer_test.dart
@@ -0,0 +1,675 @@
+import 'package:test/test.dart';
+import 'package:tonik_core/tonik_core.dart';
+
+void main() {
+ group('AllOfNormalizer', () {
+ late AllOfNormalizer normalizer;
+ late Context context;
+
+ setUp(() {
+ normalizer = const AllOfNormalizer();
+ context = Context.initial();
+ });
+
+ group('normalization', () {
+ test('converts AllOfModel with single ref to AliasModel', () {
+ final baseModel = ClassModel(
+ name: 'BaseModel',
+ properties: const [],
+ context: context.push('BaseModel'),
+ isDeprecated: false,
+ );
+
+ final allOfModel = AllOfModel(
+ name: 'ExtendedModel',
+ models: {baseModel},
+ context: context.push('ExtendedModel'),
+ description: 'Additional documentation',
+ isDeprecated: false,
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {baseModel, allOfModel},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ final extendedModel = transformed.models.firstWhere(
+ (m) => m is NamedModel && m.name == 'ExtendedModel',
+ );
+
+ expect(extendedModel, isA());
+ final alias = extendedModel as AliasModel;
+ expect(alias.model, isA());
+ expect((alias.model as ClassModel).name, equals('BaseModel'));
+ expect(alias.description, equals('Additional documentation'));
+ expect(alias.isDeprecated, isFalse);
+ });
+
+ test('preserves deprecated flag from AllOfModel', () {
+ final baseModel = StringModel(context: context);
+
+ final allOfModel = AllOfModel(
+ name: 'DeprecatedAlias',
+ models: {baseModel},
+ context: context.push('DeprecatedAlias'),
+ description: 'This is deprecated',
+ isDeprecated: true,
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {allOfModel},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ final model = transformed.models.first as AliasModel;
+ expect(model.isDeprecated, isTrue);
+ expect(model.description, equals('This is deprecated'));
+ });
+
+ test('preserves nullable flag from AllOfModel', () {
+ final baseModel = IntegerModel(context: context);
+
+ final allOfModel = AllOfModel(
+ name: 'NullableAlias',
+ models: {baseModel},
+ context: context.push('NullableAlias'),
+ isDeprecated: false,
+ isNullable: true,
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {allOfModel},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ final model = transformed.models.first as AliasModel;
+ expect(model.isNullable, isTrue);
+ });
+
+ test('normalizes anonymous AllOfModel with single model', () {
+ final baseModel = NumberModel(context: context);
+
+ final allOfModel = AllOfModel(
+ models: {baseModel},
+ context: context.push('allOf'),
+ isDeprecated: false,
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {allOfModel},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ // Anonymous single-model AllOfModels ARE normalized to AliasModel
+ final model = transformed.models.first as AliasModel;
+ expect(model.name, isNull);
+ expect(model.model, isA());
+ });
+ });
+
+ group('does not normalize', () {
+ test('AllOfModel with multiple models', () {
+ final model1 = ClassModel(
+ name: 'Model1',
+ properties: const [],
+ context: context.push('Model1'),
+ isDeprecated: false,
+ );
+
+ final model2 = ClassModel(
+ name: 'Model2',
+ properties: const [],
+ context: context.push('Model2'),
+ isDeprecated: false,
+ );
+
+ final allOfModel = AllOfModel(
+ name: 'CompositeModel',
+ models: {model1, model2},
+ context: context.push('CompositeModel'),
+ isDeprecated: false,
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {model1, model2, allOfModel},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ final composite = transformed.models.firstWhere(
+ (m) => m is NamedModel && m.name == 'CompositeModel',
+ );
+
+ expect(composite, isA());
+ expect((composite as AllOfModel).models, hasLength(2));
+ });
+
+ test('non-AllOfModel remains unchanged', () {
+ final classModel = ClassModel(
+ name: 'RegularModel',
+ properties: const [],
+ context: context.push('RegularModel'),
+ isDeprecated: false,
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {classModel},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ expect(transformed.models.first, isA());
+ expect((transformed.models.first as ClassModel).name, 'RegularModel');
+ });
+
+ test('AliasModel remains unchanged', () {
+ final baseModel = StringModel(context: context);
+
+ final aliasModel = AliasModel(
+ name: 'ExistingAlias',
+ model: baseModel,
+ context: context.push('ExistingAlias'),
+ description: 'Already an alias',
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {aliasModel},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ expect(transformed.models.first, isA());
+ expect((transformed.models.first as AliasModel).name, 'ExistingAlias');
+ expect(
+ (transformed.models.first as AliasModel).description,
+ 'Already an alias',
+ );
+ });
+ });
+
+ group('handles nested references', () {
+ test('normalizes AllOfModel containing another AliasModel', () {
+ final baseModel = BooleanModel(context: context);
+
+ final innerAlias = AliasModel(
+ name: 'InnerAlias',
+ model: baseModel,
+ context: context.push('InnerAlias'),
+ );
+
+ final outerAllOf = AllOfModel(
+ name: 'OuterAllOf',
+ models: {innerAlias},
+ context: context.push('OuterAllOf'),
+ description: 'Wraps an alias',
+ isDeprecated: false,
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {innerAlias, outerAllOf},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ final outer = transformed.models.firstWhere(
+ (m) => m is NamedModel && m.name == 'OuterAllOf',
+ );
+
+ expect(outer, isA());
+ final outerAlias = outer as AliasModel;
+ expect(outerAlias.model, isA());
+ expect((outerAlias.model as AliasModel).name, equals('InnerAlias'));
+ expect(outerAlias.description, equals('Wraps an alias'));
+ });
+ });
+
+ group('edge cases', () {
+ test('returns document unchanged when no AllOfModels present', () {
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {
+ StringModel(context: context),
+ IntegerModel(context: context),
+ },
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ expect(transformed.models.length, equals(document.models.length));
+ });
+
+ test('handles empty models set', () {
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: const {},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ expect(transformed.models, isEmpty);
+ });
+ });
+
+ group('deep transformation', () {
+ test('normalizes AllOfModel in property and updates references', () {
+ final baseModel = ClassModel(
+ name: 'ExternalUrlObject',
+ properties: const [],
+ context: context.push('ExternalUrlObject'),
+ isDeprecated: false,
+ );
+
+ final allOfModel = AllOfModel(
+ name: 'ArtistObjectExternalUrlsAllOfModel',
+ models: {baseModel},
+ context: context.push('ArtistObjectExternalUrlsAllOfModel'),
+ description: 'Known external URLs for this artist.',
+ isDeprecated: false,
+ );
+
+ final classModel = ClassModel(
+ name: 'ArtistObject',
+ properties: [
+ Property(
+ name: 'externalUrls',
+ model: allOfModel,
+ isRequired: false,
+ isNullable: true,
+ isDeprecated: false,
+ ),
+ ],
+ context: context.push('ArtistObject'),
+ isDeprecated: false,
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {baseModel, allOfModel, classModel},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ // The AllOfModel in document.models should be normalized to AliasModel
+ final normalizedAllOf =
+ transformed.models.firstWhere(
+ (m) =>
+ m is NamedModel &&
+ m.name == 'ArtistObjectExternalUrlsAllOfModel',
+ )
+ as AliasModel;
+ expect(
+ normalizedAllOf.name,
+ equals('ArtistObjectExternalUrlsAllOfModel'),
+ );
+ expect(normalizedAllOf.model, isA());
+ expect(
+ normalizedAllOf.description,
+ equals('Known external URLs for this artist.'),
+ );
+
+ // The ClassModel's property should also reference the transformed model
+ final artist =
+ transformed.models.firstWhere(
+ (m) => m is NamedModel && m.name == 'ArtistObject',
+ )
+ as ClassModel;
+ final externalUrlsProp = artist.properties.first;
+ // Property should now reference the AliasModel, not the original
+ // AllOfModel
+ expect(externalUrlsProp.model, isA());
+ expect(
+ (externalUrlsProp.model as AliasModel).name,
+ equals('ArtistObjectExternalUrlsAllOfModel'),
+ );
+ });
+
+ test('normalizes AllOfModel within ListModel content', () {
+ final baseModel = ClassModel(
+ name: 'ItemModel',
+ properties: const [],
+ context: context.push('ItemModel'),
+ isDeprecated: false,
+ );
+
+ final allOfModel = AllOfModel(
+ name: 'ItemsAllOfModel',
+ models: {baseModel},
+ context: context.push('ItemsAllOfModel'),
+ description: 'Collection items',
+ isDeprecated: false,
+ );
+
+ final listModel = ListModel(
+ content: allOfModel,
+ context: context.push('ItemList'),
+ name: 'ItemList',
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {baseModel, allOfModel, listModel},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ // AllOfModel should be normalized
+ final normalizedAllOf =
+ transformed.models.firstWhere(
+ (m) => m is NamedModel && m.name == 'ItemsAllOfModel',
+ )
+ as AliasModel;
+ expect(normalizedAllOf.model, isA());
+
+ // ListModel's content should also be the AliasModel
+ final list =
+ transformed.models.firstWhere(
+ (m) => m is NamedModel && m.name == 'ItemList',
+ )
+ as ListModel;
+ expect(list.content, isA());
+ expect((list.content as AliasModel).name, equals('ItemsAllOfModel'));
+ });
+
+ test('normalizes nested AllOfModel inside AllOfModel', () {
+ final baseModel = ClassModel(
+ name: 'BaseModel',
+ properties: const [],
+ context: context.push('BaseModel'),
+ isDeprecated: false,
+ );
+
+ final innerAllOf = AllOfModel(
+ name: 'InnerAllOf',
+ models: {baseModel},
+ context: context.push('InnerAllOf'),
+ description: 'Inner model',
+ isDeprecated: false,
+ );
+
+ final outerAllOf = AllOfModel(
+ name: 'OuterAllOf',
+ models: {innerAllOf},
+ context: context.push('OuterAllOf'),
+ description: 'Outer model',
+ isDeprecated: false,
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {baseModel, innerAllOf, outerAllOf},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ // Both should be normalized since they have single models
+ final outer =
+ transformed.models.firstWhere(
+ (m) => m is NamedModel && m.name == 'OuterAllOf',
+ )
+ as AliasModel;
+ expect(outer.description, equals('Outer model'));
+
+ final inner =
+ transformed.models.firstWhere(
+ (m) => m is NamedModel && m.name == 'InnerAllOf',
+ )
+ as AliasModel;
+ expect(inner.name, equals('InnerAllOf'));
+ expect(inner.model, isA());
+
+ // Outer's model should reference the normalized inner AliasModel
+ expect(outer.model, isA());
+ expect((outer.model as AliasModel).name, equals('InnerAllOf'));
+ });
+
+ test('preserves multi-model AllOf within properties', () {
+ final baseModel1 = ClassModel(
+ name: 'BaseModel1',
+ properties: const [],
+ context: context.push('BaseModel1'),
+ isDeprecated: false,
+ );
+
+ final baseModel2 = ClassModel(
+ name: 'BaseModel2',
+ properties: const [],
+ context: context.push('BaseModel2'),
+ isDeprecated: false,
+ );
+
+ final multiAllOf = AllOfModel(
+ name: 'MultiAllOf',
+ models: {baseModel1, baseModel2},
+ context: context.push('MultiAllOf'),
+ description: 'Composition of multiple models',
+ isDeprecated: false,
+ );
+
+ final classModel = ClassModel(
+ name: 'ContainerModel',
+ properties: [
+ Property(
+ name: 'composite',
+ model: multiAllOf,
+ isRequired: true,
+ isNullable: false,
+ isDeprecated: false,
+ ),
+ ],
+ context: context.push('ContainerModel'),
+ isDeprecated: false,
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {baseModel1, baseModel2, multiAllOf, classModel},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ final container =
+ transformed.models.firstWhere(
+ (m) => m is NamedModel && m.name == 'ContainerModel',
+ )
+ as ClassModel;
+
+ // Multi-model AllOf should NOT be normalized
+ expect(container.properties.first.model, isA());
+ final allOfProp = container.properties.first.model as AllOfModel;
+ expect(allOfProp.models.length, equals(2));
+ });
+
+ test('uses memoization to ensure referential consistency', () {
+ // A model referenced from multiple places
+ final sharedModel = ClassModel(
+ name: 'SharedModel',
+ properties: const [],
+ context: context.push('SharedModel'),
+ isDeprecated: false,
+ );
+
+ final allOf1 = AllOfModel(
+ name: 'Wrapper1',
+ models: {sharedModel},
+ context: context.push('Wrapper1'),
+ isDeprecated: false,
+ );
+
+ final allOf2 = AllOfModel(
+ name: 'Wrapper2',
+ models: {sharedModel},
+ context: context.push('Wrapper2'),
+ isDeprecated: false,
+ );
+
+ final document = ApiDocument(
+ title: 'Test API',
+ version: '1.0.0',
+ models: {sharedModel, allOf1, allOf2},
+ responseHeaders: const {},
+ requestHeaders: const {},
+ servers: const {},
+ operations: const {},
+ responses: const {},
+ queryParameters: const {},
+ pathParameters: const {},
+ requestBodies: const {},
+ );
+
+ final transformed = normalizer.apply(document);
+
+ final alias1 =
+ transformed.models.firstWhere(
+ (m) => m is NamedModel && m.name == 'Wrapper1',
+ )
+ as AliasModel;
+ final alias2 =
+ transformed.models.firstWhere(
+ (m) => m is NamedModel && m.name == 'Wrapper2',
+ )
+ as AliasModel;
+
+ // Both should reference the same transformed ClassModel instance
+ expect(identical(alias1.model, alias2.model), isTrue);
+ });
+ });
+ });
+}