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 @@ zread + + +

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); + }); + }); + }); +}