Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 261 additions & 0 deletions src/Refitter.Core/DocumentEquivalenceComparer.cs
Original file line number Diff line number Diff line change
@@ -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>(TValue existingValue, TValue incomingValue)
{
if (ReferenceEquals(existingValue, incomingValue) ||
EqualityComparer<TValue>.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<JsonSchema>(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<JsonSchema> 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);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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<JsonSchema>(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)

Check warning on line 109 in src/Refitter.Core/DocumentEquivalenceComparer.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make 'RemoveNullProperties' a static method.

See more on https://sonarcloud.io/project/issues?id=christianhelle_refitter&issues=AZ7ISia1ixFPtcnbP5DG&open=AZ7ISia1ixFPtcnbP5DG&pullRequest=1144
{
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<string, JsonSchema> definitions, JsonSchema schema)
{
var visited = new HashSet<JsonSchema>(JsonSchemaReferenceComparer.Instance);
var schemasToProcess = new Stack<JsonSchema>();
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))

Check warning on line 146 in src/Refitter.Core/DocumentEquivalenceComparer.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Loops should be simplified using the "Where" LINQ method

See more on https://sonarcloud.io/project/issues?id=christianhelle_refitter&issues=AZ7ISia1ixFPtcnbP5DI&open=AZ7ISia1ixFPtcnbP5DI&pullRequest=1144
{
if (childSchema != null)
schemasToProcess.Push(childSchema);
}
}
}

public string? GetDefinitionName(JsonSchema schema)

Check warning on line 154 in src/Refitter.Core/DocumentEquivalenceComparer.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make 'GetDefinitionName' a static method.

See more on https://sonarcloud.io/project/issues?id=christianhelle_refitter&issues=AZ7ISia1ixFPtcnbP5DH&open=AZ7ISia1ixFPtcnbP5DH&pullRequest=1144
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
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<JsonSchema> visited) =>
visited.Contains(reference)
? new JObject { ["$ref"] = "#" }
: CreateCanonicalSchemaToken(reference, new HashSet<JsonSchema>(visited, JsonSchemaReferenceComparer.Instance));

private void AddSchemaToken(JObject json, string propertyName, JsonSchema? schema, ISet<JsonSchema> visited)
{
if (schema != null)
json[propertyName] = CreateCanonicalSchemaToken(schema, new HashSet<JsonSchema>(visited, JsonSchemaReferenceComparer.Instance));
}

private void AddSchemaArray(JObject json, string propertyName, IEnumerable<JsonSchema> schemas, ISet<JsonSchema> visited)
{
var items = schemas
.Select(schema => CreateCanonicalSchemaToken(schema, new HashSet<JsonSchema>(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<JsonSchema?> 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;
}
}
}
104 changes: 104 additions & 0 deletions src/Refitter.Core/DocumentLoader.cs
Original file line number Diff line number Diff line change
@@ -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<OpenApiDocument> LoadAsync(string openApiPath)

Check failure on line 28 in src/Refitter.Core/DocumentLoader.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename parameter 'openApiPath' to 'path' to match the interface declaration.

See more on https://sonarcloud.io/project/issues?id=christianhelle_refitter&issues=AZ7ISiarixFPtcnbP5DF&open=AZ7ISiarixFPtcnbP5DF&pullRequest=1144
{
try
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

private static async Task<OpenApiDocument> 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<string> GetHttpContent(string openApiPath)
=> HttpClient.GetStringAsync(openApiPath);

private static bool IsYaml(string path)
{
return path.EndsWith("yaml", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith("yml", StringComparison.OrdinalIgnoreCase);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading