From 871541cd6d1e93a8dc4995d16dac59c7db4f0740 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Sun, 14 Jun 2026 23:48:35 +0200 Subject: [PATCH 1/3] refactor: extract DocumentEquivalenceComparer, IDocumentMerger, IDocumentLoader modules from OpenApiDocumentFactory DocumentEquivalenceComparer: canonical JSON equivalence comparison for merge conflict detection. Extracted from OpenApiDocumentFactory with public methods for testability. IDocumentMerger + DocumentMerger: multi-document merge logic (paths, schemas, definitions, security schemes, tags). Depends on DocumentEquivalenceComparer for conflict detection. IDocumentLoader + DocumentLoader: document loading from file or HTTP URL with YAML/JSON detection, OpenApiMultiFileReader for external references, and NSwag fallback. OpenApiDocumentFactory: deepened to thin composition of IDocumentLoader + IDocumentMerger. Public API unchanged. --- .../DocumentEquivalenceComparer.cs | 261 ++++++++++ src/Refitter.Core/DocumentLoader.cs | 104 ++++ src/Refitter.Core/DocumentMerger.cs | 88 ++++ src/Refitter.Core/IDocumentLoader.cs | 9 + src/Refitter.Core/IDocumentMerger.cs | 9 + src/Refitter.Core/OpenApiDocumentFactory.cs | 455 +----------------- 6 files changed, 478 insertions(+), 448 deletions(-) create mode 100644 src/Refitter.Core/DocumentEquivalenceComparer.cs create mode 100644 src/Refitter.Core/DocumentLoader.cs create mode 100644 src/Refitter.Core/DocumentMerger.cs create mode 100644 src/Refitter.Core/IDocumentLoader.cs create mode 100644 src/Refitter.Core/IDocumentMerger.cs diff --git a/src/Refitter.Core/DocumentEquivalenceComparer.cs b/src/Refitter.Core/DocumentEquivalenceComparer.cs new file mode 100644 index 000000000..964b84d78 --- /dev/null +++ b/src/Refitter.Core/DocumentEquivalenceComparer.cs @@ -0,0 +1,261 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NJsonSchema; +using NSwag; +using OpenApiDocument = NSwag.OpenApiDocument; + +namespace Refitter.Core; + +internal sealed class DocumentEquivalenceComparer +{ + public bool AreEquivalent(TValue existingValue, TValue incomingValue) + { + if (ReferenceEquals(existingValue, incomingValue) || + EqualityComparer.Default.Equals(existingValue, incomingValue)) + { + return true; + } + + try + { + return JToken.DeepEquals( + CreateCanonicalJsonToken(existingValue!), + CreateCanonicalJsonToken(incomingValue!)); + } + catch + { + return false; + } + } + + public JToken CreateCanonicalJsonToken(object value) + { + try + { + return NormalizeJsonToken(JToken.Parse(CreateOpenApiJson(value))); + } + catch when (value is JsonSchema schema) + { + return CreateCanonicalSchemaToken(schema, new HashSet(JsonSchemaReferenceComparer.Instance)); + } + } + + public JToken NormalizeJsonToken(JToken token) + => token switch + { + JObject jsonObject => new JObject( + jsonObject + .Properties() + .OrderBy(property => property.Name, StringComparer.Ordinal) + .Select(property => new JProperty(property.Name, NormalizeJsonToken(property.Value)))), + JArray jsonArray => new JArray(jsonArray.Select(NormalizeJsonToken)), + _ => token.DeepClone() + }; + + public JToken CreateCanonicalSchemaToken(JsonSchema schema, ISet visited) + { + if (schema.Reference != null) + return CreateCanonicalSchemaReferenceToken(schema.Reference, visited); + + var actualSchema = schema.ActualSchema; + if (!visited.Add(actualSchema)) + return new JObject { ["$ref"] = "#" }; + + var json = new JObject + { + ["type"] = actualSchema.Type.ToString(), + ["format"] = actualSchema.Format, + ["title"] = actualSchema.Title, + ["description"] = actualSchema.Description, + ["nullable"] = actualSchema.IsNullableRaw, + ["allowAdditionalProperties"] = actualSchema.AllowAdditionalProperties + }; + + AddSchemaToken(json, "additionalProperties", actualSchema.AdditionalPropertiesSchema, visited); + AddSchemaToken(json, "items", actualSchema.Item, visited); + AddSchemaArray(json, "allOf", actualSchema.AllOf, visited); + AddSchemaArray(json, "oneOf", actualSchema.OneOf, visited); + AddSchemaArray(json, "anyOf", actualSchema.AnyOf, visited); + + if (actualSchema.RequiredProperties.Count > 0) + json["required"] = new JArray(actualSchema.RequiredProperties.OrderBy(name => name, StringComparer.Ordinal)); + + if (actualSchema.Properties.Count > 0) + { + json["properties"] = new JObject( + actualSchema.Properties + .OrderBy(property => property.Key, StringComparer.Ordinal) + .Select(property => new JProperty( + property.Key, + CreateCanonicalSchemaToken(property.Value, new HashSet(visited, JsonSchemaReferenceComparer.Instance))))); + } + + if (actualSchema.Enumeration.Count > 0) + json["enum"] = new JArray(actualSchema.Enumeration.Select(value => value != null ? JToken.FromObject(value) : JValue.CreateNull())); + + if (actualSchema.ExtensionData is { Count: > 0 }) + { + json["extensions"] = new JObject( + actualSchema.ExtensionData + .OrderBy(extension => extension.Key, StringComparer.Ordinal) + .Select(extension => new JProperty( + extension.Key, + extension.Value != null ? NormalizeJsonToken(JToken.FromObject(extension.Value)) : JValue.CreateNull()))); + } + + return RemoveNullProperties(json); + } + + public JObject RemoveNullProperties(JObject json) + { + foreach (var property in json.Properties().Where(property => property.Value.Type == JTokenType.Null).ToArray()) + { + property.Remove(); + } + + return json; + } + + public string CreateOpenApiJson(object value) + => value switch + { + OpenApiDocument document => document.ToJson(), + JsonSchema schema => CreateDocumentWithSchema(schema).ToJson(), + NSwag.OpenApiPathItem pathItem => CreateDocumentWithPath(pathItem).ToJson(), + NSwag.OpenApiSecurityScheme securityScheme => CreateDocumentWithSecurityScheme(securityScheme).ToJson(), + _ => JsonConvert.SerializeObject(value, Formatting.None) + }; + + public void AddReferencedSchemas(IDictionary definitions, JsonSchema schema) + { + var visited = new HashSet(JsonSchemaReferenceComparer.Instance); + var schemasToProcess = new Stack(); + schemasToProcess.Push(schema); + + while (schemasToProcess.Count > 0) + { + var schemaToProcess = schemasToProcess.Pop(); + var actualSchema = schemaToProcess.ActualSchema; + if (!visited.Add(actualSchema)) + continue; + + var definitionName = GetDefinitionName(schemaToProcess) ?? GetDefinitionName(actualSchema); + if (definitionName != null && !definitions.ContainsKey(definitionName)) + definitions.Add(definitionName, actualSchema); + + foreach (var childSchema in EnumerateTraversableSchemas(actualSchema)) + { + if (childSchema != null) + schemasToProcess.Push(childSchema); + } + } + } + + public string? GetDefinitionName(JsonSchema schema) + { + var referencePath = ((NJsonSchema.References.IJsonReferenceBase)schema).ReferencePath; + if (string.IsNullOrWhiteSpace(referencePath)) + return null; + + var separatorIndex = referencePath!.LastIndexOf('/'); + return separatorIndex >= 0 && separatorIndex < referencePath.Length - 1 + ? Uri.UnescapeDataString(referencePath.Substring(separatorIndex + 1)) + : null; + } + + private JToken CreateCanonicalSchemaReferenceToken(JsonSchema reference, ISet visited) => + visited.Contains(reference) + ? new JObject { ["$ref"] = "#" } + : CreateCanonicalSchemaToken(reference, new HashSet(visited, JsonSchemaReferenceComparer.Instance)); + + private void AddSchemaToken(JObject json, string propertyName, JsonSchema? schema, ISet visited) + { + if (schema != null) + json[propertyName] = CreateCanonicalSchemaToken(schema, new HashSet(visited, JsonSchemaReferenceComparer.Instance)); + } + + private void AddSchemaArray(JObject json, string propertyName, IEnumerable schemas, ISet visited) + { + var items = schemas + .Select(schema => CreateCanonicalSchemaToken(schema, new HashSet(visited, JsonSchemaReferenceComparer.Instance))) + .ToArray(); + + if (items.Length > 0) + json[propertyName] = new JArray(items); + } + + private OpenApiDocument CreateDocumentWithSchema(JsonSchema schema) + { + var document = CreateSerializationDocument(); + document.Definitions["Schema"] = schema; + AddReferencedSchemas(document.Definitions, schema); + return document; + } + + private static OpenApiDocument CreateDocumentWithPath(NSwag.OpenApiPathItem pathItem) + { + var document = CreateSerializationDocument(); + document.Paths["/_"] = pathItem; + return document; + } + + private static OpenApiDocument CreateDocumentWithSecurityScheme(NSwag.OpenApiSecurityScheme securityScheme) + { + var document = CreateSerializationDocument(); + document.SecurityDefinitions["SecurityScheme"] = securityScheme; + return document; + } + + private static OpenApiDocument CreateSerializationDocument() + => new() + { + Info = + { + Title = "Refitter equivalence comparison", + Version = "1.0" + } + }; + + private static IEnumerable EnumerateTraversableSchemas(JsonSchema schema) + { + yield return schema.AdditionalItemsSchema; + yield return schema.AdditionalPropertiesSchema; + yield return schema.DictionaryKey; + yield return schema.Item; + + if (schema.Items != null) + { + foreach (var item in schema.Items) + { + yield return item; + } + } + + yield return schema.Not; + + foreach (var property in schema.Properties.Values) + { + yield return property; + } + + foreach (var subSchema in schema.AllOf) + { + yield return subSchema; + } + + foreach (var subSchema in schema.OneOf) + { + yield return subSchema; + } + + foreach (var subSchema in schema.AnyOf) + { + yield return subSchema; + } + + foreach (var definition in schema.Definitions.Values) + { + yield return definition; + } + } +} diff --git a/src/Refitter.Core/DocumentLoader.cs b/src/Refitter.Core/DocumentLoader.cs new file mode 100644 index 000000000..05cf89d6b --- /dev/null +++ b/src/Refitter.Core/DocumentLoader.cs @@ -0,0 +1,104 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using NSwag; +using OpenApiDocument = NSwag.OpenApiDocument; + +namespace Refitter.Core; + +internal sealed class DocumentLoader : IDocumentLoader +{ + private static readonly HttpClient HttpClient = new( + new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }) + { + Timeout = TimeSpan.FromSeconds(30) + }; + + static DocumentLoader() + { + HttpClient.DefaultRequestHeaders.Add( + "User-Agent", + $"refitter/{typeof(DocumentLoader).Assembly.GetName().Version}"); + } + + public async Task LoadAsync(string openApiPath) + { + try + { + var readResult = await OpenApiMultiFileReader.Read(openApiPath).ConfigureAwait(false); + if (!readResult.ContainedExternalReferences) + return await CreateUsingNSwagAsync(openApiPath).ConfigureAwait(false); + + var specificationVersion = readResult.OpenApiDiagnostic.SpecificationVersion; + PopulateMissingRequiredFields(openApiPath, readResult); + + if (IsYaml(openApiPath)) + { + var yaml = await readResult.OpenApiDocument.SerializeAsYamlAsync(specificationVersion).ConfigureAwait(false); + return await OpenApiYamlDocument.FromYamlAsync(yaml).ConfigureAwait(false); + } + + var json = await readResult.OpenApiDocument.SerializeAsJsonAsync(specificationVersion).ConfigureAwait(false); + return await OpenApiDocument.FromJsonAsync(json).ConfigureAwait(false); + } + catch (Exception) + { + return await CreateUsingNSwagAsync(openApiPath).ConfigureAwait(false); + } + } + + private static async Task CreateUsingNSwagAsync(string openApiPath) + { + if (IsHttp(openApiPath)) + { + var content = await GetHttpContent(openApiPath).ConfigureAwait(false); + return IsYaml(openApiPath) + ? await OpenApiYamlDocument.FromYamlAsync(content).ConfigureAwait(false) + : await OpenApiDocument.FromJsonAsync(content).ConfigureAwait(false); + } + + return IsYaml(openApiPath) + ? await OpenApiYamlDocument.FromFileAsync(openApiPath).ConfigureAwait(false) + : await OpenApiDocument.FromFileAsync(openApiPath).ConfigureAwait(false); + } + + [ExcludeFromCodeCoverage] + private static void PopulateMissingRequiredFields( + string openApiPath, + Result readResult) + { + var document = readResult.OpenApiDocument; + if (document.Info is null) + { + document.Info = new Microsoft.OpenApi.OpenApiInfo + { + Title = Path.GetFileNameWithoutExtension(openApiPath), + Version = readResult.OpenApiDiagnostic.SpecificationVersion.GetDisplayName() + }; + } + else + { + document.Info.Title ??= Path.GetFileNameWithoutExtension(openApiPath); + document.Info.Version ??= readResult.OpenApiDiagnostic.SpecificationVersion.GetDisplayName(); + } + } + + private static bool IsHttp(string path) + { + return path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + } + + private static Task GetHttpContent(string openApiPath) + => HttpClient.GetStringAsync(openApiPath); + + private static bool IsYaml(string path) + { + return path.EndsWith("yaml", StringComparison.OrdinalIgnoreCase) || + path.EndsWith("yml", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Refitter.Core/DocumentMerger.cs b/src/Refitter.Core/DocumentMerger.cs new file mode 100644 index 000000000..f4ccfe6dc --- /dev/null +++ b/src/Refitter.Core/DocumentMerger.cs @@ -0,0 +1,88 @@ +using Newtonsoft.Json.Linq; +using NJsonSchema; +using NSwag; +using OpenApiDocument = NSwag.OpenApiDocument; + +namespace Refitter.Core; + +internal sealed class DocumentMerger : IDocumentMerger +{ + private readonly DocumentEquivalenceComparer _comparer; + + public DocumentMerger(DocumentEquivalenceComparer comparer) + { + _comparer = comparer; + } + + public OpenApiDocument Merge(OpenApiDocument[] documents) + { + var baseDocument = CloneDocument(documents[0]); + var tagNames = new HashSet(baseDocument.Tags.Select(t => t.Name), StringComparer.Ordinal); + + for (var i = 1; i < documents.Length; i++) + { + var document = documents[i]; + foreach (var path in document.Paths) + { + MergeIfMissingOrThrowOnConflict(baseDocument.Paths, path.Key, path.Value, "path"); + } + + if (document.Components.Schemas.Count > 0) + { + foreach (var schema in document.Components.Schemas) + { + MergeIfMissingOrThrowOnConflict(baseDocument.Components.Schemas, schema.Key, schema.Value, "schema"); + } + } + + if (document.Definitions != null) + { + foreach (var definition in document.Definitions) + { + MergeIfMissingOrThrowOnConflict(baseDocument.Definitions, definition.Key, definition.Value, "definition"); + } + } + + if (document.SecurityDefinitions != null) + { + foreach (var securityDefinition in document.SecurityDefinitions) + { + MergeIfMissingOrThrowOnConflict(baseDocument.SecurityDefinitions, securityDefinition.Key, securityDefinition.Value, "security scheme"); + } + } + + if (document.Tags.Count > 0) + { + foreach (var tag in document.Tags) + { + if (tagNames.Add(tag.Name)) + baseDocument.Tags.Add(tag); + } + } + } + + return baseDocument; + } + + private static OpenApiDocument CloneDocument(OpenApiDocument document) + => OpenApiDocument.FromJsonAsync(document.ToJson()).GetAwaiter().GetResult(); + + private void MergeIfMissingOrThrowOnConflict( + IDictionary target, + string key, + TValue value, + string itemType) + { + if (!target.TryGetValue(key, out var existingValue)) + { + target[key] = value; + return; + } + + if (!_comparer.AreEquivalent(existingValue, value)) + throw CreateMergeConflictException(itemType, key); + } + + private static InvalidOperationException CreateMergeConflictException(string itemType, string key) => + new($"Cannot merge OpenAPI documents because a duplicate {itemType} '{key}' was found. Refitter fails fast on merge collisions to avoid silent data loss."); +} diff --git a/src/Refitter.Core/IDocumentLoader.cs b/src/Refitter.Core/IDocumentLoader.cs new file mode 100644 index 000000000..932dfa86b --- /dev/null +++ b/src/Refitter.Core/IDocumentLoader.cs @@ -0,0 +1,9 @@ +using NSwag; +using OpenApiDocument = NSwag.OpenApiDocument; + +namespace Refitter.Core; + +internal interface IDocumentLoader +{ + Task LoadAsync(string path); +} diff --git a/src/Refitter.Core/IDocumentMerger.cs b/src/Refitter.Core/IDocumentMerger.cs new file mode 100644 index 000000000..a83859393 --- /dev/null +++ b/src/Refitter.Core/IDocumentMerger.cs @@ -0,0 +1,9 @@ +using NSwag; +using OpenApiDocument = NSwag.OpenApiDocument; + +namespace Refitter.Core; + +internal interface IDocumentMerger +{ + OpenApiDocument Merge(OpenApiDocument[] documents); +} diff --git a/src/Refitter.Core/OpenApiDocumentFactory.cs b/src/Refitter.Core/OpenApiDocumentFactory.cs index 44dd14170..7586da050 100644 --- a/src/Refitter.Core/OpenApiDocumentFactory.cs +++ b/src/Refitter.Core/OpenApiDocumentFactory.cs @@ -1,34 +1,12 @@ -using System.Diagnostics.CodeAnalysis; -using System.Net; -using System.Runtime.CompilerServices; -using Microsoft.OpenApi; -using Microsoft.OpenApi.Reader; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using NJsonSchema; using NSwag; using OpenApiDocument = NSwag.OpenApiDocument; namespace Refitter.Core; -/// -/// Creates an from a specified path or URL. -/// public static class OpenApiDocumentFactory { - private static readonly HttpClient HttpClient = new( - new HttpClientHandler - { - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate - }) - { - Timeout = TimeSpan.FromSeconds(30) - }; - - static OpenApiDocumentFactory() - { - HttpClient.DefaultRequestHeaders.Add("User-Agent", $"refitter/{typeof(OpenApiDocumentFactory).Assembly.GetName().Version}"); - } + private static readonly IDocumentLoader DocumentLoader = new DocumentLoader(); + private static readonly IDocumentMerger DocumentMerger = new DocumentMerger(new DocumentEquivalenceComparer()); /// /// Creates a merged from multiple paths or URLs. @@ -36,7 +14,8 @@ static OpenApiDocumentFactory() /// /// The paths or URLs to the OpenAPI specifications. /// A merged . - /// Thrown when is null or empty. + /// Thrown when is null. + /// Thrown when is empty. public static async Task CreateAsync(IEnumerable openApiPaths) { if (openApiPaths == null) @@ -51,342 +30,11 @@ public static async Task CreateAsync(IEnumerable openAp var documents = new OpenApiDocument[paths.Length]; for (var i = 0; i < paths.Length; i++) - documents[i] = await CreateAsync(paths[i]).ConfigureAwait(false); - - return Merge(documents); - } - - private static OpenApiDocument Merge(OpenApiDocument[] documents) - { - var baseDocument = CloneDocument(documents[0]); - var tagNames = new HashSet(baseDocument.Tags.Select(t => t.Name), StringComparer.Ordinal); - - for (var i = 1; i < documents.Length; i++) - { - var document = documents[i]; - foreach (var path in document.Paths) - { - MergeIfMissingOrThrowOnConflict(baseDocument.Paths, path.Key, path.Value, "path"); - } - - if (document.Components.Schemas.Count > 0) - { - foreach (var schema in document.Components.Schemas) - { - MergeIfMissingOrThrowOnConflict(baseDocument.Components.Schemas, schema.Key, schema.Value, "schema"); - } - } - - if (document.Definitions != null) - { - foreach (var definition in document.Definitions) - { - MergeIfMissingOrThrowOnConflict(baseDocument.Definitions, definition.Key, definition.Value, "definition"); - } - } - - if (document.SecurityDefinitions != null) - { - foreach (var securityDefinition in document.SecurityDefinitions) - { - MergeIfMissingOrThrowOnConflict(baseDocument.SecurityDefinitions, securityDefinition.Key, securityDefinition.Value, "security scheme"); - } - } - - if (document.Tags.Count > 0) - { - foreach (var tag in document.Tags) - { - if (tagNames.Add(tag.Name)) - baseDocument.Tags.Add(tag); - } - } - } - - return baseDocument; - } - - private static OpenApiDocument CloneDocument(OpenApiDocument document) - => OpenApiDocument.FromJsonAsync(document.ToJson()).GetAwaiter().GetResult(); - - private static void MergeIfMissingOrThrowOnConflict( - IDictionary target, - string key, - TValue value, - string itemType) - { - if (!target.TryGetValue(key, out var existingValue)) - { - target[key] = value; - return; - } - - if (!AreEquivalent(existingValue, value)) - throw CreateMergeConflictException(itemType, key); - } - - private static bool AreEquivalent(TValue existingValue, TValue incomingValue) - { - if (ReferenceEquals(existingValue, incomingValue) || EqualityComparer.Default.Equals(existingValue, incomingValue)) - return true; - - try - { - return JToken.DeepEquals( - CreateCanonicalJsonToken(existingValue!), - CreateCanonicalJsonToken(incomingValue!)); - } - catch - { - return false; - } - } - - private static JToken CreateCanonicalJsonToken(object value) - { - try - { - return NormalizeJsonToken(JToken.Parse(CreateOpenApiJson(value))); - } - catch when (value is JsonSchema schema) - { - return CreateCanonicalSchemaToken(schema, new HashSet(JsonSchemaReferenceComparer.Instance)); - } - } - - private static string CreateOpenApiJson(object value) - => value switch - { - OpenApiDocument document => document.ToJson(), - JsonSchema schema => CreateDocumentWithSchema(schema).ToJson(), - NSwag.OpenApiPathItem pathItem => CreateDocumentWithPath(pathItem).ToJson(), - NSwag.OpenApiSecurityScheme securityScheme => CreateDocumentWithSecurityScheme(securityScheme).ToJson(), - _ => JsonConvert.SerializeObject(value, Formatting.None) - }; - - private static OpenApiDocument CreateDocumentWithSchema(JsonSchema schema) - { - var document = CreateSerializationDocument(); - document.Definitions["Schema"] = schema; - AddReferencedSchemas(document.Definitions, schema); - return document; - } - - private static void AddReferencedSchemas(IDictionary definitions, JsonSchema schema) - { - var visited = new HashSet(JsonSchemaReferenceComparer.Instance); - var schemasToProcess = new Stack(); - schemasToProcess.Push(schema); - - while (schemasToProcess.Count > 0) - { - var schemaToProcess = schemasToProcess.Pop(); - var actualSchema = schemaToProcess.ActualSchema; - if (!visited.Add(actualSchema)) - continue; - - var definitionName = GetDefinitionName(schemaToProcess) ?? GetDefinitionName(actualSchema); - if (definitionName != null && !definitions.ContainsKey(definitionName)) - definitions.Add(definitionName, actualSchema); - - foreach (var childSchema in EnumerateTraversableSchemas(actualSchema)) - { - if (childSchema != null) - schemasToProcess.Push(childSchema); - } - } - } - - private static string? GetDefinitionName(JsonSchema schema) - { - var referencePath = ((NJsonSchema.References.IJsonReferenceBase)schema).ReferencePath; - if (string.IsNullOrWhiteSpace(referencePath)) - return null; - - var separatorIndex = referencePath!.LastIndexOf('/'); - return separatorIndex >= 0 && separatorIndex < referencePath.Length - 1 - ? Uri.UnescapeDataString(referencePath.Substring(separatorIndex + 1)) - : null; - } - - private static IEnumerable EnumerateTraversableSchemas(JsonSchema schema) - { - yield return schema.AdditionalItemsSchema; - yield return schema.AdditionalPropertiesSchema; - yield return schema.DictionaryKey; - yield return schema.Item; - - if (schema.Items != null) - { - foreach (var item in schema.Items) - { - yield return item; - } - } - - yield return schema.Not; - - foreach (var property in schema.Properties.Values) - { - yield return property; - } - - foreach (var subSchema in schema.AllOf) - { - yield return subSchema; - } - - foreach (var subSchema in schema.OneOf) - { - yield return subSchema; - } - - foreach (var subSchema in schema.AnyOf) - { - yield return subSchema; - } - - foreach (var definition in schema.Definitions.Values) - { - yield return definition; - } - } - - private static OpenApiDocument CreateDocumentWithPath(NSwag.OpenApiPathItem pathItem) - { - var document = CreateSerializationDocument(); - document.Paths["/_"] = pathItem; - return document; - } - - private static OpenApiDocument CreateDocumentWithSecurityScheme(NSwag.OpenApiSecurityScheme securityScheme) - { - var document = CreateSerializationDocument(); - document.SecurityDefinitions["SecurityScheme"] = securityScheme; - return document; - } - - private static OpenApiDocument CreateSerializationDocument() - => new() - { - Info = - { - Title = "Refitter equivalence comparison", - Version = "1.0" - } - }; - - private static JToken NormalizeJsonToken(JToken token) - => token switch - { - JObject jsonObject => new JObject( - jsonObject - .Properties() - .OrderBy(property => property.Name, StringComparer.Ordinal) - .Select(property => new JProperty(property.Name, NormalizeJsonToken(property.Value)))), - JArray jsonArray => new JArray(jsonArray.Select(NormalizeJsonToken)), - _ => token.DeepClone() - }; - - private static JToken CreateCanonicalSchemaToken(JsonSchema schema, ISet visited) - { - if (schema.Reference != null) - return CreateCanonicalSchemaReferenceToken(schema.Reference, visited); - - var actualSchema = schema.ActualSchema; - if (!visited.Add(actualSchema)) - return new JObject { ["$ref"] = "#" }; - - var json = new JObject - { - ["type"] = actualSchema.Type.ToString(), - ["format"] = actualSchema.Format, - ["title"] = actualSchema.Title, - ["description"] = actualSchema.Description, - ["nullable"] = actualSchema.IsNullableRaw, - ["allowAdditionalProperties"] = actualSchema.AllowAdditionalProperties - }; - - AddSchemaToken(json, "additionalProperties", actualSchema.AdditionalPropertiesSchema, visited); - AddSchemaToken(json, "items", actualSchema.Item, visited); - AddSchemaArray(json, "allOf", actualSchema.AllOf, visited); - AddSchemaArray(json, "oneOf", actualSchema.OneOf, visited); - AddSchemaArray(json, "anyOf", actualSchema.AnyOf, visited); - - if (actualSchema.RequiredProperties.Count > 0) - json["required"] = new JArray(actualSchema.RequiredProperties.OrderBy(name => name, StringComparer.Ordinal)); - - if (actualSchema.Properties.Count > 0) - { - json["properties"] = new JObject( - actualSchema.Properties - .OrderBy(property => property.Key, StringComparer.Ordinal) - .Select(property => new JProperty( - property.Key, - CreateCanonicalSchemaToken(property.Value, new HashSet(visited, JsonSchemaReferenceComparer.Instance))))); - } - - if (actualSchema.Enumeration.Count > 0) - json["enum"] = new JArray(actualSchema.Enumeration.Select(value => value != null ? JToken.FromObject(value) : JValue.CreateNull())); - - if (actualSchema.ExtensionData is { Count: > 0 }) - { - json["extensions"] = new JObject( - actualSchema.ExtensionData - .OrderBy(extension => extension.Key, StringComparer.Ordinal) - .Select(extension => new JProperty( - extension.Key, - extension.Value != null ? NormalizeJsonToken(JToken.FromObject(extension.Value)) : JValue.CreateNull()))); - } - - return RemoveNullProperties(json); - } - - private static JToken CreateCanonicalSchemaReferenceToken(JsonSchema reference, ISet visited) => - visited.Contains(reference) - ? new JObject { ["$ref"] = "#" } - : CreateCanonicalSchemaToken(reference, new HashSet(visited, JsonSchemaReferenceComparer.Instance)); - - private static void AddSchemaToken(JObject json, string propertyName, JsonSchema? schema, ISet visited) - { - if (schema != null) - json[propertyName] = CreateCanonicalSchemaToken(schema, new HashSet(visited, JsonSchemaReferenceComparer.Instance)); - } - - private static void AddSchemaArray(JObject json, string propertyName, IEnumerable schemas, ISet visited) - { - var items = schemas - .Select(schema => CreateCanonicalSchemaToken(schema, new HashSet(visited, JsonSchemaReferenceComparer.Instance))) - .ToArray(); + documents[i] = await DocumentLoader.LoadAsync(paths[i]).ConfigureAwait(false); - if (items.Length > 0) - json[propertyName] = new JArray(items); + return DocumentMerger.Merge(documents); } - private static JObject RemoveNullProperties(JObject json) - { - foreach (var property in json.Properties().Where(property => property.Value.Type == JTokenType.Null).ToArray()) - { - property.Remove(); - } - - return json; - } - - private sealed class JsonSchemaReferenceComparer : IEqualityComparer - { - public static JsonSchemaReferenceComparer Instance { get; } = new(); - - public bool Equals(JsonSchema? x, JsonSchema? y) => - ReferenceEquals(x, y); - - public int GetHashCode(JsonSchema obj) => - RuntimeHelpers.GetHashCode(obj); - } - - private static InvalidOperationException CreateMergeConflictException(string itemType, string key) => - new($"Cannot merge OpenAPI documents because a duplicate {itemType} '{key}' was found. Refitter fails fast on merge collisions to avoid silent data loss."); - /// /// Creates a new instance of the class asynchronously. /// @@ -394,95 +42,6 @@ private static InvalidOperationException CreateMergeConflictException(string ite /// A new instance of the class. public static async Task CreateAsync(string openApiPath) { - try - { - var readResult = await OpenApiMultiFileReader.Read(openApiPath).ConfigureAwait(false); - if (!readResult.ContainedExternalReferences) - return await CreateUsingNSwagAsync(openApiPath).ConfigureAwait(false); - - var specificationVersion = readResult.OpenApiDiagnostic.SpecificationVersion; - PopulateMissingRequiredFields(openApiPath, readResult); - - if (IsYaml(openApiPath)) - { - var yaml = await readResult.OpenApiDocument.SerializeAsYamlAsync(specificationVersion).ConfigureAwait(false); - return await OpenApiYamlDocument.FromYamlAsync(yaml).ConfigureAwait(false); - } - - var json = await readResult.OpenApiDocument.SerializeAsJsonAsync(specificationVersion).ConfigureAwait(false); - return await OpenApiDocument.FromJsonAsync(json).ConfigureAwait(false); - } - catch (Exception) - { - // Fallback to NSwag if OpenApiMultiFileReader fails (e.g., for files without external references) - return await CreateUsingNSwagAsync(openApiPath).ConfigureAwait(false); - } - } - - private static async Task CreateUsingNSwagAsync(string openApiPath) - { - if (IsHttp(openApiPath)) - { - var content = await GetHttpContent(openApiPath).ConfigureAwait(false); - return IsYaml(openApiPath) - ? await OpenApiYamlDocument.FromYamlAsync(content).ConfigureAwait(false) - : await OpenApiDocument.FromJsonAsync(content).ConfigureAwait(false); - } - - return IsYaml(openApiPath) - ? await OpenApiYamlDocument.FromFileAsync(openApiPath).ConfigureAwait(false) - : await OpenApiDocument.FromFileAsync(openApiPath).ConfigureAwait(false); - } - - [ExcludeFromCodeCoverage] - private static void PopulateMissingRequiredFields( - string openApiPath, - Result readResult) - { - var document = readResult.OpenApiDocument; - if (document.Info is null) - { - document.Info = new Microsoft.OpenApi.OpenApiInfo - { - Title = Path.GetFileNameWithoutExtension(openApiPath), - Version = readResult.OpenApiDiagnostic.SpecificationVersion.GetDisplayName() - }; - } - else - { - document.Info.Title ??= Path.GetFileNameWithoutExtension(openApiPath); - document.Info.Version ??= readResult.OpenApiDiagnostic.SpecificationVersion.GetDisplayName(); - } - } - - /// - /// Determines whether the specified path is an HTTP URL. - /// - /// The path to check. - /// True if the path is an HTTP URL, otherwise false. - private static bool IsHttp(string path) - { - return path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - path.StartsWith("https://", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Gets the content of the URI as a string and decompresses it if necessary. - /// - /// The path to the OpenAPI document. - /// The content of the HTTP request. - private static Task GetHttpContent(string openApiPath) - => HttpClient.GetStringAsync(openApiPath); - - - /// - /// Determines whether the specified path is a YAML file. - /// - /// The path to check. - /// True if the path is a YAML file, otherwise false. - private static bool IsYaml(string path) - { - return path.EndsWith("yaml", StringComparison.OrdinalIgnoreCase) || - path.EndsWith("yml", StringComparison.OrdinalIgnoreCase); + return await DocumentLoader.LoadAsync(openApiPath).ConfigureAwait(false); } } From 46ccfa921b144e889b2b0de38c17fd75bd44481a Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Sun, 14 Jun 2026 23:50:53 +0200 Subject: [PATCH 2/3] test: update merge tests to use new modules instead of reflection Replace reflection-based InvokeMerge, InvokeAreEquivalent, InvokeCreateCanonicalSchemaToken, InvokeCreateCanonicalJsonToken, InvokeAddReferencedSchemas, InvokeGetDefinitionName, InvokeCreateOpenApiJson, and InvokeRemoveNullProperties with direct calls to DocumentMerger and DocumentEquivalenceComparer. InvokeMergeIfMissingOrThrowOnConflict continues to use reflection on DocumentMerger for targeted merge-conflict tests. All 2159 tests pass. --- .../OpenApiDocumentFactoryMergeTests.cs | 73 ++++--------------- 1 file changed, 13 insertions(+), 60 deletions(-) diff --git a/src/Refitter.Tests/OpenApi/OpenApiDocumentFactoryMergeTests.cs b/src/Refitter.Tests/OpenApi/OpenApiDocumentFactoryMergeTests.cs index a53f3265a..83aca3d54 100644 --- a/src/Refitter.Tests/OpenApi/OpenApiDocumentFactoryMergeTests.cs +++ b/src/Refitter.Tests/OpenApi/OpenApiDocumentFactoryMergeTests.cs @@ -10,6 +10,8 @@ namespace Refitter.Tests.OpenApi; public class OpenApiDocumentFactoryMergeTests { + private static readonly DocumentEquivalenceComparer Comparer = new(); + private static readonly DocumentMerger Merger = new(Comparer); [Test] public async Task Merge_Preserves_Tags_From_Base_Document() { @@ -1617,20 +1619,7 @@ private static Task ParseJsonDocument(string json) => OpenApiDocument.FromJsonAsync(json); private static OpenApiDocument InvokeMerge(params OpenApiDocument[] documents) - { - var mergeMethod = typeof(OpenApiDocumentFactory).GetMethod("Merge", BindingFlags.NonPublic | BindingFlags.Static); - - mergeMethod.Should().NotBeNull(); - - try - { - return (OpenApiDocument)mergeMethod!.Invoke(null, [documents])!; - } - catch (TargetInvocationException exception) when (exception.InnerException != null) - { - throw exception.InnerException; - } - } + => Merger.Merge(documents); private static void InvokeMergeIfMissingOrThrowOnConflict( IDictionary target, @@ -1638,13 +1627,13 @@ private static void InvokeMergeIfMissingOrThrowOnConflict( TValue value, string itemType) { - var mergeMethod = typeof(OpenApiDocumentFactory) - .GetMethod("MergeIfMissingOrThrowOnConflict", BindingFlags.NonPublic | BindingFlags.Static)! + var mergeMethod = typeof(DocumentMerger) + .GetMethod("MergeIfMissingOrThrowOnConflict", BindingFlags.NonPublic | BindingFlags.Instance)! .MakeGenericMethod(typeof(TValue)); try { - mergeMethod.Invoke(null, [target, key, value!, itemType]); + mergeMethod.Invoke(Merger, [target, key, value!, itemType]); } catch (TargetInvocationException exception) when (exception.InnerException != null) { @@ -1653,61 +1642,25 @@ private static void InvokeMergeIfMissingOrThrowOnConflict( } private static bool InvokeAreEquivalent(TValue existingValue, TValue incomingValue) - { - var areEquivalentMethod = typeof(OpenApiDocumentFactory) - .GetMethod("AreEquivalent", BindingFlags.NonPublic | BindingFlags.Static)! - .MakeGenericMethod(typeof(TValue)); - - return (bool)areEquivalentMethod.Invoke(null, [existingValue!, incomingValue!])!; - } + => Comparer.AreEquivalent(existingValue, incomingValue); private static JToken InvokeCreateCanonicalSchemaToken(JsonSchema schema, ISet visited) - { - var createCanonicalSchemaTokenMethod = typeof(OpenApiDocumentFactory) - .GetMethod("CreateCanonicalSchemaToken", BindingFlags.NonPublic | BindingFlags.Static)!; - - return (JToken)createCanonicalSchemaTokenMethod.Invoke(null, [schema, visited])!; - } + => Comparer.CreateCanonicalSchemaToken(schema, visited); private static JToken InvokeCreateCanonicalJsonToken(object value) - { - var createCanonicalJsonTokenMethod = typeof(OpenApiDocumentFactory) - .GetMethod("CreateCanonicalJsonToken", BindingFlags.NonPublic | BindingFlags.Static)!; - - return (JToken)createCanonicalJsonTokenMethod.Invoke(null, [value])!; - } + => Comparer.CreateCanonicalJsonToken(value); private static void InvokeAddReferencedSchemas(IDictionary definitions, JsonSchema schema) - { - var addReferencedSchemasMethod = typeof(OpenApiDocumentFactory) - .GetMethod("AddReferencedSchemas", BindingFlags.NonPublic | BindingFlags.Static)!; - - addReferencedSchemasMethod.Invoke(null, [definitions, schema]); - } + => Comparer.AddReferencedSchemas(definitions, schema); private static string? InvokeGetDefinitionName(JsonSchema schema) - { - var getDefinitionNameMethod = typeof(OpenApiDocumentFactory) - .GetMethod("GetDefinitionName", BindingFlags.NonPublic | BindingFlags.Static)!; - - return (string?)getDefinitionNameMethod.Invoke(null, [schema]); - } + => Comparer.GetDefinitionName(schema); private static string InvokeCreateOpenApiJson(object value) - { - var createOpenApiJsonMethod = typeof(OpenApiDocumentFactory) - .GetMethod("CreateOpenApiJson", BindingFlags.NonPublic | BindingFlags.Static)!; - - return (string)createOpenApiJsonMethod.Invoke(null, [value])!; - } + => Comparer.CreateOpenApiJson(value); private static JObject InvokeRemoveNullProperties(JObject json) - { - var removeNullPropertiesMethod = typeof(OpenApiDocumentFactory) - .GetMethod("RemoveNullProperties", BindingFlags.NonPublic | BindingFlags.Static)!; - - return (JObject)removeNullPropertiesMethod.Invoke(null, [json])!; - } + => Comparer.RemoveNullProperties(json); private sealed class SelfReferencingValue { From c93ec4bd6fc557fea88d18a38023f45bb939ee87 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:12:54 +0000 Subject: [PATCH 3/3] fix: apply CodeRabbit auto-fixes Fixed 3 file(s) based on 8 unresolved review comments. Co-authored-by: CodeRabbit --- .../DocumentEquivalenceComparer.cs | 51 +++++++++++++++++-- src/Refitter.Core/DocumentLoader.cs | 20 ++++++-- src/Refitter.Core/DocumentMerger.cs | 21 ++++++-- 3 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/Refitter.Core/DocumentEquivalenceComparer.cs b/src/Refitter.Core/DocumentEquivalenceComparer.cs index 964b84d78..879da97a0 100644 --- a/src/Refitter.Core/DocumentEquivalenceComparer.cs +++ b/src/Refitter.Core/DocumentEquivalenceComparer.cs @@ -8,6 +8,13 @@ namespace Refitter.Core; internal sealed class DocumentEquivalenceComparer { + /// + /// Determines whether two values are equivalent by comparing their canonical representations. + /// + /// The type of values to compare. + /// The existing value to compare. + /// The incoming value to compare. + /// True if the values are equivalent; otherwise, false. public bool AreEquivalent(TValue existingValue, TValue incomingValue) { if (ReferenceEquals(existingValue, incomingValue) || @@ -28,6 +35,11 @@ public bool AreEquivalent(TValue existingValue, TValue incomingValue) } } + /// + /// Creates a canonical JSON token representation of the given value for comparison purposes. + /// + /// The value to convert to a canonical JSON token. + /// A canonical JToken representation of the value. public JToken CreateCanonicalJsonToken(object value) { try @@ -40,6 +52,11 @@ public JToken CreateCanonicalJsonToken(object value) } } + /// + /// Normalizes a JSON token by recursively sorting object properties and preserving array order. + /// + /// The JSON token to normalize. + /// A normalized copy of the JSON token. public JToken NormalizeJsonToken(JToken token) => token switch { @@ -52,6 +69,12 @@ public JToken NormalizeJsonToken(JToken token) _ => token.DeepClone() }; + /// + /// Creates a canonical JSON token representation of a JSON schema for comparison purposes. + /// + /// The JSON schema to convert. + /// A set of already-visited schemas to handle circular references. + /// A canonical JToken representation of the schema. public JToken CreateCanonicalSchemaToken(JsonSchema schema, ISet visited) { if (schema.Reference != null) @@ -73,9 +96,9 @@ public JToken CreateCanonicalSchemaToken(JsonSchema schema, ISet vis AddSchemaToken(json, "additionalProperties", actualSchema.AdditionalPropertiesSchema, visited); AddSchemaToken(json, "items", actualSchema.Item, visited); - AddSchemaArray(json, "allOf", actualSchema.AllOf, visited); - AddSchemaArray(json, "oneOf", actualSchema.OneOf, visited); - AddSchemaArray(json, "anyOf", actualSchema.AnyOf, visited); + AddSchemaArray(json, "allOf", actualSchema.AllOf.OrderBy(s => CreateCanonicalSchemaToken(s, new HashSet(visited, JsonSchemaReferenceComparer.Instance)).ToString(Formatting.None), StringComparer.Ordinal), visited); + AddSchemaArray(json, "oneOf", actualSchema.OneOf.OrderBy(s => CreateCanonicalSchemaToken(s, new HashSet(visited, JsonSchemaReferenceComparer.Instance)).ToString(Formatting.None), StringComparer.Ordinal), visited); + AddSchemaArray(json, "anyOf", actualSchema.AnyOf.OrderBy(s => CreateCanonicalSchemaToken(s, new HashSet(visited, JsonSchemaReferenceComparer.Instance)).ToString(Formatting.None), StringComparer.Ordinal), visited); if (actualSchema.RequiredProperties.Count > 0) json["required"] = new JArray(actualSchema.RequiredProperties.OrderBy(name => name, StringComparer.Ordinal)); @@ -91,7 +114,7 @@ public JToken CreateCanonicalSchemaToken(JsonSchema schema, ISet vis } if (actualSchema.Enumeration.Count > 0) - json["enum"] = new JArray(actualSchema.Enumeration.Select(value => value != null ? JToken.FromObject(value) : JValue.CreateNull())); + json["enum"] = new JArray(actualSchema.Enumeration.OrderBy(value => value?.ToString() ?? string.Empty, StringComparer.Ordinal).Select(value => value != null ? JToken.FromObject(value) : JValue.CreateNull())); if (actualSchema.ExtensionData is { Count: > 0 }) { @@ -106,6 +129,11 @@ public JToken CreateCanonicalSchemaToken(JsonSchema schema, ISet vis return RemoveNullProperties(json); } + /// + /// Removes all properties with null values from a JSON object. + /// + /// The JSON object to process. + /// The modified JSON object with null properties removed. public JObject RemoveNullProperties(JObject json) { foreach (var property in json.Properties().Where(property => property.Value.Type == JTokenType.Null).ToArray()) @@ -116,6 +144,11 @@ public JObject RemoveNullProperties(JObject json) return json; } + /// + /// Creates a JSON string representation of an OpenAPI-related object. + /// + /// The value to serialize to JSON. + /// A JSON string representation of the value. public string CreateOpenApiJson(object value) => value switch { @@ -126,6 +159,11 @@ public string CreateOpenApiJson(object value) _ => JsonConvert.SerializeObject(value, Formatting.None) }; + /// + /// Recursively adds a schema and all its referenced schemas to the definitions dictionary. + /// + /// The dictionary to add schema definitions to. + /// The root schema to process. public void AddReferencedSchemas(IDictionary definitions, JsonSchema schema) { var visited = new HashSet(JsonSchemaReferenceComparer.Instance); @@ -151,6 +189,11 @@ public void AddReferencedSchemas(IDictionary definitions, Js } } + /// + /// Extracts the definition name from a schema's reference path. + /// + /// The schema to extract the definition name from. + /// The definition name if found; otherwise, null. public string? GetDefinitionName(JsonSchema schema) { var referencePath = ((NJsonSchema.References.IJsonReferenceBase)schema).ReferencePath; diff --git a/src/Refitter.Core/DocumentLoader.cs b/src/Refitter.Core/DocumentLoader.cs index 05cf89d6b..0c13906e5 100644 --- a/src/Refitter.Core/DocumentLoader.cs +++ b/src/Refitter.Core/DocumentLoader.cs @@ -27,6 +27,9 @@ static DocumentLoader() public async Task LoadAsync(string openApiPath) { + if (string.IsNullOrWhiteSpace(openApiPath)) + throw new ArgumentException("The openApiPath parameter cannot be null, empty, or contain only whitespace.", nameof(openApiPath)); + try { var readResult = await OpenApiMultiFileReader.Read(openApiPath).ConfigureAwait(false); @@ -45,7 +48,7 @@ public async Task LoadAsync(string openApiPath) var json = await readResult.OpenApiDocument.SerializeAsJsonAsync(specificationVersion).ConfigureAwait(false); return await OpenApiDocument.FromJsonAsync(json).ConfigureAwait(false); } - catch (Exception) + catch (Exception ex) when (ex is not OperationCanceledException && ex is not TaskCanceledException) { return await CreateUsingNSwagAsync(openApiPath).ConfigureAwait(false); } @@ -98,7 +101,18 @@ private static Task GetHttpContent(string openApiPath) private static bool IsYaml(string path) { - return path.EndsWith("yaml", StringComparison.OrdinalIgnoreCase) || - path.EndsWith("yml", StringComparison.OrdinalIgnoreCase); + var queryIndex = path.IndexOf('?'); + var fragmentIndex = path.IndexOf('#'); + var endIndex = path.Length; + + if (queryIndex >= 0) + endIndex = Math.Min(endIndex, queryIndex); + if (fragmentIndex >= 0) + endIndex = Math.Min(endIndex, fragmentIndex); + + var basePath = endIndex < path.Length ? path.Substring(0, endIndex) : path; + + return basePath.EndsWith("yaml", StringComparison.OrdinalIgnoreCase) || + basePath.EndsWith("yml", StringComparison.OrdinalIgnoreCase); } } diff --git a/src/Refitter.Core/DocumentMerger.cs b/src/Refitter.Core/DocumentMerger.cs index f4ccfe6dc..cb976aa76 100644 --- a/src/Refitter.Core/DocumentMerger.cs +++ b/src/Refitter.Core/DocumentMerger.cs @@ -9,13 +9,25 @@ internal sealed class DocumentMerger : IDocumentMerger { private readonly DocumentEquivalenceComparer _comparer; + /// + /// Initializes a new instance of the DocumentMerger class. + /// + /// The comparer used to detect equivalent document elements during merging. public DocumentMerger(DocumentEquivalenceComparer comparer) { _comparer = comparer; } + /// + /// Merges multiple OpenAPI documents into a single document. + /// + /// The array of OpenAPI documents to merge. + /// A merged OpenAPI document containing all paths, schemas, and other elements from the input documents. public OpenApiDocument Merge(OpenApiDocument[] documents) { + if (documents == null || documents.Length == 0) + throw new ArgumentException("The documents parameter cannot be null or empty.", nameof(documents)); + var baseDocument = CloneDocument(documents[0]); var tagNames = new HashSet(baseDocument.Tags.Select(t => t.Name), StringComparer.Ordinal); @@ -27,11 +39,14 @@ public OpenApiDocument Merge(OpenApiDocument[] documents) MergeIfMissingOrThrowOnConflict(baseDocument.Paths, path.Key, path.Value, "path"); } - if (document.Components.Schemas.Count > 0) + if (document.Components?.Schemas?.Count > 0) { - foreach (var schema in document.Components.Schemas) + if (baseDocument.Components?.Schemas != null) { - MergeIfMissingOrThrowOnConflict(baseDocument.Components.Schemas, schema.Key, schema.Value, "schema"); + foreach (var schema in document.Components.Schemas) + { + MergeIfMissingOrThrowOnConflict(baseDocument.Components.Schemas, schema.Key, schema.Value, "schema"); + } } }