Skip to content

Commit 9ae6437

Browse files
committed
Support generating friendly reference names
1 parent d2c8817 commit 9ae6437

17 files changed

+539
-159
lines changed

src/OpenApi/src/Extensions/JsonObjectSchemaExtensions.cs

+14-3
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,15 @@ internal static void ApplyValidationAttributes(this JsonObject schema, IEnumerab
136136
/// opposed to after the generated schemas have been mapped to OpenAPI schemas.
137137
/// </remarks>
138138
/// <param name="schema">The <see cref="JsonObject"/> produced by the underlying schema generator.</param>
139-
/// <param name="type">The <see cref="Type"/> associated with the <see paramref="schema"/>.</param>
140-
internal static void ApplyPrimitiveTypesAndFormats(this JsonObject schema, Type type)
139+
/// <param name="context">The <see cref="JsonSchemaGenerationContext"/> associated with the <see paramref="schema"/>.</param>
140+
internal static void ApplyPrimitiveTypesAndFormats(this JsonObject schema, JsonSchemaGenerationContext context)
141141
{
142-
if (_simpleTypeToOpenApiSchema.TryGetValue(type, out var openApiSchema))
142+
if (_simpleTypeToOpenApiSchema.TryGetValue(context.TypeInfo.Type, out var openApiSchema))
143143
{
144144
schema[OpenApiSchemaKeywords.NullableKeyword] = openApiSchema.Nullable || (schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray schemaType && schemaType.GetValues<string>().Contains("null"));
145145
schema[OpenApiSchemaKeywords.TypeKeyword] = openApiSchema.Type;
146146
schema[OpenApiSchemaKeywords.FormatKeyword] = openApiSchema.Format;
147+
schema[OpenApiConstants.SchemaId] = context.TypeInfo.GetSchemaReferenceId();
147148
}
148149
}
149150

@@ -284,4 +285,14 @@ internal static void ApplyPolymorphismOptions(this JsonObject schema, JsonSchema
284285
schema[OpenApiSchemaKeywords.DiscriminatorMappingKeyword] = mappings;
285286
}
286287
}
288+
289+
/// <summary>
290+
/// Set the x-schema-id property on the schema to the identifier associated with the type.
291+
/// </summary>
292+
/// <param name="schema">The <see cref="JsonObject"/> produced by the underlying schema generator.</param>
293+
/// <param name="context">The <see cref="JsonSchemaGenerationContext"/> associated with the current type.</param>
294+
internal static void ApplySchemaReferenceId(this JsonObject schema, JsonSchemaGenerationContext context)
295+
{
296+
schema[OpenApiConstants.SchemaId] = context.TypeInfo.GetSchemaReferenceId();
297+
}
287298
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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.IO.Pipelines;
5+
using System.Linq;
6+
using System.Text.Json;
7+
using System.Text.Json.Serialization.Metadata;
8+
using Microsoft.AspNetCore.Http;
9+
10+
namespace Microsoft.AspNetCore.OpenApi;
11+
12+
internal static class JsonTypeInfoExtensions
13+
{
14+
private static readonly Dictionary<Type, string> _simpleTypeToName = new()
15+
{
16+
[typeof(bool)] = "boolean",
17+
[typeof(byte)] = "byte",
18+
[typeof(int)] = "int",
19+
[typeof(uint)] = "uint",
20+
[typeof(long)] = "long",
21+
[typeof(ulong)] = "ulong",
22+
[typeof(short)] = "short",
23+
[typeof(ushort)] = "ushort",
24+
[typeof(float)] = "float",
25+
[typeof(double)] = "double",
26+
[typeof(decimal)] = "decimal",
27+
[typeof(DateTime)] = "DateTime",
28+
[typeof(DateTimeOffset)] = "DateTimeOffset",
29+
[typeof(Guid)] = "Guid",
30+
[typeof(char)] = "char",
31+
[typeof(Uri)] = "Uri",
32+
[typeof(string)] = "string",
33+
[typeof(IFormFile)] = "IFormFile",
34+
[typeof(IFormFileCollection)] = "IFormFileCollection",
35+
[typeof(PipeReader)] = "PipeReader",
36+
[typeof(Stream)] = "Stream"
37+
};
38+
39+
/// <summary>
40+
/// The following method maps a JSON type to a schema reference ID that will eventually be used as the
41+
/// schema reference name in the OpenAPI document. These schema reference names are considered URL fragments
42+
/// in the context of JSON Schema's $ref keyword and must comply with the character restrictions of URL fragments.
43+
/// In particular, the generated strings can contain alphanumeric characters and a subset of special symbols. This
44+
/// means that certain symbols that appear commonly in .NET type names like ">" are not permitted in the
45+
/// generated reference ID.
46+
/// </summary>
47+
/// <param name="jsonTypeInfo">The <see cref="JsonTypeInfo"/> associated with the target schema.</param>
48+
/// <returns>The schema reference ID represented as a string name.</returns>
49+
internal static string GetSchemaReferenceId(this JsonTypeInfo jsonTypeInfo)
50+
{
51+
var type = jsonTypeInfo.Type;
52+
// Short-hand if the type we're generating a schema reference ID for is
53+
// one of the simple types defined above.
54+
if (_simpleTypeToName.TryGetValue(type, out var simpleName))
55+
{
56+
return simpleName;
57+
}
58+
59+
if (jsonTypeInfo is JsonTypeInfo { Kind: JsonTypeInfoKind.Enumerable, ElementType: { } elementType })
60+
{
61+
var elementTypeInfo = jsonTypeInfo.Options.GetTypeInfo(elementType);
62+
return $"ArrayOf{elementTypeInfo.GetSchemaReferenceId()}";
63+
}
64+
65+
if (jsonTypeInfo is JsonTypeInfo { Kind: JsonTypeInfoKind.Dictionary, KeyType: { } keyType, ElementType: { } valueType })
66+
{
67+
var keyTypeInfo = jsonTypeInfo.Options.GetTypeInfo(keyType);
68+
var valueTypeInfo = jsonTypeInfo.Options.GetTypeInfo(valueType);
69+
return $"DictionaryOf{keyTypeInfo.GetSchemaReferenceId()}And{valueTypeInfo.GetSchemaReferenceId()}";
70+
}
71+
72+
return type.GetSchemaReferenceId(jsonTypeInfo.Options);
73+
}
74+
75+
internal static string GetSchemaReferenceId(this Type type, JsonSerializerOptions options)
76+
{
77+
// Check the simple types map first to account for the element types
78+
// of enumerables that have been processed above.
79+
if (_simpleTypeToName.TryGetValue(type, out var simpleName))
80+
{
81+
return simpleName;
82+
}
83+
84+
// Although arrays are enumerable types they are not encoded correctly
85+
// with JsonTypeInfoKind.Enumerable so we handle that here
86+
if (type.IsArray && type.GetElementType() is { } elementType)
87+
{
88+
var elementTypeInfo = options.GetTypeInfo(elementType);
89+
return $"ArrayOf{elementTypeInfo.GetSchemaReferenceId()}";
90+
}
91+
92+
// Special handling for anonymous types
93+
if (type.Name.StartsWith("<>f", StringComparison.Ordinal))
94+
{
95+
var typeName = "AnonymousType";
96+
var anonymousTypeProperties = type.GetGenericArguments();
97+
var propertyNames = string.Join("And", anonymousTypeProperties.Select(p => p.GetSchemaReferenceId(options)));
98+
return $"{typeName}Of{propertyNames}";
99+
}
100+
101+
if (type.IsGenericType)
102+
{
103+
// Nullable types are suffixed with `?` (e.g. `Todo?`)
104+
if (type.GetGenericTypeDefinition() == typeof(Nullable<>)
105+
&& Nullable.GetUnderlyingType(type) is { } underlyingType)
106+
{
107+
return $"{underlyingType.GetSchemaReferenceId(options)}?";
108+
}
109+
// Special handling for generic types that are collections
110+
// Generic types become a concatenation of the generic type name and the type arguments
111+
else
112+
{
113+
var genericTypeName = type.Name[..type.Name.LastIndexOf('`')];
114+
var genericArguments = type.GetGenericArguments();
115+
var argumentNames = string.Join("And", genericArguments.Select(arg => arg.GetSchemaReferenceId(options)));
116+
return $"{genericTypeName}Of{argumentNames}";
117+
}
118+
}
119+
return type.Name;
120+
}
121+
}

src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs

+5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Microsoft.AspNetCore.OpenApi;
88
using Microsoft.OpenApi.Any;
99
using Microsoft.OpenApi.Models;
10+
using OpenApiConstants = Microsoft.AspNetCore.OpenApi.OpenApiConstants;
1011

1112
internal sealed partial class OpenApiJsonSchema
1213
{
@@ -289,6 +290,10 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName,
289290
var mappings = ReadDictionary<string>(ref reader);
290291
schema.Discriminator.Mapping = mappings;
291292
break;
293+
case OpenApiConstants.SchemaId:
294+
reader.Read();
295+
schema.Extensions.Add(OpenApiConstants.SchemaId, new OpenApiString(reader.GetString()));
296+
break;
292297
default:
293298
reader.Skip();
294299
break;

src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs

+1
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ internal class OpenApiSchemaKeywords
2424
public const string MinItemsKeyword = "minItems";
2525
public const string MaxItemsKeyword = "maxItems";
2626
public const string RefKeyword = "$ref";
27+
public const string SchemaIdKeyword = "x-schema-id";
2728
}

src/OpenApi/src/Services/OpenApiConstants.cs

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ internal static class OpenApiConstants
1111
internal const string DefaultOpenApiVersion = "1.0.0";
1212
internal const string DefaultOpenApiRoute = "/openapi/{documentName}.json";
1313
internal const string DescriptionId = "x-aspnetcore-id";
14+
internal const string SchemaId = "x-schema-id";
1415
internal const string DefaultOpenApiResponseKey = "default";
1516
// Since there's a finite set of operation types that can be included in a given
1617
// OpenApiPaths, we can pre-allocate an array of these types and use a direct

src/OpenApi/src/Services/OpenApiDocumentService.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ private async Task ApplyTransformersAsync(OpenApiDocument document, Cancellation
7777
var transformer = _options.DocumentTransformers[i];
7878
await transformer.TransformAsync(document, documentTransformerContext, cancellationToken);
7979
}
80-
// Remove `x-aspnetcore-id` extension from operations after all transformers have run.
81-
await _scrubExtensionsTransformer.TransformAsync(document, documentTransformerContext, cancellationToken);
8280
// Move duplicated JSON schemas to the global components.schemas object and map references after all transformers have run.
8381
await _schemaReferenceTransformer.TransformAsync(document, documentTransformerContext, cancellationToken);
82+
// Remove `x-aspnetcore-id` and `x-schema-id` extensions from operations after all transformers have run.
83+
await _scrubExtensionsTransformer.TransformAsync(document, documentTransformerContext, cancellationToken);
8484
}
8585

8686
// Note: Internal for testing.

src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ internal sealed class OpenApiSchemaService(
5454
[OpenApiSchemaKeywords.FormatKeyword] = "binary"
5555
};
5656
}
57-
schema.ApplyPrimitiveTypesAndFormats(type);
57+
schema.ApplyPrimitiveTypesAndFormats(context);
58+
schema.ApplySchemaReferenceId(context);
5859
if (context.GetCustomAttributes(typeof(ValidationAttribute)) is { } validationAttributes)
5960
{
6061
schema.ApplyValidationAttributes(validationAttributes);

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

+52-12
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.IO.Pipelines;
66
using System.Text.Json.Nodes;
77
using Microsoft.AspNetCore.Http;
8+
using Microsoft.OpenApi.Any;
89
using Microsoft.OpenApi.Models;
910

1011
namespace Microsoft.AspNetCore.OpenApi;
@@ -18,14 +19,35 @@ internal sealed class OpenApiSchemaStore
1819
private readonly ConcurrentDictionary<OpenApiSchemaKey, JsonObject> _schemas = new()
1920
{
2021
// Pre-populate OpenAPI schemas for well-defined types in ASP.NET Core.
21-
[new OpenApiSchemaKey(typeof(IFormFile), null)] = new JsonObject { ["type"] = "string", ["format"] = "binary" },
22+
[new OpenApiSchemaKey(typeof(IFormFile), null)] = new JsonObject
23+
{
24+
["type"] = "string",
25+
["format"] = "binary",
26+
[OpenApiConstants.SchemaId] = "IFormFile"
27+
},
2228
[new OpenApiSchemaKey(typeof(IFormFileCollection), null)] = new JsonObject
2329
{
2430
["type"] = "array",
25-
["items"] = new JsonObject { ["type"] = "string", ["format"] = "binary" }
31+
["items"] = new JsonObject
32+
{
33+
["type"] = "string",
34+
["format"] = "binary",
35+
[OpenApiConstants.SchemaId] = "IFormFile"
36+
},
37+
[OpenApiConstants.SchemaId] = "IFormFileCollection"
38+
},
39+
[new OpenApiSchemaKey(typeof(Stream), null)] = new JsonObject
40+
{
41+
["type"] = "string",
42+
["format"] = "binary",
43+
[OpenApiConstants.SchemaId] = "Stream"
44+
},
45+
[new OpenApiSchemaKey(typeof(PipeReader), null)] = new JsonObject
46+
{
47+
["type"] = "string",
48+
["format"] = "binary",
49+
[OpenApiConstants.SchemaId] = "PipeReader"
2650
},
27-
[new OpenApiSchemaKey(typeof(Stream), null)] = new JsonObject { ["type"] = "string", ["format"] = "binary" },
28-
[new OpenApiSchemaKey(typeof(PipeReader), null)] = new JsonObject { ["type"] = "string", ["format"] = "binary" },
2951
};
3052

3153
private readonly ConcurrentDictionary<OpenApiSchema, string?> _schemasWithReference = new(OpenApiSchemaComparer.Instance);
@@ -46,37 +68,55 @@ public JsonObject GetOrAdd(OpenApiSchemaKey key, Func<OpenApiSchemaKey, JsonObje
4668
/// used to populate the top-level components.schemas object. This method will
4769
/// unwrap the provided schema and add any child schemas to the global cache. Child
4870
/// schemas include those referenced in the schema.Items, schema.AdditionalProperties, or
49-
/// schema.Properties collections. A unique identifier is generated for each schema
50-
/// on the update step to avoid populating the cache with schemas that only appear once in
51-
/// the document.
71+
/// schema.Properties collections. Schema reference IDs are only set for schemas that have
72+
/// been encountered more than once in the document to avoid unnecessarily capturing unique
73+
/// schemas into the top-level document.
5274
/// </summary>
5375
/// <param name="schema">The <see cref="OpenApiSchema"/> to add to the schemas-with-references cache.</param>
5476
public void PopulateSchemaIntoReferenceCache(OpenApiSchema schema)
5577
{
56-
_schemasWithReference.AddOrUpdate(schema, (_) => null, (_, _) => Guid.NewGuid().ToString());
78+
_schemasWithReference.AddOrUpdate(schema, (_) => null, (schema, _) => GetSchemaReferenceId(schema));
5779
if (schema.AdditionalProperties is not null)
5880
{
59-
_schemasWithReference.AddOrUpdate(schema.AdditionalProperties, (_) => null, (_, _) => Guid.NewGuid().ToString());
81+
_schemasWithReference.AddOrUpdate(schema.AdditionalProperties, (_) => null, (schema, _) => GetSchemaReferenceId(schema));
6082
}
6183
if (schema.Items is not null)
6284
{
63-
_schemasWithReference.AddOrUpdate(schema.Items, (_) => null, (_, _) => Guid.NewGuid().ToString());
85+
_schemasWithReference.AddOrUpdate(schema.Items, (_) => null, (schema, _) => GetSchemaReferenceId(schema));
6486
}
6587
if (schema.AllOf is not null)
6688
{
6789
foreach (var allOfSchema in schema.AllOf)
6890
{
69-
_schemasWithReference.AddOrUpdate(allOfSchema, (_) => null, (_, _) => Guid.NewGuid().ToString());
91+
_schemasWithReference.AddOrUpdate(allOfSchema, (_) => null, (schema, _) => GetSchemaReferenceId(schema));
92+
}
93+
}
94+
if (schema.AnyOf is not null)
95+
{
96+
foreach (var anyOfSchema in schema.AnyOf)
97+
{
98+
_schemasWithReference.AddOrUpdate(anyOfSchema, (_) => null, (schema, _) => GetSchemaReferenceId(schema));
7099
}
71100
}
72101
if (schema.Properties is not null)
73102
{
74103
foreach (var property in schema.Properties.Values)
75104
{
76-
_schemasWithReference.AddOrUpdate(property, (_) => null, (_, _) => Guid.NewGuid().ToString());
105+
_schemasWithReference.AddOrUpdate(property, (_) => null, (schema, _) => GetSchemaReferenceId(schema));
77106
}
78107
}
79108
}
80109

110+
private static string GetSchemaReferenceId(OpenApiSchema schema)
111+
{
112+
if (schema.Extensions.TryGetValue(OpenApiConstants.SchemaId, out var referenceIdAny)
113+
&& referenceIdAny is OpenApiString { Value: string referenceId })
114+
{
115+
return referenceId;
116+
}
117+
118+
throw new InvalidOperationException("The schema reference ID must be set on the schema.");
119+
}
120+
81121
public ConcurrentDictionary<OpenApiSchema, string?> SchemasByReference => _schemasWithReference;
82122
}

0 commit comments

Comments
 (0)