Skip to content

Commit d82dab5

Browse files
authored
Add support for generating OpenAPI request bodies (#55040)
1 parent 4fe1a1f commit d82dab5

7 files changed

+552
-3
lines changed

src/OpenApi/sample/Program.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Microsoft.AspNetCore.Mvc;
45
using Microsoft.OpenApi.Models;
56
using Sample.Transformers;
67

@@ -21,6 +22,7 @@
2122
});
2223
});
2324
builder.Services.AddOpenApi("responses");
25+
builder.Services.AddOpenApi("forms");
2426

2527
var app = builder.Build();
2628

@@ -30,6 +32,18 @@
3032
app.MapSwaggerUi();
3133
}
3234

35+
var forms = app.MapGroup("forms")
36+
.WithGroupName("forms");
37+
38+
if (app.Environment.IsDevelopment())
39+
{
40+
forms.DisableAntiforgery();
41+
}
42+
43+
forms.MapPost("/form-file", (IFormFile resume) => Results.Ok(resume.FileName));
44+
forms.MapPost("/form-files", (IFormFileCollection files) => Results.Ok(files.Count));
45+
forms.MapPost("/form-todo", ([FromForm] Todo todo) => Results.Ok(todo));
46+
3347
var v1 = app.MapGroup("v1")
3448
.WithGroupName("v1");
3549
var v2 = app.MapGroup("v2")

src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics;
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.Linq;
57
using System.Text;
68
using Microsoft.AspNetCore.Mvc.ApiExplorer;
79
using Microsoft.AspNetCore.Mvc.ModelBinding;
@@ -81,4 +83,34 @@ public static bool IsRequestBodyParameter(this ApiParameterDescription apiParame
8183
apiParameterDescription.Source == BindingSource.Body ||
8284
apiParameterDescription.Source == BindingSource.FormFile ||
8385
apiParameterDescription.Source == BindingSource.Form;
86+
87+
/// <summary>
88+
/// Retrieves the form parameters from the ApiDescription, if they exist.
89+
/// </summary>
90+
/// <param name="apiDescription">The ApiDescription to resolve form parameters from.</param>
91+
/// <param name="formParameters">A list of <see cref="ApiParameterDescription"/> associated with the form parameters.</param>
92+
/// <returns><see langword="true"/> if form parameters were found, <see langword="false"/> otherwise.</returns>
93+
public static bool TryGetFormParameters(this ApiDescription apiDescription, out IEnumerable<ApiParameterDescription> formParameters)
94+
{
95+
formParameters = apiDescription.ParameterDescriptions.Where(parameter => parameter.Source == BindingSource.Form || parameter.Source == BindingSource.FormFile);
96+
return formParameters.Any();
97+
}
98+
99+
/// <summary>
100+
/// Retrieves the body parameter from the ApiDescription, if it exists.
101+
/// </summary>
102+
/// <param name="apiDescription">The ApiDescription to resolve the body parameter from.</param>
103+
/// <param name="bodyParameter">The <see cref="ApiParameterDescription"/> associated with the body parameter.</param>
104+
/// <returns><see langword="true"/> if a single body parameter was found, <see langword="false"/> otherwise.</returns>
105+
public static bool TryGetBodyParameter(this ApiDescription apiDescription, [NotNullWhen(true)] out ApiParameterDescription? bodyParameter)
106+
{
107+
bodyParameter = null;
108+
var bodyParameters = apiDescription.ParameterDescriptions.Where(parameter => parameter.Source == BindingSource.Body);
109+
if (bodyParameters.Count() == 1)
110+
{
111+
bodyParameter = bodyParameters.Single();
112+
return true;
113+
}
114+
return false;
115+
}
84116
}

src/OpenApi/src/Services/OpenApiComponentService.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Collections.Concurrent;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.OpenApi.Models;
7+
48
namespace Microsoft.AspNetCore.OpenApi;
59

610
/// <summary>
@@ -10,4 +14,25 @@ namespace Microsoft.AspNetCore.OpenApi;
1014
/// </summary>
1115
internal sealed class OpenApiComponentService
1216
{
17+
private readonly ConcurrentDictionary<Type, OpenApiSchema> _schemas = new()
18+
{
19+
// Pre-populate OpenAPI schemas for well-defined types in ASP.NET Core.
20+
[typeof(IFormFile)] = new OpenApiSchema { Type = "string", Format = "binary" },
21+
[typeof(IFormFileCollection)] = new OpenApiSchema
22+
{
23+
Type = "array",
24+
Items = new OpenApiSchema { Type = "string", Format = "binary" }
25+
},
26+
};
27+
28+
internal OpenApiSchema GetOrCreateSchema(Type type)
29+
{
30+
return _schemas.GetOrAdd(type, _ => CreateSchema());
31+
}
32+
33+
// TODO: Implement this method to create a schema for a given type.
34+
private static OpenApiSchema CreateSchema()
35+
{
36+
return new OpenApiSchema { Type = "string" };
37+
}
1338
}

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ internal sealed class OpenApiDocumentService(
2828
IServiceProvider serviceProvider)
2929
{
3030
private readonly OpenApiOptions _options = optionsMonitor.Get(documentName);
31+
private readonly OpenApiComponentService _componentService = serviceProvider.GetRequiredKeyedService<OpenApiComponentService>(documentName);
32+
33+
private static readonly OpenApiEncoding _defaultFormEncoding = new OpenApiEncoding { Style = ParameterStyle.Form, Explode = true };
3134

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

127-
private static OpenApiOperation GetOperation(ApiDescription description, HashSet<OpenApiTag> capturedTags)
130+
private OpenApiOperation GetOperation(ApiDescription description, HashSet<OpenApiTag> capturedTags)
128131
{
129132
var tags = GetTags(description);
130133
if (tags != null)
@@ -140,6 +143,7 @@ private static OpenApiOperation GetOperation(ApiDescription description, HashSet
140143
Description = GetDescription(description),
141144
Responses = GetResponses(description),
142145
Parameters = GetParameters(description),
146+
RequestBody = GetRequestBody(description),
143147
Tags = tags,
144148
};
145149
return operation;
@@ -256,4 +260,78 @@ private static OpenApiResponse GetResponse(ApiDescription apiDescription, int st
256260
}
257261
return parameters;
258262
}
263+
264+
private OpenApiRequestBody? GetRequestBody(ApiDescription description)
265+
{
266+
// Only one parameter can be bound from the body in each request.
267+
if (description.TryGetBodyParameter(out var bodyParameter))
268+
{
269+
return GetJsonRequestBody(description.SupportedRequestFormats, bodyParameter);
270+
}
271+
// If there are no body parameters, check for form parameters.
272+
// Note: Form parameters and body parameters cannot exist simultaneously
273+
// in the same endpoint.
274+
if (description.TryGetFormParameters(out var formParameters))
275+
{
276+
return GetFormRequestBody(description.SupportedRequestFormats, formParameters);
277+
}
278+
return null;
279+
}
280+
281+
private OpenApiRequestBody GetFormRequestBody(IList<ApiRequestFormat> supportedRequestFormats, IEnumerable<ApiParameterDescription> formParameters)
282+
{
283+
if (supportedRequestFormats.Count == 0)
284+
{
285+
// Assume "application/x-www-form-urlencoded" as the default media type
286+
// to match the default assumed in IFormFeature.
287+
supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/x-www-form-urlencoded" }];
288+
}
289+
290+
var requestBody = new OpenApiRequestBody
291+
{
292+
Required = formParameters.Any(parameter => parameter.IsRequired),
293+
Content = new Dictionary<string, OpenApiMediaType>()
294+
};
295+
296+
// Forms are represented as objects with properties for each form field.
297+
var schema = new OpenApiSchema { Type = "object", Properties = new Dictionary<string, OpenApiSchema>() };
298+
foreach (var parameter in formParameters)
299+
{
300+
schema.Properties[parameter.Name] = _componentService.GetOrCreateSchema(parameter.Type);
301+
}
302+
303+
foreach (var requestFormat in supportedRequestFormats)
304+
{
305+
var contentType = requestFormat.MediaType;
306+
requestBody.Content[contentType] = new OpenApiMediaType
307+
{
308+
Schema = schema,
309+
Encoding = new Dictionary<string, OpenApiEncoding>() { [contentType] = _defaultFormEncoding }
310+
};
311+
}
312+
313+
return requestBody;
314+
}
315+
316+
private static OpenApiRequestBody GetJsonRequestBody(IList<ApiRequestFormat> supportedRequestFormats, ApiParameterDescription bodyParameter)
317+
{
318+
if (supportedRequestFormats.Count == 0)
319+
{
320+
supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json" }];
321+
}
322+
323+
var requestBody = new OpenApiRequestBody
324+
{
325+
Required = bodyParameter.IsRequired,
326+
Content = new Dictionary<string, OpenApiMediaType>()
327+
};
328+
329+
foreach (var requestForm in supportedRequestFormats)
330+
{
331+
var contentType = requestForm.MediaType;
332+
requestBody.Content[contentType] = new OpenApiMediaType();
333+
}
334+
335+
return requestBody;
336+
}
259337
}

src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public void GetOpenApiInfo_RespectsHostEnvironmentName()
2323
new Mock<IApiDescriptionGroupCollectionProvider>().Object,
2424
hostEnvironment,
2525
new Mock<IOptionsMonitor<OpenApiOptions>>().Object,
26-
new Mock<IServiceProvider>().Object);
26+
new Mock<IKeyedServiceProvider>().Object);
2727

2828
// Act
2929
var info = docService.GetOpenApiInfo();
@@ -45,7 +45,7 @@ public void GetOpenApiInfo_RespectsDocumentName()
4545
new Mock<IApiDescriptionGroupCollectionProvider>().Object,
4646
hostEnvironment,
4747
new Mock<IOptionsMonitor<OpenApiOptions>>().Object,
48-
new Mock<IServiceProvider>().Object);
48+
new Mock<IKeyedServiceProvider>().Object);
4949

5050
// Act
5151
var info = docService.GetOpenApiInfo();

0 commit comments

Comments
 (0)