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

* merge schemas in allOf when generating a sliced OpenAPI document.

* add test for object schema merge

* add null checks for allOf inlining algorithm

* add check for allOf types with different values

* fix schema type validation

* remove comment

* add any of and one of

* Add test for normal schema being unchanged

* apply code suggestions

* use SelectMany for AllOf flattening loop
This commit is contained in:
Caleb Kiage 2024-09-10 01:19:22 +03:00 коммит произвёл GitHub
Родитель 915c31f8b4
Коммит 5fdd2615bf
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
2 изменённых файлов: 297 добавлений и 2 удалений

Просмотреть файл

@ -3,7 +3,6 @@ using System.Collections.Generic;
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;
@ -12,7 +11,6 @@ using Kiota.Builder.Extensions;
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;
@ -60,6 +58,7 @@ public partial class PluginsGenerationService
#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();
@ -104,6 +103,126 @@ public partial class PluginsGenerationService
}
}
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)
{
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;
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();

Просмотреть файл

@ -549,4 +549,180 @@ components:
// 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
}