Skip to content

Commit 5b44e5e

Browse files
committed
Add schema spec tests, move controller tests, move dedupe logic
1 parent 9086b3e commit 5b44e5e

14 files changed

+443
-452
lines changed

src/OpenApi/sample/Controllers/TestController.cs

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

88
[ApiController]
99
[Route("[controller]")]
10+
[ApiExplorerSettings(GroupName = "controllers")]
1011
public class TestController : ControllerBase
1112
{
1213
[HttpGet]

src/OpenApi/sample/Program.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
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.ComponentModel;
5+
using Microsoft.AspNetCore.Http.HttpResults;
46
using Microsoft.AspNetCore.Mvc;
57
using Microsoft.OpenApi.Models;
68
using Sample.Transformers;
@@ -24,8 +26,10 @@
2426
return Task.CompletedTask;
2527
});
2628
});
29+
builder.Services.AddOpenApi("controllers");
2730
builder.Services.AddOpenApi("responses");
2831
builder.Services.AddOpenApi("forms");
32+
builder.Services.AddOpenApi("schemas-by-ref");
2933

3034
var app = builder.Build();
3135

@@ -38,6 +42,9 @@
3842
var forms = app.MapGroup("forms")
3943
.WithGroupName("forms");
4044

45+
var schemas = app.MapGroup("schemas-by-ref")
46+
.WithGroupName("schemas-by-ref");
47+
4148
if (app.Environment.IsDevelopment())
4249
{
4350
forms.DisableAntiforgery();
@@ -84,6 +91,17 @@
8491
responses.MapGet("/triangle", () => new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 });
8592
responses.MapGet("/shape", () => new Shape { Color = "blue", Sides = 4 });
8693

94+
schemas.MapGet("/typed-results", () => TypedResults.Ok(new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 }));
95+
schemas.MapGet("/multiple-results", Results<Ok<Triangle>, NotFound<string>> () => Random.Shared.Next(0, 2) == 0
96+
? TypedResults.Ok(new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 })
97+
: TypedResults.NotFound<string>("Item not found."));
98+
schemas.MapGet("/iresult-no-produces", () => Results.Ok(new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 }));
99+
schemas.MapGet("/iresult-with-produces", () => Results.Ok(new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 }))
100+
.Produces<Triangle>(200, "text/xml");
101+
schemas.MapGet("/primitives", ([Description("The ID associated with the Todo item.")] int id, [Description("The number of Todos to fetch")] int size) => { });
102+
schemas.MapGet("/product", (Product product) => TypedResults.Ok(product));
103+
schemas.MapGet("/account", (Account account) => TypedResults.Ok(account));
104+
87105
app.MapControllers();
88106

89107
app.Run();

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
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;
54
using System.ComponentModel.DataAnnotations;
65
using System.Diagnostics;
76
using System.Diagnostics.CodeAnalysis;
@@ -44,7 +43,7 @@ internal sealed class OpenApiDocumentService(
4443
/// are unique within the lifetime of an application and serve as helpful associators between
4544
/// operations, API descriptions, and their respective transformer contexts.
4645
/// </summary>
47-
private readonly ConcurrentDictionary<string, OpenApiOperationTransformerContext> _operationTransformerContextCache = new();
46+
private readonly Dictionary<string, OpenApiOperationTransformerContext> _operationTransformerContextCache = new();
4847
private static readonly ApiResponseType _defaultApiResponseType = new ApiResponseType { StatusCode = StatusCodes.Status200OK };
4948

5049
internal bool TryGetCachedOperationTransformerContext(string descriptionId, [NotNullWhen(true)] out OpenApiOperationTransformerContext? context)

src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
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;
54
using System.IO.Pipelines;
65
using System.Text.Json.Nodes;
76
using Microsoft.AspNetCore.Http;
@@ -16,7 +15,7 @@ namespace Microsoft.AspNetCore.OpenApi;
1615
/// </summary>
1716
internal sealed class OpenApiSchemaStore
1817
{
19-
private readonly ConcurrentDictionary<OpenApiSchemaKey, JsonObject> _schemas = new()
18+
private readonly Dictionary<OpenApiSchemaKey, JsonObject> _schemas = new()
2019
{
2120
// Pre-populate OpenAPI schemas for well-defined types in ASP.NET Core.
2221
[new OpenApiSchemaKey(typeof(IFormFile), null)] = new JsonObject
@@ -50,7 +49,8 @@ internal sealed class OpenApiSchemaStore
5049
},
5150
};
5251

53-
private readonly ConcurrentDictionary<OpenApiSchema, string?> _schemasWithReference = new(OpenApiSchemaComparer.Instance);
52+
private readonly Dictionary<OpenApiSchema, string?> _schemasWithReference = new(OpenApiSchemaComparer.Instance);
53+
private readonly Dictionary<string, int> _referenceIdCounter = new();
5454

5555
/// <summary>
5656
/// Resolves the JSON schema for the given type and parameter description.
@@ -60,7 +60,13 @@ internal sealed class OpenApiSchemaStore
6060
/// <returns>A <see cref="JsonObject" /> representing the JSON schema associated with the key.</returns>
6161
public JsonObject GetOrAdd(OpenApiSchemaKey key, Func<OpenApiSchemaKey, JsonObject> valueFactory)
6262
{
63-
return _schemas.GetOrAdd(key, valueFactory);
63+
if (_schemas.TryGetValue(key, out var schema))
64+
{
65+
return schema;
66+
}
67+
var targetSchema = valueFactory(key);
68+
_schemas.Add(key, targetSchema);
69+
return targetSchema;
6470
}
6571

6672
/// <summary>
@@ -75,38 +81,91 @@ public JsonObject GetOrAdd(OpenApiSchemaKey key, Func<OpenApiSchemaKey, JsonObje
7581
/// <param name="schema">The <see cref="OpenApiSchema"/> to add to the schemas-with-references cache.</param>
7682
public void PopulateSchemaIntoReferenceCache(OpenApiSchema schema)
7783
{
78-
_schemasWithReference.AddOrUpdate(schema, (_) => null, (schema, _) => GetSchemaReferenceId(schema));
84+
AddOrUpdateSchemaByReference(schema);
7985
if (schema.AdditionalProperties is not null)
8086
{
81-
_schemasWithReference.AddOrUpdate(schema.AdditionalProperties, (_) => null, (schema, _) => GetSchemaReferenceId(schema));
87+
AddOrUpdateSchemaByReference(schema.AdditionalProperties);
8288
}
8389
if (schema.Items is not null)
8490
{
85-
_schemasWithReference.AddOrUpdate(schema.Items, (_) => null, (schema, _) => GetSchemaReferenceId(schema));
91+
AddOrUpdateSchemaByReference(schema.Items);
8692
}
8793
if (schema.AllOf is not null)
8894
{
8995
foreach (var allOfSchema in schema.AllOf)
9096
{
91-
_schemasWithReference.AddOrUpdate(allOfSchema, (_) => null, (schema, _) => GetSchemaReferenceId(schema));
97+
AddOrUpdateSchemaByReference(allOfSchema);
9298
}
9399
}
94100
if (schema.AnyOf is not null)
95101
{
96102
foreach (var anyOfSchema in schema.AnyOf)
97103
{
98-
_schemasWithReference.AddOrUpdate(anyOfSchema, (_) => null, (schema, _) => GetSchemaReferenceId(schema));
104+
AddOrUpdateSchemaByReference(anyOfSchema);
99105
}
100106
}
101107
if (schema.Properties is not null)
102108
{
103109
foreach (var property in schema.Properties.Values)
104110
{
105-
_schemasWithReference.AddOrUpdate(property, (_) => null, (schema, _) => GetSchemaReferenceId(schema));
111+
AddOrUpdateSchemaByReference(property);
106112
}
107113
}
108114
}
109115

116+
private void AddOrUpdateSchemaByReference(OpenApiSchema schema)
117+
{
118+
if (_schemasWithReference.TryGetValue(schema, out var referenceId))
119+
{
120+
// If we've already used this reference ID else where in the document, increment a counter value to the reference
121+
// ID to avoid name collisions. These collisions are most likely to occur when the same .NET type produces a different
122+
// schema in the OpenAPI document because of special annotations provided on it. For example, in the two type definitions
123+
// below:
124+
// public class Todo
125+
// {
126+
// public int Id { get; set; }
127+
// public string Name { get; set; }
128+
// }
129+
// public class Project
130+
// {
131+
// public int Id { get; set; }
132+
// [MinLength(5)]
133+
// public string Title { get; set; }
134+
// }
135+
// The `Title` and `Name` properties are both strings but the `Title` property has a `minLength` annotation
136+
// on it that will materialize into a different schema.
137+
// {
138+
//
139+
// "type": "string",
140+
// "minLength": 5
141+
// }
142+
// {
143+
// "type": "string"
144+
// }
145+
// In this case, although the reference ID based on the .NET type we would use is `string`, the
146+
// two schemas are distinct.
147+
if (referenceId == null)
148+
{
149+
var targetReferenceId = GetSchemaReferenceId(schema);
150+
if (_referenceIdCounter.TryGetValue(targetReferenceId, out var counter))
151+
{
152+
counter++;
153+
_referenceIdCounter[targetReferenceId] = counter;
154+
_schemasWithReference[schema] = $"{targetReferenceId}{counter}";
155+
}
156+
else
157+
{
158+
_referenceIdCounter[targetReferenceId] = 1;
159+
_schemasWithReference[schema] = targetReferenceId;
160+
}
161+
}
162+
}
163+
else
164+
{
165+
_schemasWithReference[schema] = null;
166+
}
167+
}
168+
110169
private static string GetSchemaReferenceId(OpenApiSchema schema)
111170
{
112171
if (schema.Extensions.TryGetValue(OpenApiConstants.SchemaId, out var referenceIdAny)
@@ -118,5 +177,5 @@ private static string GetSchemaReferenceId(OpenApiSchema schema)
118177
throw new InvalidOperationException("The schema reference ID must be set on the schema.");
119178
}
120179

121-
public ConcurrentDictionary<OpenApiSchema, string?> SchemasByReference => _schemasWithReference;
180+
public Dictionary<OpenApiSchema, string?> SchemasByReference => _schemasWithReference;
122181
}

src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs

Lines changed: 4 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
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;
54
using System.Linq;
65
using Microsoft.Extensions.DependencyInjection;
76
using Microsoft.OpenApi.Models;
@@ -14,8 +13,6 @@ namespace Microsoft.AspNetCore.OpenApi;
1413
/// </summary>
1514
internal sealed class OpenApiSchemaReferenceTransformer : IOpenApiDocumentTransformer
1615
{
17-
private readonly Dictionary<string, int> _referenceIdCounter = new();
18-
1916
public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
2017
{
2118
var schemaStore = context.ApplicationServices.GetRequiredKeyedService<OpenApiSchemaStore>(context.DocumentName);
@@ -33,46 +30,9 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC
3330
// Note: we create a copy of the schema here to avoid modifying the original schema
3431
// so that comparisons between the original schema and the resolved schema during
3532
// the transformation process are consistent.
36-
var resolvedSchema = ResolveReferenceForSchema(new OpenApiSchema(schema), schemasByReference, skipResolution: true);
37-
// If we've already used this reference ID else where in the document, increment a counter value to the reference
38-
// ID to avoid name collisions. These collisions are most likely to occur when the same .NET type produces a different
39-
// schema in the OpenAPI document because of special annotations provided on it. For example, in the two type definitions
40-
// below:
41-
// public class Todo
42-
// {
43-
// public int Id { get; set; }
44-
// public string Name { get; set; }
45-
// }
46-
// public class Project
47-
// {
48-
// public int Id { get; set; }
49-
// [MinLength(5)]
50-
// public string Title { get; set; }
51-
// }
52-
// The `Title` and `Name` properties are both strings but the `Title` property has a `minLength` annotation
53-
// on it that will materialize into a different schema.
54-
// {
55-
//
56-
// "type": "string",
57-
// "minLength": 5
58-
// }
59-
// {
60-
// "type": "string"
61-
// }
62-
// In this case, although the reference ID based on the .NET type we would use is `string`, the
63-
// two schemas are distinct.
64-
if (!document.Components.Schemas.TryAdd(referenceId, resolvedSchema))
65-
{
66-
var counter = _referenceIdCounter[referenceId];
67-
_referenceIdCounter[referenceId] += 1;
68-
document.Components.Schemas.Add($"{referenceId}{counter}", resolvedSchema);
69-
schemasByReference[schema] = $"{referenceId}{counter}";
70-
}
71-
else
72-
{
73-
_referenceIdCounter[referenceId] = 1;
74-
75-
}
33+
document.Components.Schemas.Add(
34+
referenceId,
35+
ResolveReferenceForSchema(new OpenApiSchema(schema), schemasByReference, skipResolution: true));
7636
}
7737
}
7838

@@ -125,7 +85,7 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC
12585
/// <param name="schema">The inline schema to replace with a reference.</param>
12686
/// <param name="schemasByReference">A cache of schemas and their associated reference IDs.</param>
12787
/// <param name="skipResolution">When <see langword="true" />, will skip resolving references for the top-most schema provided.</param>
128-
internal static OpenApiSchema? ResolveReferenceForSchema(OpenApiSchema? schema, ConcurrentDictionary<OpenApiSchema, string?> schemasByReference, bool skipResolution = false)
88+
internal static OpenApiSchema? ResolveReferenceForSchema(OpenApiSchema? schema, Dictionary<OpenApiSchema, string?> schemasByReference, bool skipResolution = false)
12989
{
13090
if (schema is null)
13191
{

src/OpenApi/test/Extensions/JsonTypeInfoExtensionsTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ private class Container
5757
[typeof(ProblemDetails), "ProblemDetails"],
5858
[typeof(Dictionary<string, string[]>), "DictionaryOfstringAndArrayOfstring"],
5959
[typeof(Dictionary<string, List<string[]>>), "DictionaryOfstringAndArrayOfArrayOfstring"],
60+
[typeof(Dictionary<string, IEnumerable<string[]>>), "DictionaryOfstringAndArrayOfArrayOfstring"],
6061
];
6162

6263
[Theory]

src/OpenApi/test/Integration/OpenApiDocumentIntegrationTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ public sealed class OpenApiDocumentIntegrationTests(SampleAppFixture fixture) :
1414
[Theory]
1515
[InlineData("v1")]
1616
[InlineData("v2")]
17+
[InlineData("controllers")]
1718
[InlineData("responses")]
1819
[InlineData("forms")]
20+
[InlineData("schemas-by-ref")]
1921
public async Task VerifyOpenApiDocument(string documentName)
2022
{
2123
var documentService = fixture.Services.GetRequiredKeyedService<OpenApiDocumentService>(documentName);

0 commit comments

Comments
 (0)