Skip to content

Commit 6814f45

Browse files
committed
Add support for generating JSON schemas in OpenAPI doc
1 parent 7d53092 commit 6814f45

File tree

40 files changed

+5215
-39
lines changed

40 files changed

+5215
-39
lines changed

eng/Dependencies.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ and are generated based on the last package release.
228228
<LatestPackageReference Include="StackExchange.Redis" />
229229
<LatestPackageReference Include="Swashbuckle.AspNetCore" />
230230
<LatestPackageReference Include="System.Reactive.Linq" />
231+
<LatestPackageReference Include="Verify.Xunit" />
231232
<LatestPackageReference Include="xunit.abstractions" />
232233
<LatestPackageReference Include="xunit.analyzers" />
233234
<LatestPackageReference Include="xunit.assert" />

eng/Versions.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,9 +325,10 @@
325325
<StackExchangeRedisVersion>2.7.27</StackExchangeRedisVersion>
326326
<SystemReactiveLinqVersion>5.0.0</SystemReactiveLinqVersion>
327327
<SwashbuckleAspNetCoreVersion>6.4.0</SwashbuckleAspNetCoreVersion>
328+
<VerifyXunitVersion>23.1.0</VerifyXunitVersion>
328329
<XunitAbstractionsVersion>2.0.3</XunitAbstractionsVersion>
329330
<XunitAnalyzersVersion>1.0.0</XunitAnalyzersVersion>
330-
<XunitVersion>2.4.2</XunitVersion>
331+
<XunitVersion>2.6.6</XunitVersion>
331332
<XunitAssertVersion>$(XunitVersion)</XunitAssertVersion>
332333
<XunitExtensibilityCoreVersion>$(XunitVersion)</XunitExtensibilityCoreVersion>
333334
<XunitExtensibilityExecutionVersion>$(XunitVersion)</XunitExtensibilityExecutionVersion>

src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Microsoft.AspNetCore.Mvc.RouteAttribute</Description>
9090
<InternalsVisibleTo Include="Microsoft.AspNetCore.Mvc.ViewFeatures.Test" />
9191
<InternalsVisibleTo Include="Microsoft.AspNetCore.Mvc.Views.TestCommon" />
9292
<InternalsVisibleTo Include="Microsoft.AspNetCore.Mvc.Microbenchmarks" />
93+
<InternalsVisibleTo Include="Microsoft.AspNetCore.OpenApi.Tests" />
9394
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" Key="$(MoqPublicKey)" />
9495
</ItemGroup>
9596
</Project>

src/Mvc/shared/Mvc.Core.TestCommon/MediaTypeAssert.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,18 @@ public static void Equal(StringSegment left, StringSegment right)
3232
}
3333
else if (!left.HasValue || !right.HasValue)
3434
{
35-
throw new EqualException(left.ToString(), right.ToString());
35+
throw EqualException.ForMismatchedValues(
36+
expected: left.ToString(),
37+
actual: right.ToString());
3638
}
3739

3840
if (!MediaTypeHeaderValue.TryParse(left.Value, out var leftMediaType) ||
3941
!MediaTypeHeaderValue.TryParse(right.Value, out var rightMediaType) ||
4042
!leftMediaType.Equals(rightMediaType))
4143
{
42-
throw new EqualException(left.ToString(), right.ToString());
44+
throw EqualException.ForMismatchedValues(
45+
expected: left.ToString(),
46+
actual: right.ToString());
4347
}
4448
}
4549
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using Microsoft.AspNetCore.Mvc;
3+
4+
[ApiController]
5+
[Route("[controller]")]
6+
public class TestController : ControllerBase
7+
{
8+
[HttpGet]
9+
[Route("/getbyidandname/{id}/{name}")]
10+
public string GetByIdAndName(RouteParamsContainer paramsContainer)
11+
{
12+
return paramsContainer.Id + "_" + paramsContainer.Name;
13+
}
14+
15+
public class RouteParamsContainer
16+
{
17+
[FromRoute]
18+
public int Id { get; set; }
19+
20+
[FromRoute]
21+
[MinLength(5)]
22+
public string? Name { get; set; }
23+
}
24+
}

src/OpenApi/sample/Program.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
var builder = WebApplication.CreateBuilder(args);
99

10+
builder.Services.AddControllers();
1011
builder.Services.AddAuthentication().AddJwtBearer();
1112

1213
builder.Services.AddOpenApi("v1", options =>
@@ -51,6 +52,8 @@
5152
var responses = app.MapGroup("responses")
5253
.WithGroupName("responses");
5354

55+
v1.MapGet("/ienumrable", (Guid[] guids) => guids);
56+
5457
v1.MapPost("/todos", (Todo todo) => Results.Created($"/todos/{todo.Id}", todo))
5558
.WithSummary("Creates a new todo item.");
5659
v1.MapGet("/todos/{id}", (int id) => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now))
@@ -67,4 +70,13 @@
6770
responses.MapGet("/200-only-xml", () => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now))
6871
.Produces<Todo>(contentType: "text/xml");
6972

73+
responses.MapGet("/triangle", () => new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 });
74+
responses.MapGet("/shape", () => new Shape { Color = "blue", Sides = 4 });
75+
76+
app.MapControllers();
77+
7078
app.Run();
79+
80+
// Make Program class public to support snapshot testing
81+
// against sample app using WebApplicationFactory.
82+
public partial class Program { }

src/OpenApi/sample/Transformers/OperationTransformers.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public static OpenApiOptions AddHeader(this OpenApiOptions options, string heade
1616
{
1717
var schema = OpenApiTypeMapper.MapTypeToOpenApiPrimitiveType(typeof(string));
1818
schema.Default = new OpenApiString(defaultValue);
19+
operation.Parameters ??= [];
1920
operation.Parameters.Add(new OpenApiParameter
2021
{
2122
Name = headerName,
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.ComponentModel.DataAnnotations;
5+
using System.Globalization;
6+
using System.Linq;
7+
using System.Reflection;
8+
using System.Text.Json.Nodes;
9+
using JsonSchemaMapper;
10+
using Microsoft.AspNetCore.Mvc.ApiExplorer;
11+
using Microsoft.AspNetCore.Routing;
12+
using Microsoft.AspNetCore.Routing.Constraints;
13+
using Microsoft.OpenApi.Models;
14+
15+
namespace Microsoft.AspNetCore.OpenApi;
16+
17+
/// <summary>
18+
/// Provides a set of extension methods for modifying the opaque JSON Schema type
19+
/// that is provided by the underlying schema generator in System.Text.Json.
20+
/// </summary>
21+
internal static class JsonObjectSchemaExtensions
22+
{
23+
private static readonly Dictionary<Type, OpenApiSchema> _simpleTypeToOpenApiSchema = new()
24+
{
25+
[typeof(bool)] = new() { Type = "boolean" },
26+
[typeof(byte)] = new() { Type = "string", Format = "byte" },
27+
[typeof(int)] = new() { Type = "integer", Format = "int32" },
28+
[typeof(uint)] = new() { Type = "integer", Format = "int32" },
29+
[typeof(long)] = new() { Type = "integer", Format = "int64" },
30+
[typeof(ulong)] = new() { Type = "integer", Format = "int64" },
31+
[typeof(short)] = new() { Type = "integer", Format = null },
32+
[typeof(ushort)] = new() { Type = "integer", Format = null },
33+
[typeof(float)] = new() { Type = "number", Format = "float" },
34+
[typeof(double)] = new() { Type = "number", Format = "double" },
35+
[typeof(decimal)] = new() { Type = "number", Format = "double" },
36+
[typeof(DateTime)] = new() { Type = "string", Format = "date-time" },
37+
[typeof(DateTimeOffset)] = new() { Type = "string", Format = "date-time" },
38+
[typeof(Guid)] = new() { Type = "string", Format = "uuid" },
39+
[typeof(char)] = new() { Type = "string" },
40+
41+
// Nullable types
42+
[typeof(bool?)] = new() { Type = "boolean", Nullable = true },
43+
[typeof(byte?)] = new() { Type = "string", Format = "byte", Nullable = true },
44+
[typeof(int?)] = new() { Type = "integer", Format = "int32", Nullable = true },
45+
[typeof(uint?)] = new() { Type = "integer", Format = "int32", Nullable = true },
46+
[typeof(long?)] = new() { Type = "integer", Format = "int64", Nullable = true },
47+
[typeof(ulong?)] = new() { Type = "integer", Format = "int64", Nullable = true },
48+
[typeof(short?)] = new() { Type = "integer", Format = null, Nullable = true },
49+
[typeof(ushort?)] = new() { Type = "integer", Format = null, Nullable = true },
50+
[typeof(float?)] = new() { Type = "number", Format = "float", Nullable = true },
51+
[typeof(double?)] = new() { Type = "number", Format = "double", Nullable = true },
52+
[typeof(decimal?)] = new() { Type = "number", Format = "double", Nullable = true },
53+
[typeof(DateTime?)] = new() { Type = "string", Format = "date-time", Nullable = true },
54+
[typeof(DateTimeOffset?)] = new() { Type = "string", Format = "date-time", Nullable = true },
55+
[typeof(Guid?)] = new() { Type = "string", Format = "uuid", Nullable = true },
56+
[typeof(char?)] = new() { Type = "string", Nullable = true },
57+
[typeof(Uri)] = new() { Type = "string", Format = "uri" },
58+
[typeof(string)] = new() { Type = "string" },
59+
};
60+
61+
/// <summary>
62+
/// Maps the given validation attributes to the target schema.
63+
/// </summary>
64+
/// <remarks>
65+
/// OpenApi schema v3 supports the validation vocabulary supported by JSON Schema. Because the underlying
66+
/// schema generator does not validation attributes to the validation vocabulary, we apply that mapping here.
67+
///
68+
/// Note that this method targets <see cref="JsonObject"/> and not <see cref="OpenApiSchema"/> because it is
69+
/// designed to be invoked via the `OnGenerated` callback provided by the underlying schema generator
70+
/// so that attributes can be mapped to the properties associated with inputs and outputs to a given request.
71+
///
72+
/// This implementation only supports mapping validation attributes that have an associated keyword in the
73+
/// validation vocabulary.
74+
/// </remarks>
75+
/// <param name="schema">The <see cref="JsonObject"/> produced by the underlying schema generator.</param>
76+
/// <param name="validationAttributes">A list of the validation attributes to apply.</param>
77+
internal static void ApplyValidationAttributes(this JsonObject schema, IEnumerable<Attribute> validationAttributes)
78+
{
79+
foreach (var attribute in validationAttributes)
80+
{
81+
if (attribute is Base64StringAttribute)
82+
{
83+
schema["format"] = "byte";
84+
}
85+
if (attribute is RangeAttribute rangeAttribute)
86+
{
87+
schema["minimum"] = decimal.Parse(rangeAttribute.Minimum.ToString()!, CultureInfo.InvariantCulture);
88+
schema["maximum"] = decimal.Parse(rangeAttribute.Maximum.ToString()!, CultureInfo.InvariantCulture);
89+
}
90+
if (attribute is RegularExpressionAttribute regularExpressionAttribute)
91+
{
92+
schema["pattern"] = regularExpressionAttribute.Pattern;
93+
}
94+
if (attribute is MaxLengthAttribute maxLengthAttribute)
95+
{
96+
schema["maxLength"] = maxLengthAttribute.Length;
97+
}
98+
if (attribute is MinLengthAttribute minLengthAttribute)
99+
{
100+
schema["minLength"] = minLengthAttribute.Length;
101+
}
102+
if (attribute is StringLengthAttribute stringLengthAttribute)
103+
{
104+
schema["minimum"] = stringLengthAttribute.MinimumLength;
105+
schema["maximum"] = stringLengthAttribute.MaximumLength;
106+
}
107+
}
108+
}
109+
110+
/// <summary>
111+
/// Applies the primitive types and formats to the schema based on the type.
112+
/// </summary>
113+
/// <remarks>
114+
/// OpenAPI v3 requires support for the format keyword in generated types. Because the
115+
/// underlying schema generator does not support this, we need to manually apply the
116+
/// supported formats to the schemas associated with the generated type.
117+
///
118+
/// Whereas JsonSchema represents nullable types via `type: ["string", "null"]`, OpenAPI
119+
/// v3 exposes a nullable property on the schema. This method will set the nullable property
120+
/// based on whether the underlying schema generator returned an array type containing "null" to
121+
/// represent a nullable type or if the type was denoted as nullable from our lookup cache.
122+
///
123+
/// Note that this method targets <see cref="JsonObject"/> and not <see cref="OpenApiSchema"/> because
124+
/// it is is designed to be invoked via the `OnGenerated` callback in the underlying schema generator as
125+
/// opposed to after the generated schemas have been mapped to OpenAPI schemas.
126+
/// </remarks>
127+
/// <param name="schema">The <see cref="JsonObject"/> produced by the underlying schema generator.</param>
128+
/// <param name="type">The <see cref="Type"/> associated with the <see paramref="schema"/>.</param>
129+
internal static void ApplyPrimitiveTypesAndFormats(this JsonObject schema, Type type)
130+
{
131+
if (_simpleTypeToOpenApiSchema.TryGetValue(type, out var openApiSchema))
132+
{
133+
schema["nullable"] = openApiSchema.Nullable || (schema["type"] is JsonArray schemaType
134+
&& schemaType.GetValues<string>().Contains("null"));
135+
schema["type"] = openApiSchema.Type;
136+
schema["format"] = openApiSchema.Format;
137+
}
138+
}
139+
140+
/// <summary>
141+
/// Applies route constraints to the target schema.
142+
/// </summary>
143+
/// <param name="schema">The <see cref="JsonObject"/> produced by the underlying schema generator.</param>
144+
/// <param name="constraints">The list of <see cref="IRouteConstraint"/>s associated with the route parameter.</param>
145+
internal static void ApplyRouteConstraints(this JsonObject schema, IEnumerable<IRouteConstraint> constraints)
146+
{
147+
foreach (var constraint in constraints)
148+
{
149+
if (constraint is MinRouteConstraint minRouteConstraint)
150+
{
151+
schema["minimum"] = minRouteConstraint.Min;
152+
}
153+
else if (constraint is MaxRouteConstraint maxRouteConstraint)
154+
{
155+
schema["maximum"] = maxRouteConstraint.Max;
156+
}
157+
else if (constraint is MinLengthRouteConstraint minLengthRouteConstraint)
158+
{
159+
schema["minLength"] = minLengthRouteConstraint.MinLength;
160+
}
161+
else if (constraint is MaxLengthRouteConstraint maxLengthRouteConstraint)
162+
{
163+
schema["maxLength"] = maxLengthRouteConstraint.MaxLength;
164+
}
165+
else if (constraint is RangeRouteConstraint rangeRouteConstraint)
166+
{
167+
schema["minimum"] = rangeRouteConstraint.Min;
168+
schema["maximum"] = rangeRouteConstraint.Max;
169+
}
170+
else if (constraint is RegexRouteConstraint regexRouteConstraint)
171+
{
172+
schema["type"] = "string";
173+
schema["format"] = null;
174+
schema["pattern"] = regexRouteConstraint.Constraint.ToString();
175+
}
176+
else if (constraint is LengthRouteConstraint lengthRouteConstraint)
177+
{
178+
schema["minLength"] = lengthRouteConstraint.MinLength;
179+
schema["maxLength"] = lengthRouteConstraint.MaxLength;
180+
}
181+
else if (constraint is FloatRouteConstraint or DecimalRouteConstraint or DoubleRouteConstraint)
182+
{
183+
schema["type"] = "number";
184+
schema["format"] = constraint is FloatRouteConstraint ? "float" : "double";
185+
}
186+
else if (constraint is LongRouteConstraint or IntRouteConstraint)
187+
{
188+
schema["type"] = "integer";
189+
schema["format"] = constraint is LongRouteConstraint ? "int64" : "int32";
190+
}
191+
else if (constraint is GuidRouteConstraint or StringRouteConstraint)
192+
{
193+
schema["type"] = "string";
194+
schema["format"] = constraint is GuidRouteConstraint ? "uuid" : null;
195+
}
196+
else if (constraint is BoolRouteConstraint)
197+
{
198+
schema["type"] = "boolean";
199+
schema["format"] = null;
200+
}
201+
else if (constraint is AlphaRouteConstraint)
202+
{
203+
schema["type"] = "string";
204+
schema["format"] = null;
205+
}
206+
else if (constraint is DateTimeRouteConstraint)
207+
{
208+
schema["type"] = "string";
209+
schema["format"] = "date-time";
210+
}
211+
}
212+
}
213+
214+
/// <summary>
215+
/// Applies parameter-specific customizations to the target schema.
216+
/// </summary>
217+
/// <param name="schema">The <see cref="JsonObject"/> produced by the underlying schema generator.</param>
218+
/// <param name="parameterDescription">The <see cref="ApiParameterDescription"/> associated with the <see paramref="schema"/>.</param>
219+
internal static void ApplyParameterInfo(this JsonObject schema, ApiParameterDescription parameterDescription)
220+
{
221+
// Route constraints are only defined on parameters that are sourced from the path. Since
222+
// they are encoded in the route template, and not in the type information based to the underlying
223+
// schema generator we have to handle them separately here.
224+
if (parameterDescription.RouteInfo?.Constraints is { } constraints)
225+
{
226+
schema.ApplyRouteConstraints(constraints);
227+
}
228+
// This is special handling for parameters that are not bound from the body but represented in a complex type.
229+
// For example:
230+
//
231+
// public class MyArgs
232+
// {
233+
// [Required]
234+
// [Range(1, 10)]
235+
// [FromQuery]
236+
// public string Name { get; set; }
237+
// }
238+
//
239+
// public IActionResult(MyArgs myArgs) { }
240+
//
241+
// In this case, the `ApiParameterDescription` object that we received will represent the `Name` property
242+
// based on our model binding heuristics. In that case, to access the validation attributes that the
243+
// model binder will respect we will need to get the property from the container type and map the
244+
// attributes on it to the schema.
245+
if (parameterDescription.ModelMetadata.PropertyName is { } propertyName)
246+
{
247+
var property = parameterDescription.ModelMetadata.ContainerType?.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
248+
if (property is not null)
249+
{
250+
var attributes = property.GetCustomAttributes(true).OfType<ValidationAttribute>();
251+
schema.ApplyValidationAttributes(attributes);
252+
}
253+
}
254+
}
255+
256+
/// <summary>
257+
/// Applies the polymorphism options to the target schema following OpenAPI v3's conventions.
258+
/// </summary>
259+
/// <param name="schema">The <see cref="JsonObject"/> produced by the underlying schema generator.</param>
260+
/// <param name="context">The <see cref="JsonSchemaGenerationContext"/> associated with the current type.</param>
261+
internal static void ApplyPolymorphismOptions(this JsonObject schema, JsonSchemaGenerationContext context)
262+
{
263+
if (context.TypeInfo.PolymorphismOptions is { } polymorphismOptions)
264+
{
265+
var mappings = new JsonObject();
266+
foreach (var derivedType in polymorphismOptions.DerivedTypes)
267+
{
268+
if (derivedType.TypeDiscriminator is null)
269+
{
270+
continue;
271+
}
272+
// TODO: Use the actual reference ID instead of the empty string.
273+
mappings[derivedType.TypeDiscriminator.ToString()!] = string.Empty;
274+
}
275+
schema["discriminatorPropertyName"] = polymorphismOptions.TypeDiscriminatorPropertyName;
276+
schema["discriminatorMappings"] = mappings;
277+
}
278+
}
279+
}

0 commit comments

Comments
 (0)