Skip to content
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

merge schemas in allOf when generating a sliced OpenAPI document. #5308

Merged
merged 17 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
123 changes: 121 additions & 2 deletions src/Kiota.Builder/Plugins/PluginsGenerationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -12,7 +11,6 @@
using Kiota.Builder.OpenApiExtensions;
using Microsoft.Kiota.Abstractions.Extensions;
using Microsoft.OpenApi.ApiManifest;
using Microsoft.OpenApi.Extensions;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi.Services;
using Microsoft.OpenApi.Writers;
Expand Down Expand Up @@ -60,6 +58,7 @@
#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task
var descriptionWriter = new OpenApiYamlWriter(fileWriter);
var trimmedPluginDocument = GetDocumentWithTrimmedComponentsAndResponses(OAIDocument);
trimmedPluginDocument = InlineRequestBodyAllOf(trimmedPluginDocument);
trimmedPluginDocument.SerializeAsV3(descriptionWriter);
descriptionWriter.Flush();

Expand All @@ -81,7 +80,7 @@
pluginDocument.Write(writer);
break;
case PluginType.APIManifest:
var apiManifest = new ApiManifestDocument("application"); //TODO add application name

Check warning on line 83 in src/Kiota.Builder/Plugins/PluginsGenerationService.cs

View workflow job for this annotation

GitHub Actions / Build

Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)

Check warning on line 83 in src/Kiota.Builder/Plugins/PluginsGenerationService.cs

View workflow job for this annotation

GitHub Actions / Build

Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)
// pass empty config hash so that its not included in this manifest.
apiManifest.ApiDependencies.AddOrReplace(Configuration.ClientClassName, Configuration.ToApiDependency(string.Empty, TreeNode?.GetRequestInfo().ToDictionary(static x => x.Key, static x => x.Value) ?? [], WorkingDirectory));
var publisherName = string.IsNullOrEmpty(OAIDocument.Info?.Contact?.Name)
Expand All @@ -104,6 +103,126 @@
}
}

private static OpenApiDocument InlineRequestBodyAllOf(OpenApiDocument openApiDocument)
{
if (openApiDocument.Paths is null) return openApiDocument;
var contentItems = openApiDocument.Paths.Values.Where(static x => x?.Operations is not null)
.SelectMany(static x => x.Operations.Values.Where(static x => x?.RequestBody?.Content is not null)
.SelectMany(static x => x.RequestBody.Content.Values));
foreach (var contentItem in contentItems)
{
var schema = contentItem.Schema;
// Merge all schemas in allOf `schema.MergeAllOfSchemaEntries()` doesn't seem to do the right thing.
schema = MergeAllOfInSchema(schema);
schema = SelectFirstAnyOfOrOneOf(schema);
contentItem.Schema = schema;
}

return openApiDocument;

static OpenApiSchema? SelectFirstAnyOfOrOneOf(OpenApiSchema? schema)
{
if (schema?.AnyOf is not { Count: > 0 } && schema?.OneOf is not { Count: > 0 }) return schema;
OpenApiSchema newSchema;
if (schema.AnyOf is { Count: > 0 })
{
newSchema = schema.AnyOf[0];
}
else if (schema.OneOf is { Count: > 0 })
{
newSchema = schema.OneOf[0];
}
else
{
newSchema = schema;
}
return newSchema;
}
static OpenApiSchema? MergeAllOfInSchema(OpenApiSchema? schema)

Check warning on line 141 in src/Kiota.Builder/Plugins/PluginsGenerationService.cs

View workflow job for this annotation

GitHub Actions / Build

Refactor this static local function to reduce its Cognitive Complexity from 137 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)

Check warning on line 141 in src/Kiota.Builder/Plugins/PluginsGenerationService.cs

View workflow job for this annotation

GitHub Actions / Build

Refactor this static local function to reduce its Cognitive Complexity from 137 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
{
if (schema?.AllOf is not { Count: > 0 }) return schema;
var newSchema = new OpenApiSchema();
foreach (var apiSchema in schema.AllOf)
{
if (apiSchema.Title is not null) newSchema.Title = apiSchema.Title;
if (!string.IsNullOrEmpty(apiSchema.Type))
{
if (!string.IsNullOrEmpty(newSchema.Type) && newSchema.Type != apiSchema.Type)
{
throw new InvalidOperationException(
$"The schemas in allOf cannot have different types: '{newSchema.Type}' and '{apiSchema.Type}'.");
}
newSchema.Type = apiSchema.Type;
}
if (apiSchema.Format is not null) newSchema.Format = apiSchema.Format;
baywet marked this conversation as resolved.
Show resolved Hide resolved
if (!string.IsNullOrEmpty(apiSchema.Description)) newSchema.Description = apiSchema.Description;
if (apiSchema.Maximum is not null) newSchema.Maximum = apiSchema.Maximum;
if (apiSchema.ExclusiveMaximum is not null) newSchema.ExclusiveMaximum = apiSchema.ExclusiveMaximum;
if (apiSchema.Minimum is not null) newSchema.Minimum = apiSchema.Minimum;
if (apiSchema.ExclusiveMinimum is not null) newSchema.ExclusiveMinimum = apiSchema.ExclusiveMinimum;
if (apiSchema.MaxLength is not null) newSchema.MaxLength = apiSchema.MaxLength;
if (apiSchema.MinLength is not null) newSchema.MinLength = apiSchema.MinLength;
if (!string.IsNullOrEmpty(apiSchema.Pattern)) newSchema.Pattern = apiSchema.Pattern;
if (apiSchema.MultipleOf is not null) newSchema.MultipleOf = apiSchema.MultipleOf;
if (apiSchema.Default is not null) newSchema.Default = apiSchema.Default;
if (apiSchema.ReadOnly) newSchema.ReadOnly = apiSchema.ReadOnly;
if (apiSchema.WriteOnly) newSchema.WriteOnly = apiSchema.WriteOnly;
if (apiSchema.Not is not null) newSchema.Not = apiSchema.Not;
if (apiSchema.Required is { Count: > 0 })
{
foreach (var r in apiSchema.Required.Where(static r => !string.IsNullOrEmpty(r)))
{
newSchema.Required.Add(r);
}
}
if (apiSchema.Items is not null) newSchema.Items = apiSchema.Items;
if (apiSchema.MaxItems is not null) newSchema.MaxItems = apiSchema.MaxItems;
if (apiSchema.MinItems is not null) newSchema.MinItems = apiSchema.MinItems;
if (apiSchema.UniqueItems is not null) newSchema.UniqueItems = apiSchema.UniqueItems;
if (apiSchema.Properties is not null)
{
foreach (var property in apiSchema.Properties)
{
newSchema.Properties.Add(property.Key, property.Value);
}
}
if (apiSchema.MaxProperties is not null) newSchema.MaxProperties = apiSchema.MaxProperties;
if (apiSchema.MinProperties is not null) newSchema.MinProperties = apiSchema.MinProperties;
if (apiSchema.AdditionalPropertiesAllowed) newSchema.AdditionalPropertiesAllowed = true;
if (apiSchema.AdditionalProperties is not null) newSchema.AdditionalProperties = apiSchema.AdditionalProperties;
if (apiSchema.Discriminator is not null) newSchema.Discriminator = apiSchema.Discriminator;
if (apiSchema.Example is not null) newSchema.Example = apiSchema.Example;
if (apiSchema.Enum is not null)
{
foreach (var enumValue in apiSchema.Enum)
{
newSchema.Enum.Add(enumValue);
}
}
if (apiSchema.Nullable) newSchema.Nullable = apiSchema.Nullable;
if (apiSchema.ExternalDocs is not null) newSchema.ExternalDocs = apiSchema.ExternalDocs;
if (apiSchema.Deprecated) newSchema.Deprecated = apiSchema.Deprecated;
if (apiSchema.Xml is not null) newSchema.Xml = apiSchema.Xml;
if (apiSchema.Extensions is not null)
{
foreach (var extension in apiSchema.Extensions)
{
newSchema.Extensions.Add(extension.Key, extension.Value);
}
}
if (apiSchema.Reference is not null) newSchema.Reference = apiSchema.Reference;
if (apiSchema.Annotations is not null)
{
foreach (var annotation in apiSchema.Annotations)
{
newSchema.Annotations.Add(annotation.Key, annotation.Value);
}
}
}
return newSchema;
}
}

[GeneratedRegex(@"[^a-zA-Z0-9_]+", RegexOptions.IgnoreCase | RegexOptions.Singleline, 2000)]
private static partial Regex PluginNameCleanupRegex();

Expand Down
176 changes: 176 additions & 0 deletions tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -549,4 +549,180 @@ await assertions(async () =>
// ignored
}
}

#region Validation

public static TheoryData<string, Action<OpenApiDocument, OpenApiDiagnostic>>
ValidationSchemaTestInput()
{
return new TheoryData<string, Action<OpenApiDocument, OpenApiDiagnostic>>
{
// AllOf
// simple disjoint
{
"""
content:
application/json:
schema:
allOf: [
{type: string},
{maxLength: 5}
]
""", (slicedDocument, _) =>
{
Assert.NotNull(slicedDocument);
Assert.NotEmpty(slicedDocument.Paths);
var schema = slicedDocument.Paths["/test"].Operations[OperationType.Post].RequestBody
.Content["application/json"].Schema;
Assert.Equal("string", schema.Type);
Assert.Equal(5, schema.MaxLength);
}
},
// objects
{
"""
content:
application/json:
schema:
allOf: [
{type: object, properties: {a: {type: string}, b: {type: number}}},
{type: object, properties: {c: {type: number}}}
]
""", (slicedDocument, _) =>
{
Assert.NotNull(slicedDocument);
Assert.NotEmpty(slicedDocument.Paths);
var schema = slicedDocument.Paths["/test"].Operations[OperationType.Post].RequestBody
.Content["application/json"].Schema;
Assert.Equal("object", schema.Type);
Assert.Equal(3, schema.Properties.Count);
}
},
// AnyOf
{
"""
content:
application/json:
schema:
anyOf: [
{type: object, properties: {a: {type: string}, b: {type: number}}},
{type: object, properties: {c: {type: number}}}
]
""", (slicedDocument, _) =>
{
Assert.NotNull(slicedDocument);
Assert.NotEmpty(slicedDocument.Paths);
var schema = slicedDocument.Paths["/test"].Operations[OperationType.Post].RequestBody
.Content["application/json"].Schema;
Assert.Equal("object", schema.Type);
Assert.Equal(2, schema.Properties.Count);
}
},
// OneOf
{
"""
content:
application/json:
schema:
oneOf: [
{type: object, properties: {c: {type: number}}},
{type: object, properties: {a: {type: string}, b: {type: number}}}
]
""", (slicedDocument, _) =>
{
Assert.NotNull(slicedDocument);
Assert.NotEmpty(slicedDocument.Paths);
var schema = slicedDocument.Paths["/test"].Operations[OperationType.Post].RequestBody
.Content["application/json"].Schema;
Assert.Equal("object", schema.Type);
Assert.Single(schema.Properties);
}
},
// normal schema
{
"""
content:
application/json:
schema: {type: object, properties: {c: {type: number}}}
""", (slicedDocument, _) =>
{
Assert.NotNull(slicedDocument);
Assert.NotEmpty(slicedDocument.Paths);
var schema = slicedDocument.Paths["/test"].Operations[OperationType.Post].RequestBody
.Content["application/json"].Schema;
Assert.Equal("object", schema.Type);
Assert.Single(schema.Properties);
}
},
};
}

[Theory]
[MemberData(nameof(ValidationSchemaTestInput))]
public async Task MergesAllOfRequestBodyAsync(string content, Action<OpenApiDocument, OpenApiDiagnostic> assertions)
{
var apiDescription = $"""
openapi: 3.0.0
info:
title: test
version: "1.0"
paths:
/test:
post:
description: description for test path
requestBody:
required: true
{content}
responses:
'200':
description: "success"
""";
// creates a new schema with both type:string & maxLength:5
var workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
var simpleDescriptionPath = Path.Combine(workingDirectory) + "description.yaml";
await File.WriteAllTextAsync(simpleDescriptionPath, apiDescription);
var mockLogger = new Mock<ILogger<PluginsGenerationService>>();
var openAPIDocumentDS = new OpenApiDocumentDownloadService(_httpClient, mockLogger.Object);
var outputDirectory = Path.Combine(workingDirectory, "output");
var generationConfiguration = new GenerationConfiguration
{
OutputPath = outputDirectory,
OpenAPIFilePath = "openapiPath",
PluginTypes = [PluginType.APIPlugin],
ClientClassName = "client",
ApiRootUrl = "http://localhost/", //Kiota builder would set this for us
};
var (openAPIDocumentStream, _) = await openAPIDocumentDS.LoadStreamAsync(simpleDescriptionPath, generationConfiguration, null, false);
var openApiDocument = await openAPIDocumentDS.GetDocumentFromStreamAsync(openAPIDocumentStream, generationConfiguration);
KiotaBuilder.CleanupOperationIdForPlugins(openApiDocument);
var urlTreeNode = OpenApiUrlTreeNode.Create(openApiDocument, Constants.DefaultOpenApiLabel);

var pluginsGenerationService = new PluginsGenerationService(openApiDocument, urlTreeNode, generationConfiguration, workingDirectory);
await pluginsGenerationService.GenerateManifestAsync();

Assert.True(File.Exists(Path.Combine(outputDirectory, ManifestFileName)));
Assert.True(File.Exists(Path.Combine(outputDirectory, OpenApiFileName)));

try
{
// Validate the sliced openapi
var slicedApiContent = await File.ReadAllTextAsync(Path.Combine(outputDirectory, OpenApiFileName));
var r = new OpenApiStringReader();
var slicedDocument = r.Read(slicedApiContent, out var diagnostic);
assertions(slicedDocument, diagnostic);
}
finally
{
try
{
Directory.Delete(outputDirectory);
}
catch (Exception)
{
// ignored
}
}
}

#endregion
}
Loading