Skip to content

Add support for generating OpenAPI request bodies #55040

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

Merged
merged 4 commits into from
Apr 16, 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
14 changes: 14 additions & 0 deletions src/OpenApi/sample/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi.Models;
using Sample.Transformers;

Expand All @@ -21,6 +22,7 @@
});
});
builder.Services.AddOpenApi("responses");
builder.Services.AddOpenApi("forms");

var app = builder.Build();

Expand All @@ -30,6 +32,18 @@
app.MapSwaggerUi();
}

var forms = app.MapGroup("forms")
.WithGroupName("forms");

if (app.Environment.IsDevelopment())
{
forms.DisableAntiforgery();
}

forms.MapPost("/form-file", (IFormFile resume) => Results.Ok(resume.FileName));
forms.MapPost("/form-files", (IFormFileCollection files) => Results.Ok(files.Count));
forms.MapPost("/form-todo", ([FromForm] Todo todo) => Results.Ok(todo));

var v1 = app.MapGroup("v1")
.WithGroupName("v1");
var v2 = app.MapGroup("v2")
Expand Down
32 changes: 32 additions & 0 deletions src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ModelBinding;
Expand Down Expand Up @@ -81,4 +83,34 @@ public static bool IsRequestBodyParameter(this ApiParameterDescription apiParame
apiParameterDescription.Source == BindingSource.Body ||
apiParameterDescription.Source == BindingSource.FormFile ||
apiParameterDescription.Source == BindingSource.Form;

/// <summary>
/// Retrieves the form parameters from the ApiDescription, if they exist.
/// </summary>
/// <param name="apiDescription">The ApiDescription to resolve form parameters from.</param>
/// <param name="formParameters">A list of <see cref="ApiParameterDescription"/> associated with the form parameters.</param>
/// <returns><see langword="true"/> if form parameters were found, <see langword="false"/> otherwise.</returns>
public static bool TryGetFormParameters(this ApiDescription apiDescription, out IEnumerable<ApiParameterDescription> formParameters)
{
formParameters = apiDescription.ParameterDescriptions.Where(parameter => parameter.Source == BindingSource.Form || parameter.Source == BindingSource.FormFile);
return formParameters.Any();
}

/// <summary>
/// Retrieves the body parameter from the ApiDescription, if it exists.
/// </summary>
/// <param name="apiDescription">The ApiDescription to resolve the body parameter from.</param>
/// <param name="bodyParameter">The <see cref="ApiParameterDescription"/> associated with the body parameter.</param>
/// <returns><see langword="true"/> if a single body parameter was found, <see langword="false"/> otherwise.</returns>
public static bool TryGetBodyParameter(this ApiDescription apiDescription, [NotNullWhen(true)] out ApiParameterDescription? bodyParameter)
{
bodyParameter = null;
var bodyParameters = apiDescription.ParameterDescriptions.Where(parameter => parameter.Source == BindingSource.Body);
if (bodyParameters.Count() == 1)
{
bodyParameter = bodyParameters.Single();
Comment on lines +109 to +111
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use SingleOrDefault()?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that throws if there are multiple elements? (Unless you meant under the if, in which case, why?)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have gone the other way and suggested First since there's no reason to do another bounds check.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh that's me misremembering then. I thought Single() was one or exception, and SingleOrDefault() was one or null/default.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SingleOrDefault will only return null/default if the list is empty. If there are multiple elements, it'll through an exception. Although based on the check that we are doing in the if, that should never be the case hence my inclination to use Single. First could also be used here but I feel it obfuscates the intention that there should always be only one parameter resolved from the JSON request body in a given request.

return true;
}
return false;
}
}
25 changes: 25 additions & 0 deletions src/OpenApi/src/Services/OpenApiComponentService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Concurrent;
using Microsoft.AspNetCore.Http;
using Microsoft.OpenApi.Models;

namespace Microsoft.AspNetCore.OpenApi;

/// <summary>
Expand All @@ -10,4 +14,25 @@ namespace Microsoft.AspNetCore.OpenApi;
/// </summary>
internal sealed class OpenApiComponentService
{
private readonly ConcurrentDictionary<Type, OpenApiSchema> _schemas = new()
{
// Pre-populate OpenAPI schemas for well-defined types in ASP.NET Core.
[typeof(IFormFile)] = new OpenApiSchema { Type = "string", Format = "binary" },
[typeof(IFormFileCollection)] = new OpenApiSchema
{
Type = "array",
Items = new OpenApiSchema { Type = "string", Format = "binary" }
},
};

internal OpenApiSchema GetOrCreateSchema(Type type)
{
return _schemas.GetOrAdd(type, _ => CreateSchema());
}

// TODO: Implement this method to create a schema for a given type.
private static OpenApiSchema CreateSchema()
{
return new OpenApiSchema { Type = "string" };
}
}
80 changes: 79 additions & 1 deletion src/OpenApi/src/Services/OpenApiDocumentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ internal sealed class OpenApiDocumentService(
IServiceProvider serviceProvider)
{
private readonly OpenApiOptions _options = optionsMonitor.Get(documentName);
private readonly OpenApiComponentService _componentService = serviceProvider.GetRequiredKeyedService<OpenApiComponentService>(documentName);

private static readonly OpenApiEncoding _defaultFormEncoding = new OpenApiEncoding { Style = ParameterStyle.Form, Explode = true };

/// <summary>
/// Cache of <see cref="OpenApiOperationTransformerContext"/> instances keyed by the
Expand Down Expand Up @@ -124,7 +127,7 @@ private Dictionary<OperationType, OpenApiOperation> GetOperations(IGrouping<stri
return operations;
}

private static OpenApiOperation GetOperation(ApiDescription description, HashSet<OpenApiTag> capturedTags)
private OpenApiOperation GetOperation(ApiDescription description, HashSet<OpenApiTag> capturedTags)
{
var tags = GetTags(description);
if (tags != null)
Expand All @@ -140,6 +143,7 @@ private static OpenApiOperation GetOperation(ApiDescription description, HashSet
Description = GetDescription(description),
Responses = GetResponses(description),
Parameters = GetParameters(description),
RequestBody = GetRequestBody(description),
Tags = tags,
};
return operation;
Expand Down Expand Up @@ -256,4 +260,78 @@ private static OpenApiResponse GetResponse(ApiDescription apiDescription, int st
}
return parameters;
}

private OpenApiRequestBody? GetRequestBody(ApiDescription description)
{
// Only one parameter can be bound from the body in each request.
if (description.TryGetBodyParameter(out var bodyParameter))
{
return GetJsonRequestBody(description.SupportedRequestFormats, bodyParameter);
}
// If there are no body parameters, check for form parameters.
// Note: Form parameters and body parameters cannot exist simultaneously
// in the same endpoint.
if (description.TryGetFormParameters(out var formParameters))
{
return GetFormRequestBody(description.SupportedRequestFormats, formParameters);
}
return null;
}

private OpenApiRequestBody GetFormRequestBody(IList<ApiRequestFormat> supportedRequestFormats, IEnumerable<ApiParameterDescription> formParameters)
{
if (supportedRequestFormats.Count == 0)
{
// Assume "application/x-www-form-urlencoded" as the default media type
// to match the default assumed in IFormFeature.
supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/x-www-form-urlencoded" }];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does one change this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For overriding whatever content-type is used, end-users can add Accepts metadata to the given endpoint.

}

var requestBody = new OpenApiRequestBody
{
Required = formParameters.Any(parameter => parameter.IsRequired),
Content = new Dictionary<string, OpenApiMediaType>()
};

// Forms are represented as objects with properties for each form field.
var schema = new OpenApiSchema { Type = "object", Properties = new Dictionary<string, OpenApiSchema>() };
foreach (var parameter in formParameters)
{
schema.Properties[parameter.Name] = _componentService.GetOrCreateSchema(parameter.Type);
}

foreach (var requestFormat in supportedRequestFormats)
{
var contentType = requestFormat.MediaType;
requestBody.Content[contentType] = new OpenApiMediaType
{
Schema = schema,
Encoding = new Dictionary<string, OpenApiEncoding>() { [contentType] = _defaultFormEncoding }
};
}

return requestBody;
}

private static OpenApiRequestBody GetJsonRequestBody(IList<ApiRequestFormat> supportedRequestFormats, ApiParameterDescription bodyParameter)
{
if (supportedRequestFormats.Count == 0)
{
supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json" }];
}

var requestBody = new OpenApiRequestBody
{
Required = bodyParameter.IsRequired,
Content = new Dictionary<string, OpenApiMediaType>()
};

foreach (var requestForm in supportedRequestFormats)
{
var contentType = requestForm.MediaType;
requestBody.Content[contentType] = new OpenApiMediaType();
}

return requestBody;
}
}
4 changes: 2 additions & 2 deletions src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public void GetOpenApiInfo_RespectsHostEnvironmentName()
new Mock<IApiDescriptionGroupCollectionProvider>().Object,
hostEnvironment,
new Mock<IOptionsMonitor<OpenApiOptions>>().Object,
new Mock<IServiceProvider>().Object);
new Mock<IKeyedServiceProvider>().Object);

// Act
var info = docService.GetOpenApiInfo();
Expand All @@ -45,7 +45,7 @@ public void GetOpenApiInfo_RespectsDocumentName()
new Mock<IApiDescriptionGroupCollectionProvider>().Object,
hostEnvironment,
new Mock<IOptionsMonitor<OpenApiOptions>>().Object,
new Mock<IServiceProvider>().Object);
new Mock<IKeyedServiceProvider>().Object);

// Act
var info = docService.GetOpenApiInfo();
Expand Down
Loading
Loading