-
-
Notifications
You must be signed in to change notification settings - Fork 65
Refactor OpenApiDocumentFactory into separate modules #1144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
|
|
||
| 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
|
||
| { | ||
| 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
|
||
| { | ||
| if (childSchema != null) | ||
| schemasToProcess.Push(childSchema); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public string? GetDefinitionName(JsonSchema schema) | ||
|
Check warning on line 154 in src/Refitter.Core/DocumentEquivalenceComparer.cs
|
||
|
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; | ||
| } | ||
| } | ||
| } | ||
| 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
|
||
| { | ||
| try | ||
|
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); | ||
| } | ||
|
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); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.