Skip to content

Commit 4c8b5fe

Browse files
authored
Fix up nullability and primitive type handing in schema generation (#56372)
* Fix up nullability and primitive type handing in schema generation * Guard against unsupported NIC and fix object schemas * Address feedback * Move primitive types to constants
1 parent ee4a0e9 commit 4c8b5fe

12 files changed

+188
-165
lines changed

src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs

+11-13
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@ namespace Microsoft.AspNetCore.OpenApi;
2424
/// </summary>
2525
internal static class JsonNodeSchemaExtensions
2626
{
27-
private static readonly NullabilityInfoContext _nullabilityInfoContext = new();
28-
2927
private static readonly Dictionary<Type, OpenApiSchema> _simpleTypeToOpenApiSchema = new()
3028
{
3129
[typeof(bool)] = new() { Type = "boolean" },
@@ -350,7 +348,10 @@ internal static void ApplyPolymorphismOptions(this JsonNode schema, JsonSchemaEx
350348
/// <param name="context">The <see cref="JsonSchemaExporterContext"/> associated with the current type.</param>
351349
internal static void ApplySchemaReferenceId(this JsonNode schema, JsonSchemaExporterContext context)
352350
{
353-
schema[OpenApiConstants.SchemaId] = context.TypeInfo.GetSchemaReferenceId();
351+
if (context.TypeInfo.GetSchemaReferenceId() is { } schemaReferenceId)
352+
{
353+
schema[OpenApiConstants.SchemaId] = schemaReferenceId;
354+
}
354355
}
355356

356357
/// <summary>
@@ -365,7 +366,8 @@ internal static void ApplyNullabilityContextInfo(this JsonNode schema, Parameter
365366
return;
366367
}
367368

368-
var nullabilityInfo = _nullabilityInfoContext.Create(parameterInfo);
369+
var nullabilityInfoContext = new NullabilityInfoContext();
370+
var nullabilityInfo = nullabilityInfoContext.Create(parameterInfo);
369371
if (nullabilityInfo.WriteState == NullabilityState.Nullable)
370372
{
371373
schema[OpenApiSchemaKeywords.NullableKeyword] = true;
@@ -376,16 +378,12 @@ internal static void ApplyNullabilityContextInfo(this JsonNode schema, Parameter
376378
/// Support applying nullability status for reference types provided as a property or field.
377379
/// </summary>
378380
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
379-
/// <param name="attributeProvider">The <see cref="PropertyInfo" /> or <see cref="FieldInfo"/> associated with the schema.</param>
380-
internal static void ApplyNullabilityContextInfo(this JsonNode schema, ICustomAttributeProvider attributeProvider)
381+
/// <param name="propertyInfo">The <see cref="JsonPropertyInfo" /> associated with the schema.</param>
382+
internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPropertyInfo propertyInfo)
381383
{
382-
var nullabilityInfo = attributeProvider switch
383-
{
384-
PropertyInfo propertyInfo => !propertyInfo.PropertyType.IsValueType ? _nullabilityInfoContext.Create(propertyInfo) : null,
385-
FieldInfo fieldInfo => !fieldInfo.FieldType.IsValueType ? _nullabilityInfoContext.Create(fieldInfo) : null,
386-
_ => null
387-
};
388-
if (nullabilityInfo is { WriteState: NullabilityState.Nullable } or { ReadState: NullabilityState.Nullable })
384+
// Avoid setting explicit nullability annotations for `object` types so they continue to match on the catch
385+
// all schema (no type, no format, no constraints).
386+
if (propertyInfo.PropertyType != typeof(object) && (propertyInfo.IsGetNullable || propertyInfo.IsSetNullable))
389387
{
390388
schema[OpenApiSchemaKeywords.NullableKeyword] = true;
391389
}

src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs

+19-19
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,19 @@ internal static class JsonTypeInfoExtensions
4545
/// generated reference ID.
4646
/// </summary>
4747
/// <param name="jsonTypeInfo">The <see cref="JsonTypeInfo"/> associated with the target schema.</param>
48+
/// <param name="isTopLevel">
49+
/// When <see langword="false" />, returns schema name for primitive
50+
/// types to support use in list/dictionary types.
51+
/// </param>
4852
/// <returns>The schema reference ID represented as a string name.</returns>
49-
internal static string GetSchemaReferenceId(this JsonTypeInfo jsonTypeInfo)
53+
internal static string? GetSchemaReferenceId(this JsonTypeInfo jsonTypeInfo, bool isTopLevel = true)
5054
{
5155
var type = jsonTypeInfo.Type;
56+
if (isTopLevel && OpenApiConstants.PrimitiveTypes.Contains(type))
57+
{
58+
return null;
59+
}
60+
5261
// Short-hand if the type we're generating a schema reference ID for is
5362
// one of the simple types defined above.
5463
if (_simpleTypeToName.TryGetValue(type, out var simpleName))
@@ -59,14 +68,14 @@ internal static string GetSchemaReferenceId(this JsonTypeInfo jsonTypeInfo)
5968
if (jsonTypeInfo is JsonTypeInfo { Kind: JsonTypeInfoKind.Enumerable, ElementType: { } elementType })
6069
{
6170
var elementTypeInfo = jsonTypeInfo.Options.GetTypeInfo(elementType);
62-
return $"ArrayOf{elementTypeInfo.GetSchemaReferenceId()}";
71+
return $"ArrayOf{elementTypeInfo.GetSchemaReferenceId(isTopLevel: false)}";
6372
}
6473

6574
if (jsonTypeInfo is JsonTypeInfo { Kind: JsonTypeInfoKind.Dictionary, KeyType: { } keyType, ElementType: { } valueType })
6675
{
6776
var keyTypeInfo = jsonTypeInfo.Options.GetTypeInfo(keyType);
6877
var valueTypeInfo = jsonTypeInfo.Options.GetTypeInfo(valueType);
69-
return $"DictionaryOf{keyTypeInfo.GetSchemaReferenceId()}And{valueTypeInfo.GetSchemaReferenceId()}";
78+
return $"DictionaryOf{keyTypeInfo.GetSchemaReferenceId(isTopLevel: false)}And{valueTypeInfo.GetSchemaReferenceId(isTopLevel: false)}";
7079
}
7180

7281
return type.GetSchemaReferenceId(jsonTypeInfo.Options);
@@ -86,7 +95,7 @@ internal static string GetSchemaReferenceId(this Type type, JsonSerializerOption
8695
if (type.IsArray && type.GetElementType() is { } elementType)
8796
{
8897
var elementTypeInfo = options.GetTypeInfo(elementType);
89-
return $"ArrayOf{elementTypeInfo.GetSchemaReferenceId()}";
98+
return $"ArrayOf{elementTypeInfo.GetSchemaReferenceId(isTopLevel: false)}";
9099
}
91100

92101
// Special handling for anonymous types
@@ -98,23 +107,14 @@ internal static string GetSchemaReferenceId(this Type type, JsonSerializerOption
98107
return $"{typeName}Of{propertyNames}";
99108
}
100109

110+
// Special handling for generic types that are collections
111+
// Generic types become a concatenation of the generic type name and the type arguments
101112
if (type.IsGenericType)
102113
{
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-
}
114+
var genericTypeName = type.Name[..type.Name.LastIndexOf('`')];
115+
var genericArguments = type.GetGenericArguments();
116+
var argumentNames = string.Join("And", genericArguments.Select(arg => arg.GetSchemaReferenceId(options)));
117+
return $"{genericTypeName}Of{argumentNames}";
118118
}
119119
return type.Name;
120120
}

src/OpenApi/src/Services/OpenApiConstants.cs

+35
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,39 @@ internal static class OpenApiConstants
2727
OperationType.Patch,
2828
OperationType.Trace
2929
];
30+
// Represents primitive types that should never be represented as
31+
// a schema reference and always inlined.
32+
internal static readonly List<Type> PrimitiveTypes =
33+
[
34+
typeof(bool),
35+
typeof(byte),
36+
typeof(sbyte),
37+
typeof(byte[]),
38+
typeof(string),
39+
typeof(int),
40+
typeof(uint),
41+
typeof(nint),
42+
typeof(nuint),
43+
typeof(Int128),
44+
typeof(UInt128),
45+
typeof(long),
46+
typeof(ulong),
47+
typeof(float),
48+
typeof(double),
49+
typeof(decimal),
50+
typeof(Half),
51+
typeof(ulong),
52+
typeof(short),
53+
typeof(ushort),
54+
typeof(char),
55+
typeof(object),
56+
typeof(DateTime),
57+
typeof(DateTimeOffset),
58+
typeof(TimeOnly),
59+
typeof(DateOnly),
60+
typeof(TimeSpan),
61+
typeof(Guid),
62+
typeof(Uri),
63+
typeof(Version)
64+
];
3065
}

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

+9-2
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,18 @@ internal sealed class OpenApiSchemaService(
8181
}
8282
};
8383
}
84+
// STJ uses `true` in place of an empty object to represent a schema that matches
85+
// anything. We override this default behavior here to match the style traditionally
86+
// expected in OpenAPI documents.
87+
if (type == typeof(object))
88+
{
89+
schema = new JsonObject();
90+
}
8491
schema.ApplyPrimitiveTypesAndFormats(context);
8592
schema.ApplySchemaReferenceId(context);
86-
if (context.PropertyInfo is { AttributeProvider: { } attributeProvider })
93+
if (context.PropertyInfo is { AttributeProvider: { } attributeProvider } jsonPropertyInfo)
8794
{
88-
schema.ApplyNullabilityContextInfo(attributeProvider);
95+
schema.ApplyNullabilityContextInfo(jsonPropertyInfo);
8996
if (attributeProvider.GetCustomAttributes(inherit: false).OfType<ValidationAttribute>() is { } validationAttributes)
9097
{
9198
schema.ApplyValidationAttributes(validationAttributes);

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

+3-4
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,8 @@ private void AddOrUpdateSchemaByReference(OpenApiSchema schema)
144144
// }
145145
// In this case, although the reference ID based on the .NET type we would use is `string`, the
146146
// two schemas are distinct.
147-
if (referenceId == null)
147+
if (referenceId == null && GetSchemaReferenceId(schema) is { } targetReferenceId)
148148
{
149-
var targetReferenceId = GetSchemaReferenceId(schema);
150149
if (_referenceIdCounter.TryGetValue(targetReferenceId, out var counter))
151150
{
152151
counter++;
@@ -166,14 +165,14 @@ private void AddOrUpdateSchemaByReference(OpenApiSchema schema)
166165
}
167166
}
168167

169-
private static string GetSchemaReferenceId(OpenApiSchema schema)
168+
private static string? GetSchemaReferenceId(OpenApiSchema schema)
170169
{
171170
if (schema.Extensions.TryGetValue(OpenApiConstants.SchemaId, out var referenceIdAny)
172171
&& referenceIdAny is OpenApiString { Value: string referenceId })
173172
{
174173
return referenceId;
175174
}
176175

177-
throw new InvalidOperationException("The schema reference ID must be set on the schema.");
176+
return null;
178177
}
179178
}

src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt

+10-17
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"in": "path",
2626
"required": true,
2727
"schema": {
28-
"$ref": "#/components/schemas/string2"
28+
"minLength": 5,
29+
"type": "string"
2930
}
3031
}
3132
],
@@ -35,17 +36,17 @@
3536
"content": {
3637
"text/plain": {
3738
"schema": {
38-
"$ref": "#/components/schemas/string"
39+
"type": "string"
3940
}
4041
},
4142
"application/json": {
4243
"schema": {
43-
"$ref": "#/components/schemas/string"
44+
"type": "string"
4445
}
4546
},
4647
"text/json": {
4748
"schema": {
48-
"$ref": "#/components/schemas/string"
49+
"type": "string"
4950
}
5051
}
5152
}
@@ -65,10 +66,12 @@
6566
"type": "object",
6667
"properties": {
6768
"Title": {
68-
"$ref": "#/components/schemas/string2"
69+
"minLength": 5,
70+
"type": "string"
6971
},
7072
"Description": {
71-
"$ref": "#/components/schemas/string2"
73+
"minLength": 5,
74+
"type": "string"
7275
},
7376
"IsCompleted": {
7477
"type": "boolean"
@@ -92,17 +95,7 @@
9295
}
9396
}
9497
},
95-
"components": {
96-
"schemas": {
97-
"string": {
98-
"type": "string"
99-
},
100-
"string2": {
101-
"minLength": 5,
102-
"type": "string"
103-
}
104-
}
105-
},
98+
"components": { },
10699
"tags": [
107100
{
108101
"name": "Test"

src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt

+6-18
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,6 @@
200200
},
201201
"components": {
202202
"schemas": {
203-
"boolean": {
204-
"type": "boolean"
205-
},
206-
"DateTime": {
207-
"type": "string",
208-
"format": "date-time"
209-
},
210203
"IFormFile": {
211204
"type": "string",
212205
"format": "binary"
@@ -217,13 +210,6 @@
217210
"$ref": "#/components/schemas/IFormFile"
218211
}
219212
},
220-
"int": {
221-
"type": "integer",
222-
"format": "int32"
223-
},
224-
"string": {
225-
"type": "string"
226-
},
227213
"Todo": {
228214
"required": [
229215
"id",
@@ -234,16 +220,18 @@
234220
"type": "object",
235221
"properties": {
236222
"id": {
237-
"$ref": "#/components/schemas/int"
223+
"type": "integer",
224+
"format": "int32"
238225
},
239226
"title": {
240-
"$ref": "#/components/schemas/string"
227+
"type": "string"
241228
},
242229
"completed": {
243-
"$ref": "#/components/schemas/boolean"
230+
"type": "boolean"
244231
},
245232
"createdAt": {
246-
"$ref": "#/components/schemas/DateTime"
233+
"type": "string",
234+
"format": "date-time"
247235
}
248236
}
249237
}

src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt

+6-18
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,6 @@
8989
},
9090
"components": {
9191
"schemas": {
92-
"boolean": {
93-
"type": "boolean"
94-
},
95-
"DateTime": {
96-
"type": "string",
97-
"format": "date-time"
98-
},
99-
"int": {
100-
"type": "integer",
101-
"format": "int32"
102-
},
103-
"string": {
104-
"type": "string"
105-
},
10692
"Todo": {
10793
"required": [
10894
"id",
@@ -113,16 +99,18 @@
11399
"type": "object",
114100
"properties": {
115101
"id": {
116-
"$ref": "#/components/schemas/int"
102+
"type": "integer",
103+
"format": "int32"
117104
},
118105
"title": {
119-
"$ref": "#/components/schemas/string"
106+
"type": "string"
120107
},
121108
"completed": {
122-
"$ref": "#/components/schemas/boolean"
109+
"type": "boolean"
123110
},
124111
"createdAt": {
125-
"$ref": "#/components/schemas/DateTime"
112+
"type": "string",
113+
"format": "date-time"
126114
}
127115
}
128116
}

0 commit comments

Comments
 (0)