diff --git a/src/OpenApi/src/Extensions/JsonObjectSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs similarity index 73% rename from src/OpenApi/src/Extensions/JsonObjectSchemaExtensions.cs rename to src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index 59e713014ff5..dadd88c1858a 100644 --- a/src/OpenApi/src/Extensions/JsonObjectSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -8,8 +8,8 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Schema; using System.Text.Json.Serialization.Metadata; -using JsonSchemaMapper; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; @@ -22,8 +22,10 @@ namespace Microsoft.AspNetCore.OpenApi; /// Provides a set of extension methods for modifying the opaque JSON Schema type /// that is provided by the underlying schema generator in System.Text.Json. /// -internal static class JsonObjectSchemaExtensions +internal static class JsonNodeSchemaExtensions { + private static readonly NullabilityInfoContext _nullabilityInfoContext = new(); + private static readonly Dictionary _simpleTypeToOpenApiSchema = new() { [typeof(bool)] = new() { Type = "boolean" }, @@ -43,6 +45,8 @@ internal static class JsonObjectSchemaExtensions [typeof(char)] = new() { Type = "string" }, [typeof(Uri)] = new() { Type = "string", Format = "uri" }, [typeof(string)] = new() { Type = "string" }, + [typeof(TimeOnly)] = new() { Type = "string", Format = "time" }, + [typeof(DateOnly)] = new() { Type = "string", Format = "date" }, }; /// @@ -52,7 +56,7 @@ internal static class JsonObjectSchemaExtensions /// OpenApi schema v3 supports the validation vocabulary supported by JSON Schema. Because the underlying /// schema generator does not handle validation attributes to the validation vocabulary, we apply that mapping here. /// - /// Note that this method targets and not because it is + /// Note that this method targets and not because it is /// designed to be invoked via the `OnGenerated` callback provided by the underlying schema generator /// so that attributes can be mapped to the properties associated with inputs and outputs to a given request. /// @@ -74,9 +78,9 @@ internal static class JsonObjectSchemaExtensions /// will result in the schema having a type of "string" and a format of "uri" even though the model binding /// layer will validate the string against *both* constraints. /// - /// The produced by the underlying schema generator. + /// The produced by the underlying schema generator. /// A list of the validation attributes to apply. - internal static void ApplyValidationAttributes(this JsonObject schema, IEnumerable validationAttributes) + internal static void ApplyValidationAttributes(this JsonNode schema, IEnumerable validationAttributes) { foreach (var attribute in validationAttributes) { @@ -126,10 +130,10 @@ internal static void ApplyValidationAttributes(this JsonObject schema, IEnumerab /// /// Populate the default value into the current schema. /// - /// The produced by the underlying schema generator. + /// The produced by the underlying schema generator. /// An object representing the associated with the default value. /// The associated with the target type. - internal static void ApplyDefaultValue(this JsonObject schema, object? defaultValue, JsonTypeInfo? jsonTypeInfo) + internal static void ApplyDefaultValue(this JsonNode schema, object? defaultValue, JsonTypeInfo? jsonTypeInfo) { if (jsonTypeInfo is null) { @@ -159,29 +163,35 @@ internal static void ApplyDefaultValue(this JsonObject schema, object? defaultVa /// based on whether the underlying schema generator returned an array type containing "null" to /// represent a nullable type or if the type was denoted as nullable from our lookup cache. /// - /// Note that this method targets and not because + /// Note that this method targets and not because /// it is is designed to be invoked via the `OnGenerated` callback in the underlying schema generator as /// opposed to after the generated schemas have been mapped to OpenAPI schemas. /// - /// The produced by the underlying schema generator. - /// The associated with the . - internal static void ApplyPrimitiveTypesAndFormats(this JsonObject schema, JsonSchemaGenerationContext context) + /// The produced by the underlying schema generator. + /// The associated with the . + internal static void ApplyPrimitiveTypesAndFormats(this JsonNode schema, JsonSchemaExporterContext context) { - if (_simpleTypeToOpenApiSchema.TryGetValue(context.TypeInfo.Type, out var openApiSchema)) + var type = context.TypeInfo.Type; + var underlyingType = Nullable.GetUnderlyingType(type); + if (_simpleTypeToOpenApiSchema.TryGetValue(underlyingType ?? type, out var openApiSchema)) { schema[OpenApiSchemaKeywords.NullableKeyword] = openApiSchema.Nullable || (schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray schemaType && schemaType.GetValues().Contains("null")); schema[OpenApiSchemaKeywords.TypeKeyword] = openApiSchema.Type; schema[OpenApiSchemaKeywords.FormatKeyword] = openApiSchema.Format; schema[OpenApiConstants.SchemaId] = context.TypeInfo.GetSchemaReferenceId(); + schema[OpenApiSchemaKeywords.NullableKeyword] = underlyingType != null; + // Clear out patterns that the underlying JSON schema generator uses to represent + // validations for DateTime, DateTimeOffset, and integers. + schema[OpenApiSchemaKeywords.PatternKeyword] = null; } } /// /// Applies route constraints to the target schema. /// - /// The produced by the underlying schema generator. + /// The produced by the underlying schema generator. /// The list of s associated with the route parameter. - internal static void ApplyRouteConstraints(this JsonObject schema, IEnumerable constraints) + internal static void ApplyRouteConstraints(this JsonNode schema, IEnumerable constraints) { // Apply constraints in reverse order because when it comes to the routing // layer the first constraint that is violated causes routing to short circuit. @@ -255,10 +265,10 @@ internal static void ApplyRouteConstraints(this JsonObject schema, IEnumerable /// Applies parameter-specific customizations to the target schema. /// - /// The produced by the underlying schema generator. + /// The produced by the underlying schema generator. /// The associated with the . /// The associated with the . - internal static void ApplyParameterInfo(this JsonObject schema, ApiParameterDescription parameterDescription, JsonTypeInfo? jsonTypeInfo) + internal static void ApplyParameterInfo(this JsonNode schema, ApiParameterDescription parameterDescription, JsonTypeInfo? jsonTypeInfo) { // This is special handling for parameters that are not bound from the body but represented in a complex type. // For example: @@ -281,17 +291,24 @@ internal static void ApplyParameterInfo(this JsonObject schema, ApiParameterDesc { var attributes = validations.OfType(); schema.ApplyValidationAttributes(attributes); - if (parameterDescription.ParameterDescriptor is IParameterInfoParameterDescriptor { ParameterInfo: { } parameterInfo }) + } + if (parameterDescription.ParameterDescriptor is IParameterInfoParameterDescriptor { ParameterInfo: { } parameterInfo }) + { + if (parameterInfo.HasDefaultValue) { - if (parameterInfo.HasDefaultValue) - { - schema.ApplyDefaultValue(parameterInfo.DefaultValue, jsonTypeInfo); - } - else if (parameterInfo.GetCustomAttributes().LastOrDefault() is { } defaultValueAttribute) - { - schema.ApplyDefaultValue(defaultValueAttribute.Value, jsonTypeInfo); - } + schema.ApplyDefaultValue(parameterInfo.DefaultValue, jsonTypeInfo); + } + else if (parameterInfo.GetCustomAttributes().LastOrDefault() is { } defaultValueAttribute) + { + schema.ApplyDefaultValue(defaultValueAttribute.Value, jsonTypeInfo); + } + + if (parameterInfo.GetCustomAttributes().OfType() is { } validationAttributes) + { + schema.ApplyValidationAttributes(validationAttributes); } + + schema.ApplyNullabilityContextInfo(parameterInfo); } // Route constraints are only defined on parameters that are sourced from the path. Since // they are encoded in the route template, and not in the type information based to the underlying @@ -305,9 +322,9 @@ internal static void ApplyParameterInfo(this JsonObject schema, ApiParameterDesc /// /// Applies the polymorphism options to the target schema following OpenAPI v3's conventions. /// - /// The produced by the underlying schema generator. - /// The associated with the current type. - internal static void ApplyPolymorphismOptions(this JsonObject schema, JsonSchemaGenerationContext context) + /// The produced by the underlying schema generator. + /// The associated with the current type. + internal static void ApplyPolymorphismOptions(this JsonNode schema, JsonSchemaExporterContext context) { if (context.TypeInfo.PolymorphismOptions is { } polymorphismOptions) { @@ -329,10 +346,48 @@ internal static void ApplyPolymorphismOptions(this JsonObject schema, JsonSchema /// /// Set the x-schema-id property on the schema to the identifier associated with the type. /// - /// The produced by the underlying schema generator. - /// The associated with the current type. - internal static void ApplySchemaReferenceId(this JsonObject schema, JsonSchemaGenerationContext context) + /// The produced by the underlying schema generator. + /// The associated with the current type. + internal static void ApplySchemaReferenceId(this JsonNode schema, JsonSchemaExporterContext context) { schema[OpenApiConstants.SchemaId] = context.TypeInfo.GetSchemaReferenceId(); } + + /// + /// Support applying nullability status for reference types provided as a parameter. + /// + /// The produced by the underlying schema generator. + /// The associated with the schema. + internal static void ApplyNullabilityContextInfo(this JsonNode schema, ParameterInfo parameterInfo) + { + if (parameterInfo.ParameterType.IsValueType) + { + return; + } + + var nullabilityInfo = _nullabilityInfoContext.Create(parameterInfo); + if (nullabilityInfo.WriteState == NullabilityState.Nullable) + { + schema[OpenApiSchemaKeywords.NullableKeyword] = true; + } + } + + /// + /// Support applying nullability status for reference types provided as a property or field. + /// + /// The produced by the underlying schema generator. + /// The or associated with the schema. + internal static void ApplyNullabilityContextInfo(this JsonNode schema, ICustomAttributeProvider attributeProvider) + { + var nullabilityInfo = attributeProvider switch + { + PropertyInfo propertyInfo => !propertyInfo.PropertyType.IsValueType ? _nullabilityInfoContext.Create(propertyInfo) : null, + FieldInfo fieldInfo => !fieldInfo.FieldType.IsValueType ? _nullabilityInfoContext.Create(fieldInfo) : null, + _ => null + }; + if (nullabilityInfo is { WriteState: NullabilityState.Nullable } or { ReadState: NullabilityState.Nullable }) + { + schema[OpenApiSchemaKeywords.NullableKeyword] = true; + } + } } diff --git a/src/OpenApi/src/Schemas/JsonSchemaMapper/JsonSchemaGenerationContext.cs b/src/OpenApi/src/Schemas/JsonSchemaMapper/JsonSchemaGenerationContext.cs deleted file mode 100644 index 8978dfef1fdf..000000000000 --- a/src/OpenApi/src/Schemas/JsonSchemaMapper/JsonSchemaGenerationContext.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Linq; -using System.Reflection; -using System.Text.Json.Serialization.Metadata; - -namespace JsonSchemaMapper; - -/// -/// Defines the context in which a JSON schema within a type graph is being generated. -/// -#if EXPOSE_JSON_SCHEMA_MAPPER -public -#else -internal -#endif - readonly struct JsonSchemaGenerationContext -{ - internal JsonSchemaGenerationContext(JsonTypeInfo typeInfo, Type? declaringType, JsonPropertyInfo? propertyInfo, ParameterInfo? parameterInfo) - { - TypeInfo = typeInfo; - DeclaringType = declaringType; - PropertyInfo = propertyInfo; - ParameterInfo = parameterInfo; - } - - /// - /// The for the type being processed. - /// - public JsonTypeInfo TypeInfo { get; } - - /// - /// The declaring type of the property or parameter being processed. - /// - public Type? DeclaringType { get; } - - /// - /// The if the schema is being generated for a property. - /// - public JsonPropertyInfo? PropertyInfo { get; } - - /// - /// The if a constructor parameter - /// has been associated with the accompanying . - /// - public ParameterInfo? ParameterInfo { get; } - - /// - /// Checks if the type, property, or parameter has the specified attribute applied. - /// - /// The type of the attribute to resolve. - /// Whether to look up the hierarchy chain for the inherited custom attribute. - /// True if the attribute is defined by the current context. - public bool IsDefined(bool inherit = false) - where TAttribute : Attribute => - GetCustomAttributes(typeof(TAttribute), inherit).Any(); - - /// - /// Checks if the type, property, or parameter has the specified attribute applied. - /// - /// The type of the attribute to resolve. - /// Whether to look up the hierarchy chain for the inherited custom attribute. - /// The first attribute resolved from the current context, or null. - public TAttribute? GetAttribute(bool inherit = false) - where TAttribute : Attribute => - (TAttribute?)GetCustomAttributes(typeof(TAttribute), inherit).FirstOrDefault(); - - /// - /// Resolves any custom attributes that might have been applied to the type, property, or parameter. - /// - /// The attribute type to resolve. - /// Whether to look up the hierarchy chain for the inherited custom attribute. - /// An enumerable of all custom attributes defined by the context. - public IEnumerable GetCustomAttributes(Type type, bool inherit = false) - { - // Resolves attributes starting from the property, then the parameter, and finally the type itself. - return GetAttrs(JsonSchemaMapper.ResolveAttributeProvider(DeclaringType, PropertyInfo)) - .Concat(GetAttrs(ParameterInfo)) - .Concat(GetAttrs(TypeInfo.Type)) - .Cast(); - - object[] GetAttrs(ICustomAttributeProvider? provider) => - provider?.GetCustomAttributes(type, inherit) ?? Array.Empty(); - } -} diff --git a/src/OpenApi/src/Schemas/JsonSchemaMapper/JsonSchemaMapper.ReflectionHelpers.cs b/src/OpenApi/src/Schemas/JsonSchemaMapper/JsonSchemaMapper.ReflectionHelpers.cs deleted file mode 100644 index f44ef3480b36..000000000000 --- a/src/OpenApi/src/Schemas/JsonSchemaMapper/JsonSchemaMapper.ReflectionHelpers.cs +++ /dev/null @@ -1,425 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.ComponentModel; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Reflection; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; - -namespace JsonSchemaMapper; - -#if EXPOSE_JSON_SCHEMA_MAPPER -public -#else -internal -#endif - static partial class JsonSchemaMapper -{ - // Uses reflection to determine the element type of an enumerable or dictionary type - // Workaround for https://github.com/dotnet/runtime/issues/77306#issuecomment-2007887560 - private static Type GetElementType(JsonTypeInfo typeInfo) - { - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary); - // Element type is non-null for enumerable and dictionary types - return typeInfo.ElementType!; - } - - // The source generator currently doesn't populate attribute providers for properties - // cf. https://github.com/dotnet/runtime/issues/100095 - // Work around the issue by running a query for the relevant MemberInfo using the internal MemberName property - // https://github.com/dotnet/runtime/blob/de774ff9ee1a2c06663ab35be34b755cd8d29731/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs#L206 -#if NETCOREAPP - [EditorBrowsable(EditorBrowsableState.Never)] - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", - Justification = "We're reading the internal JsonPropertyInfo.MemberName which cannot have been trimmed away.")] - [UnconditionalSuppressMessage("Trimming", "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of method does not have matching annotations.", - Justification = "We're reading the member which is already accessed by the source generator.")] -#endif - internal static ICustomAttributeProvider? ResolveAttributeProvider(Type? declaringType, JsonPropertyInfo? propertyInfo) - { - if (declaringType is null || propertyInfo is null) - { - return null; - } - - if (propertyInfo.AttributeProvider is { } provider) - { - return provider; - } - - s_memberNameProperty ??= typeof(JsonPropertyInfo).GetProperty("MemberName", BindingFlags.Instance | BindingFlags.NonPublic)!; - var memberName = (string?)s_memberNameProperty.GetValue(propertyInfo); - if (memberName is not null) - { - return declaringType.GetMember(memberName, MemberTypes.Property | MemberTypes.Field, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).FirstOrDefault(); - } - - return null; - } - - private static PropertyInfo? s_memberNameProperty; - - // Uses reflection to determine any custom converters specified for the element of a nullable type. -#if NETCOREAPP - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "We're resolving private fields of the built-in Nullable converter which cannot have been trimmed away.")] -#endif - private static JsonConverter? ExtractCustomNullableConverter(JsonConverter? converter) - { - Debug.Assert(converter is null || IsBuiltInConverter(converter)); - - // There is unfortunately no way in which we can obtain the element converter from a nullable converter without resorting to private reflection - // https://github.com/dotnet/runtime/blob/5fda47434cecc590095e9aef3c4e560b7b7ebb47/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/NullableConverter.cs#L15-L17 - Type? converterType = converter?.GetType(); - if (converterType?.Name == "NullableConverter`1") - { - FieldInfo elementConverterField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_elementConverter"); - return (JsonConverter)elementConverterField!.GetValue(converter)!; - } - - return null; - } - - // Uses reflection to determine serialization configuration for enum types - // cf. https://github.com/dotnet/runtime/blob/5fda47434cecc590095e9aef3c4e560b7b7ebb47/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs#L23-L25 -#if NETCOREAPP - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] -#endif - private static bool TryGetStringEnumConverterValues(JsonTypeInfo typeInfo, JsonConverter converter, out JsonArray? values) - { - Debug.Assert(typeInfo.Type.IsEnum && IsBuiltInConverter(converter)); - - if (converter is JsonConverterFactory factory) - { - converter = factory.CreateConverter(typeInfo.Type, typeInfo.Options)!; - } - - Type converterType = converter.GetType(); - FieldInfo converterOptionsField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_converterOptions"); - FieldInfo namingPolicyField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_namingPolicy"); - - const int EnumConverterOptionsAllowStrings = 1; - var converterOptions = (int)converterOptionsField!.GetValue(converter)!; - if ((converterOptions & EnumConverterOptionsAllowStrings) != 0) - { - if (typeInfo.Type.GetCustomAttribute() is not null) - { - // For enums implemented as flags do not surface values in the JSON schema. - values = null; - } - else - { - var namingPolicy = (JsonNamingPolicy?)namingPolicyField!.GetValue(converter)!; - string[] names = Enum.GetNames(typeInfo.Type); - values = new JsonArray(); - foreach (string name in names) - { - string effectiveName = namingPolicy?.ConvertName(name) ?? name; - values.Add((JsonNode)effectiveName); - } - } - - return true; - } - - values = null; - return false; - } - -#if NETCOREAPP - [RequiresUnreferencedCode("Resolves unreferenced member metadata.")] -#endif - private static FieldInfo GetPrivateFieldWithPotentiallyTrimmedMetadata(this Type type, string fieldName) - { - FieldInfo? field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); - if (field is null) - { - throw new InvalidOperationException( - $"Could not resolve metadata for field '{fieldName}' in type '{type}'. " + - "If running Native AOT ensure that the 'IlcTrimMetadata' property has been disabled."); - } - - return field; - } - - // Resolves the parameters of the deserialization constructor for a type, if they exist. -#if NETCOREAPP - [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", - Justification = "The deserialization constructor should have already been referenced by the source generator and therefore will not have been trimmed.")] -#endif - private static Func ResolveJsonConstructorParameterMapper(JsonTypeInfo typeInfo) - { - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Object); - - if (typeInfo.Properties.Count > 0 && - typeInfo.CreateObject is null && // Ensure that a default constructor isn't being used - typeInfo.Type.TryGetDeserializationConstructor(useDefaultCtorInAnnotatedStructs: true, out ConstructorInfo? ctor)) - { - ParameterInfo[]? parameters = ctor?.GetParameters(); - if (parameters?.Length > 0) - { - Dictionary dict = new(parameters.Length); - foreach (ParameterInfo parameter in parameters) - { - if (parameter.Name is not null) - { - // We don't care about null parameter names or conflicts since they - // would have already been rejected by JsonTypeInfo configuration. - dict[new(parameter.Name, parameter.ParameterType)] = parameter; - } - } - - return prop => dict.TryGetValue(new(prop.Name, prop.PropertyType), out ParameterInfo? parameter) ? parameter : null; - } - } - - return static _ => null; - } - - // Parameter to property matching semantics as declared in - // https://github.com/dotnet/runtime/blob/12d96ccfaed98e23c345188ee08f8cfe211c03e7/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs#L1007-L1030 - private readonly struct ParameterLookupKey : IEquatable - { - public ParameterLookupKey(string name, Type type) - { - Name = name; - Type = type; - } - - public string Name { get; } - public Type Type { get; } - - public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Name); - public bool Equals(ParameterLookupKey other) => Type == other.Type && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); - public override bool Equals(object? obj) => obj is ParameterLookupKey key && Equals(key); - } - - // Resolves the deserialization constructor for a type using logic copied from - // https://github.com/dotnet/runtime/blob/e12e2fa6cbdd1f4b0c8ad1b1e2d960a480c21703/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L227-L286 - private static bool TryGetDeserializationConstructor( -#if NETCOREAPP - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] -#endif - this Type type, - bool useDefaultCtorInAnnotatedStructs, - out ConstructorInfo? deserializationCtor) - { - ConstructorInfo? ctorWithAttribute = null; - ConstructorInfo? publicParameterlessCtor = null; - ConstructorInfo? lonePublicCtor = null; - - ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); - - if (constructors.Length == 1) - { - lonePublicCtor = constructors[0]; - } - - foreach (ConstructorInfo constructor in constructors) - { - if (HasJsonConstructorAttribute(constructor)) - { - if (ctorWithAttribute != null) - { - deserializationCtor = null; - return false; - } - - ctorWithAttribute = constructor; - } - else if (constructor.GetParameters().Length == 0) - { - publicParameterlessCtor = constructor; - } - } - - // Search for non-public ctors with [JsonConstructor]. - foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)) - { - if (HasJsonConstructorAttribute(constructor)) - { - if (ctorWithAttribute != null) - { - deserializationCtor = null; - return false; - } - - ctorWithAttribute = constructor; - } - } - - // Structs will use default constructor if attribute isn't used. - if (useDefaultCtorInAnnotatedStructs && type.IsValueType && ctorWithAttribute == null) - { - deserializationCtor = null; - return true; - } - - deserializationCtor = ctorWithAttribute ?? publicParameterlessCtor ?? lonePublicCtor; - return true; - - static bool HasJsonConstructorAttribute(ConstructorInfo constructorInfo) => - constructorInfo.GetCustomAttribute() != null; - } - - private static bool IsBuiltInConverter(JsonConverter converter) => - converter.GetType().Assembly == typeof(JsonConverter).Assembly; - - // Resolves the nullable reference type annotations for a property or field, - // additionally addressing a few known bugs of the NullabilityInfo pre .NET 9. - private static NullabilityInfo GetMemberNullability(this NullabilityInfoContext context, MemberInfo memberInfo) - { - Debug.Assert(memberInfo is PropertyInfo or FieldInfo); - return memberInfo is PropertyInfo prop - ? context.Create(prop) - : context.Create((FieldInfo)memberInfo); - } - - private static NullabilityState GetParameterNullability(this NullabilityInfoContext context, ParameterInfo parameterInfo) - { - // Workaround for https://github.com/dotnet/runtime/issues/92487 - if (parameterInfo.GetGenericParameterDefinition() is { ParameterType: { IsGenericParameter: true } typeParam }) - { - // Step 1. Look for nullable annotations on the type parameter. - if (GetNullableFlags(typeParam) is byte[] flags) - { - return TranslateByte(flags[0]); - } - - // Step 2. Look for nullable annotations on the generic method declaration. - if (typeParam.DeclaringMethod != null && GetNullableContextFlag(typeParam.DeclaringMethod) is byte flag) - { - return TranslateByte(flag); - } - - // Step 3. Look for nullable annotations on the generic method declaration. - if (GetNullableContextFlag(typeParam.DeclaringType!) is byte flag2) - { - return TranslateByte(flag2); - } - - // Default to nullable. - return NullabilityState.Nullable; - -#if NETCOREAPP - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", - Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] -#endif - static byte[]? GetNullableFlags(MemberInfo member) - { - Attribute? attr = member.GetCustomAttributes().FirstOrDefault(attr => - { - Type attrType = attr.GetType(); - return attrType.Namespace == "System.Runtime.CompilerServices" && attrType.Name == "NullableAttribute"; - }); - - return (byte[])attr?.GetType().GetField("NullableFlags")?.GetValue(attr)!; - } - -#if NETCOREAPP - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", - Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] -#endif - static byte? GetNullableContextFlag(MemberInfo member) - { - Attribute? attr = member.GetCustomAttributes().FirstOrDefault(attr => - { - Type attrType = attr.GetType(); - return attrType.Namespace == "System.Runtime.CompilerServices" && attrType.Name == "NullableContextAttribute"; - }); - - return (byte?)attr?.GetType().GetField("Flag")?.GetValue(attr)!; - } - - static NullabilityState TranslateByte(byte b) => - b switch - { - 1 => NullabilityState.NotNull, - 2 => NullabilityState.Nullable, - _ => NullabilityState.Unknown - }; - } - - return context.Create(parameterInfo).WriteState; - } - - private static ParameterInfo GetGenericParameterDefinition(this ParameterInfo parameter) - { - if (parameter.Member is { DeclaringType.IsConstructedGenericType: true } - or MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false }) - { - var genericMethod = (MethodBase)parameter.Member.GetGenericMemberDefinition()!; - return genericMethod.GetParameters()[parameter.Position]; - } - - return parameter; - } - -#if NETCOREAPP - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", - Justification = "Looking up the generic member definition of the provided member.")] -#endif - private static MemberInfo GetGenericMemberDefinition(this MemberInfo member) - { - if (member is Type type) - { - return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; - } - - if (member.DeclaringType!.IsConstructedGenericType) - { - const BindingFlags AllMemberFlags = - BindingFlags.Static | BindingFlags.Instance | - BindingFlags.Public | BindingFlags.NonPublic; - - return member.DeclaringType.GetGenericTypeDefinition() - .GetMember(member.Name, AllMemberFlags) - .First(m => m.MetadataToken == member.MetadataToken); - } - - if (member is MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false } method) - { - return method.GetGenericMethodDefinition(); - } - - return member; - } - - // Taken from https://github.com/dotnet/runtime/blob/903bc019427ca07080530751151ea636168ad334/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L288-L317 - private static object? GetNormalizedDefaultValue(this ParameterInfo parameterInfo) - { - Type parameterType = parameterInfo.ParameterType; - object? defaultValue = parameterInfo.DefaultValue; - - if (defaultValue is null) - { - return null; - } - - // DBNull.Value is sometimes used as the default value (returned by reflection) of nullable params in place of null. - if (defaultValue == DBNull.Value && parameterType != typeof(DBNull)) - { - return null; - } - - // Default values of enums or nullable enums are represented using the underlying type and need to be cast explicitly - // cf. https://github.com/dotnet/runtime/issues/68647 - if (parameterType.IsEnum) - { - return Enum.ToObject(parameterType, defaultValue); - } - - if (Nullable.GetUnderlyingType(parameterType) is Type underlyingType && underlyingType.IsEnum) - { - return Enum.ToObject(underlyingType, defaultValue); - } - - return defaultValue; - } -} diff --git a/src/OpenApi/src/Schemas/JsonSchemaMapper/JsonSchemaMapper.cs b/src/OpenApi/src/Schemas/JsonSchemaMapper/JsonSchemaMapper.cs deleted file mode 100644 index 87d4be43b226..000000000000 --- a/src/OpenApi/src/Schemas/JsonSchemaMapper/JsonSchemaMapper.cs +++ /dev/null @@ -1,965 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.ComponentModel; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using System.Reflection; -using System.Runtime.InteropServices; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; - -namespace JsonSchemaMapper; - -/// -/// Maps .NET types to JSON schema objects using contract metadata from instances. -/// -#if EXPOSE_JSON_SCHEMA_MAPPER -public -#else -internal -#endif - static partial class JsonSchemaMapper -{ - /// - /// The JSON schema draft version used by the generated schemas. - /// - public const string SchemaVersion = "https://json-schema.org/draft/2020-12/schema"; - - /// - /// Generates a JSON schema corresponding to the contract metadata of the specified type. - /// - /// The options instance from which to resolve the contract metadata. - /// The root type for which to generate the JSON schema. - /// The configuration object controlling the schema generation. - /// A new instance defining the JSON schema for . - /// One of the specified parameters is . - /// The parameter contains unsupported configuration. - public static JsonObject GetJsonSchema(this JsonSerializerOptions options, Type type, JsonSchemaMapperConfiguration? configuration = null) - { - if (options is null) - { - ThrowHelpers.ThrowArgumentNullException(nameof(options)); - } - - if (type is null) - { - ThrowHelpers.ThrowArgumentNullException(nameof(type)); - } - - ValidateOptions(options); - configuration ??= JsonSchemaMapperConfiguration.Default; - - JsonTypeInfo typeInfo = options.GetTypeInfo(type); - var state = new GenerationState(configuration); - return MapJsonSchemaCore(ref state, typeInfo); - } - - /// - /// Generates a JSON object schema with properties corresponding to the specified method parameters. - /// - /// The options instance from which to resolve the contract metadata. - /// The method from whose parameters to generate the JSON schema. - /// The configuration object controlling the schema generation. - /// A new instance defining the JSON schema for . - /// One of the specified parameters is . - /// The parameter contains unsupported configuration. - public static JsonObject GetJsonSchema(this JsonSerializerOptions options, MethodBase method, JsonSchemaMapperConfiguration? configuration = null) - { - if (options is null) - { - ThrowHelpers.ThrowArgumentNullException(nameof(options)); - } - - if (method is null) - { - ThrowHelpers.ThrowArgumentNullException(nameof(method)); - } - - ValidateOptions(options); - configuration ??= JsonSchemaMapperConfiguration.Default; - - var state = new GenerationState(configuration); - string title = method.Name; - string? description = configuration.ResolveDescriptionAttributes - ? method.GetCustomAttribute()?.Description - : null; - - JsonSchemaType type = JsonSchemaType.Object; - JsonObject? paramSchemas = null; - JsonArray? requiredParams = null; - - foreach (ParameterInfo parameterInfo in method.GetParameters()) - { - if (parameterInfo.Name is null) - { - ThrowHelpers.ThrowInvalidOperationException_TrimmedMethodParameters(method); - } - - JsonTypeInfo jsonParameterInfo = options.GetTypeInfo(parameterInfo.ParameterType); - bool isNullableReferenceType = false; - string? parameterDescription = null; - bool hasDefaultValue = false; - JsonNode? defaultValue = null; - bool isRequired = false; - - ResolveParameterInfo(ref state, parameterInfo, jsonParameterInfo, ref parameterDescription, ref hasDefaultValue, ref defaultValue, ref isNullableReferenceType, ref isRequired); - - state.Push(parameterInfo.Name); - JsonObject paramSchema = MapJsonSchemaCore( - ref state, - jsonParameterInfo, - description: parameterDescription, - isNullableReferenceType: isNullableReferenceType, - hasDefaultValue: hasDefaultValue, - defaultValue: defaultValue); - - state.Pop(); - - (paramSchemas ??= new()).Add(parameterInfo.Name, paramSchema); - if (isRequired) - { - (requiredParams ??= new()).Add((JsonNode)parameterInfo.Name); - } - } - - return CreateSchemaDocument(ref state, title: title, description: description, schemaType: type, properties: paramSchemas, requiredProperties: requiredParams); - } - - public static JsonObject GetJsonSchema(this JsonSerializerOptions options, ParameterInfo parameterInfo, JsonSchemaMapperConfiguration? configuration = null) - { - if (options is null) - { - ThrowHelpers.ThrowArgumentNullException(nameof(options)); - } - - if (parameterInfo is null) - { - ThrowHelpers.ThrowArgumentNullException(nameof(parameterInfo)); - } - - ValidateOptions(options); - configuration ??= JsonSchemaMapperConfiguration.Default; - - var state = new GenerationState(configuration); - - if (parameterInfo.Name is null) - { - throw new InvalidOperationException("Unexpected parameter info."); - } - - JsonTypeInfo jsonParameterInfo = options.GetTypeInfo(parameterInfo.ParameterType); - bool isNullableReferenceType = false; - string? parameterDescription = null; - bool hasDefaultValue = false; - JsonNode? defaultValue = null; - bool isRequired = false; - - ResolveParameterInfo(ref state, parameterInfo, jsonParameterInfo, ref parameterDescription, ref hasDefaultValue, ref defaultValue, ref isNullableReferenceType, ref isRequired); - - return MapJsonSchemaCore( - ref state, - jsonParameterInfo, - description: parameterDescription, - isNullableReferenceType: isNullableReferenceType, - hasDefaultValue: hasDefaultValue, - parentParameterInfo: parameterInfo, - defaultValue: defaultValue); - - } - - /// - /// Generates a JSON schema corresponding to the specified contract metadata. - /// - /// The contract metadata for which to generate the schema. - /// The configuration object controlling the schema generation. - /// A new instance defining the JSON schema for . - /// One of the specified parameters is . - /// The parameter contains unsupported configuration. - public static JsonObject GetJsonSchema(this JsonTypeInfo typeInfo, JsonSchemaMapperConfiguration? configuration = null) - { - if (typeInfo is null) - { - ThrowHelpers.ThrowArgumentNullException(nameof(typeInfo)); - } - - ValidateOptions(typeInfo.Options); - typeInfo.MakeReadOnly(); - - var state = new GenerationState(configuration ?? JsonSchemaMapperConfiguration.Default); - return MapJsonSchemaCore(ref state, typeInfo); - } - - /// - /// Renders the specified instance as a JSON string. - /// - /// The node to serialize. - /// Whether to indent the resultant JSON text. - /// The JSON node rendered as a JSON string. - public static string ToJsonString(this JsonNode? node, bool writeIndented = false) - { - return node is null - ? "null" - : node.ToJsonString(writeIndented ? new JsonSerializerOptions { WriteIndented = true } : null); - } - - private static JsonObject MapJsonSchemaCore( - ref GenerationState state, - JsonTypeInfo typeInfo, - Type? parentType = null, - JsonPropertyInfo? parentPropertyInfo = null, - ParameterInfo? parentParameterInfo = null, - string? description = null, - bool isNullableReferenceType = false, - bool isNullableOfTElement = false, - JsonConverter? customConverter = null, - JsonNumberHandling? customNumberHandling = null, - bool hasDefaultValue = false, - JsonNode? defaultValue = null, - Type? parentPolymorphicType = null, - KeyValuePair? typeDiscriminator = null) - { - Debug.Assert(typeInfo.IsReadOnly); - - Type type = typeInfo.Type; - JsonConverter effectiveConverter = customConverter ?? typeInfo.Converter; - JsonNumberHandling? effectiveNumberHandling = customNumberHandling ?? typeInfo.NumberHandling; - bool canCacheResult = - description is null && - !hasDefaultValue && - !isNullableOfTElement && - parentPolymorphicType != type && - typeDiscriminator is null && - typeInfo.Kind != JsonTypeInfoKind.None; - - if (canCacheResult && state.GetOrAddSchemaPointer(type, customConverter, isNullableReferenceType, effectiveNumberHandling) is string existingPointer) - { - // Schema for type has already been generated, return a reference to it. - // For derived types using discriminators, the schema is generated inline. - return new JsonObject { [RefPropertyName] = existingPointer }; - } - - if (state.Configuration.ResolveDescriptionAttributes) - { - description ??= type.GetCustomAttribute()?.Description; - } - - JsonObject schema; - JsonSchemaType schemaType = JsonSchemaType.Any; - string? format = null; - string? pattern = null; - JsonObject? properties = null; - JsonArray? requiredProperties = null; - JsonObject? arrayItems = null; - JsonNode? additionalProperties = null; - JsonArray? enumValues = null; - JsonArray? anyOfSchema = null; - - if (!IsBuiltInConverter(effectiveConverter)) - { - // We can't make any schema determinations if a custom converter is used. - goto ConstructSchemaDocument; - } - - if (Nullable.GetUnderlyingType(type) is Type nullableElementType) - { - JsonTypeInfo? nullableElementTypeInfo = typeInfo.Options.GetTypeInfo(nullableElementType); - customConverter = ExtractCustomNullableConverter(customConverter); - schema = MapJsonSchemaCore( - ref state, - nullableElementTypeInfo, - parentType, - parentPropertyInfo, - parentParameterInfo, - description: description, - hasDefaultValue: hasDefaultValue, - defaultValue: defaultValue, - customNumberHandling: effectiveNumberHandling, - customConverter: customConverter, - isNullableOfTElement: true); - - state.HandleGenerationCallback(schema, typeInfo, parentType, parentPropertyInfo, parentParameterInfo); - return schema; - } - - if (parentPolymorphicType is null && typeInfo.PolymorphismOptions is { DerivedTypes.Count: > 0 } polyOptions) - { - // This is the base type of a polymorphic type hierarchy. The schema for this type - // will include an "anyOf" property with the schemas for all derived types. - - string typeDiscriminatorKey = polyOptions.TypeDiscriminatorPropertyName; - List derivedTypes = polyOptions.DerivedTypes.ToList(); - - if (!type.IsAbstract && !derivedTypes.Any(derived => derived.DerivedType == type)) - { - // For non-abstract base types that haven't been explicitly configured, - // add a trivial schema to the derived types since we should support it. - derivedTypes.Add(new JsonDerivedType(type)); - } - - state.Push(AnyOfPropertyName); - anyOfSchema = new JsonArray(); - - int i = 0; - foreach (JsonDerivedType derivedType in derivedTypes) - { - Debug.Assert(derivedType.TypeDiscriminator is null or int or string); - - KeyValuePair? derivedTypeDiscriminator = null; - if (derivedType.TypeDiscriminator is { } discriminatorValue) - { - JsonNode discriminatorNodeValue = discriminatorValue is string stringId - ? (JsonNode)stringId - : (JsonNode)(int)discriminatorValue; - - var typeDiscriminatorPropertySchema = new JsonObject { [ConstPropertyName] = discriminatorNodeValue }; - derivedTypeDiscriminator = new(typeDiscriminatorKey, typeDiscriminatorPropertySchema); - } - - JsonTypeInfo derivedTypeInfo = typeInfo.Options.GetTypeInfo(derivedType.DerivedType); - - state.Push(i++.ToString(CultureInfo.InvariantCulture)); - JsonObject derivedSchema = MapJsonSchemaCore( - ref state, - derivedTypeInfo, - parentPolymorphicType: type, - typeDiscriminator: derivedTypeDiscriminator); - - anyOfSchema.Add((JsonNode)derivedSchema); - state.Pop(); - } - - state.Pop(); - goto ConstructSchemaDocument; - } - - switch (typeInfo.Kind) - { - case JsonTypeInfoKind.None: - if (s_simpleTypeInfo.TryGetValue(type, out SimpleTypeJsonSchema simpleTypeInfo)) - { - schemaType = simpleTypeInfo.SchemaType; - format = simpleTypeInfo.Format; - pattern = simpleTypeInfo.Pattern; - - if (effectiveNumberHandling is JsonNumberHandling numberHandling && - schemaType is JsonSchemaType.Integer or JsonSchemaType.Number) - { - if ((numberHandling & (JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)) != 0) - { - schemaType |= JsonSchemaType.String; - } - else if (numberHandling is JsonNumberHandling.AllowNamedFloatingPointLiterals && simpleTypeInfo.IsIeeeFloatingPoint) - { - anyOfSchema = new JsonArray - { - (JsonNode)new JsonObject { [TypePropertyName] = MapSchemaType(schemaType) }, - (JsonNode)new JsonObject - { - [EnumPropertyName] = new JsonArray { (JsonNode)"NaN", (JsonNode)"Infinity", (JsonNode)"-Infinity" }, - }, - }; - - schemaType = JsonSchemaType.Any; // reset the parent setting - } - } - } - else if (type.IsEnum) - { - if (TryGetStringEnumConverterValues(typeInfo, effectiveConverter, out JsonArray? values)) - { - if (values is null) - { - // enum declared with the flags attribute -- do not surface enum values in the JSON schema. - schemaType = JsonSchemaType.String; - } - else - { - if (isNullableOfTElement) - { - // We're generating the schema for a nullable - // enum type. Append null to the "enum" array. - values.Add(null); - } - - enumValues = values; - } - } - else - { - schemaType = JsonSchemaType.Integer; - } - } - - break; - - case JsonTypeInfoKind.Object: - schemaType = JsonSchemaType.Object; - - if (typeInfo.UnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) - { - // Disallow unspecified properties. - additionalProperties = false; - } - - if (typeDiscriminator.HasValue) - { - (properties ??= new()).Add(typeDiscriminator.Value); - (requiredProperties ??= new()).Add((JsonNode)typeDiscriminator.Value.Key); - } - - Func parameterInfoMapper = ResolveJsonConstructorParameterMapper(typeInfo); - - state.Push(PropertiesPropertyName); - foreach (JsonPropertyInfo property in typeInfo.Properties) - { - if (property is { Get: null, Set: null } - or { IsExtensionData: true }) - { - continue; // Skip [JsonIgnore] property or extension data properties. - } - - JsonNumberHandling? propertyNumberHandling = property.NumberHandling ?? effectiveNumberHandling; - JsonTypeInfo propertyTypeInfo = typeInfo.Options.GetTypeInfo(property.PropertyType); - - // Only resolve nullability metadata for reference types. - NullabilityInfoContext? nullabilityCtx = !property.PropertyType.IsValueType ? state.NullabilityInfoContext : null; - - // Only resolve the attribute provider if needed. - ICustomAttributeProvider? attributeProvider = state.Configuration.ResolveDescriptionAttributes || nullabilityCtx != null - ? ResolveAttributeProvider(typeInfo.Type, property) - : null; - - // Resolve property-level description attributes. - string? propertyDescription = state.Configuration.ResolveDescriptionAttributes - ? attributeProvider?.GetCustomAttributes(inherit: true).OfType().FirstOrDefault()?.Description - : null; - - // Declare the property as nullable if either getter or setter are nullable. - bool isPropertyNullableReferenceType = nullabilityCtx != null && attributeProvider is MemberInfo memberInfo - ? nullabilityCtx.GetMemberNullability(memberInfo) is { WriteState: NullabilityState.Nullable } or { ReadState: NullabilityState.Nullable } - : false; - - bool isRequired = property.IsRequired; - bool propertyHasDefaultValue = false; - JsonNode? propertyDefaultValue = null; - - ParameterInfo? ctorParameterInfo = parameterInfoMapper(property); - if (ctorParameterInfo != null) - { - ResolveParameterInfo( - ref state, - ctorParameterInfo, - propertyTypeInfo, - ref propertyDescription, - ref propertyHasDefaultValue, - ref propertyDefaultValue, - ref isPropertyNullableReferenceType, - ref isRequired); - } - - state.Push(property.Name); - JsonObject propertySchema = MapJsonSchemaCore( - ref state, - propertyTypeInfo, - parentType: type, - property, - ctorParameterInfo, - description: propertyDescription, - isNullableReferenceType: isPropertyNullableReferenceType, - customConverter: property.CustomConverter, - hasDefaultValue: propertyHasDefaultValue, - defaultValue: propertyDefaultValue, - customNumberHandling: propertyNumberHandling); - - state.Pop(); - - (properties ??= new()).Add(property.Name, propertySchema); - - if (isRequired) - { - (requiredProperties ??= new()).Add((JsonNode)property.Name); - } - } - - state.Pop(); - break; - - case JsonTypeInfoKind.Enumerable: - Type elementType = GetElementType(typeInfo); - JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementType); - - if (typeDiscriminator is null) - { - schemaType = JsonSchemaType.Array; - - state.Push(ItemsPropertyName); - arrayItems = MapJsonSchemaCore(ref state, elementTypeInfo); - state.Pop(); - } - else - { - // Polymorphic enumerable types are represented using a wrapping object: - // { "$type" : "discriminator", "$values" : [element1, element2, ...] } - // Which corresponds to the schema - // { "properties" : { "$type" : { "const" : "discriminator" }, "$values" : { "type" : "array", "items" : { ... } } } } - - schemaType = JsonSchemaType.Object; - (properties ??= new()).Add(typeDiscriminator.Value); - (requiredProperties ??= new()).Add((JsonNode)typeDiscriminator.Value.Key); - - state.Push(PropertiesPropertyName); - state.Push(StjValuesMetadataProperty); - state.Push(ItemsPropertyName); - JsonObject elementSchema = MapJsonSchemaCore(ref state, elementTypeInfo, parentType, parentPropertyInfo, parentParameterInfo); - state.Pop(); - state.Pop(); - state.Pop(); - - properties.Add( - StjValuesMetadataProperty, - new JsonObject - { - [TypePropertyName] = MapSchemaType(JsonSchemaType.Array), - [ItemsPropertyName] = elementSchema, - }); - } - - break; - - case JsonTypeInfoKind.Dictionary: - schemaType = JsonSchemaType.Object; - Type valueType = GetElementType(typeInfo); - JsonTypeInfo valueTypeInfo = typeInfo.Options.GetTypeInfo(valueType); - - if (typeDiscriminator.HasValue) - { - (properties ??= new()).Add(typeDiscriminator.Value); - (requiredProperties ??= new()).Add((JsonNode)typeDiscriminator.Value.Key); - } - - state.Push(AdditionalPropertiesPropertyName); - additionalProperties = MapJsonSchemaCore(ref state, valueTypeInfo, parentType, parentPropertyInfo, parentParameterInfo); - state.Pop(); - break; - - default: - Debug.Fail("Unreachable code"); - break; - } - - if (schemaType != JsonSchemaType.Any && - (type.IsValueType - ? isNullableOfTElement - : (isNullableReferenceType || state.Configuration.ReferenceTypeNullability is ReferenceTypeNullability.AlwaysNullable))) - { - // Append "null" to the type array in the following cases: - // 1. The type is a nullable value type or - // 2. The type has been inferred to be a nullable reference type annotation or - // 3. The schema generator has been configured to always emit null for reference types (default STJ semantics). - schemaType |= JsonSchemaType.Null; - } - - ConstructSchemaDocument: - schema = CreateSchemaDocument( - ref state, - description: description, - schemaType: schemaType, - format: format, - pattern: pattern, - properties: properties, - requiredProperties: requiredProperties, - arrayItems: arrayItems, - additionalProperties: additionalProperties, - enumValues: enumValues, - anyOfSchema: anyOfSchema, - hasDefaultValue: hasDefaultValue, - defaultValue: defaultValue); - - state.HandleGenerationCallback(schema, typeInfo, parentType, parentPropertyInfo, parentParameterInfo); - return schema; - } - - private static void ResolveParameterInfo( - ref GenerationState state, - ParameterInfo parameter, - JsonTypeInfo parameterTypeInfo, - ref string? description, - ref bool hasDefaultValue, - ref JsonNode? defaultValue, - ref bool isNullableReferenceType, - ref bool isRequired) - { - Debug.Assert(parameterTypeInfo.Type == parameter.ParameterType); - - if (state.Configuration.ResolveDescriptionAttributes) - { - // Resolve parameter-level description attributes. - description ??= parameter.GetCustomAttribute()?.Description; - } - - if (!isNullableReferenceType && state.NullabilityInfoContext is { } ctx) - { - // Consult the nullability annotation of the constructor parameter if available. - isNullableReferenceType = ctx.GetParameterNullability(parameter) is NullabilityState.Nullable; - } - - if (parameter.HasDefaultValue) - { - // Append the default value to the description. - object? defaultVal = parameter.GetNormalizedDefaultValue(); - defaultValue = JsonSerializer.SerializeToNode(defaultVal, parameterTypeInfo); - hasDefaultValue = true; - } - else if (state.Configuration.RequireConstructorParameters) - { - // Parameter is not optional, mark as required. - isRequired = true; - } - } - - private ref struct GenerationState - { - private readonly JsonSchemaMapperConfiguration _configuration; - private readonly NullabilityInfoContext? _nullabilityInfoContext; - private readonly Dictionary<(Type, JsonConverter? CustomConverter, bool IsNullableReferenceType, JsonNumberHandling? CustomNumberHandling), string>? _generatedTypePaths; - private readonly List? _currentPath; - private int _currentDepth; - - public GenerationState(JsonSchemaMapperConfiguration configuration) - { - _configuration = configuration; - _nullabilityInfoContext = configuration.ReferenceTypeNullability is ReferenceTypeNullability.Annotated ? new() : null; - _generatedTypePaths = configuration.AllowSchemaReferences ? new() : null; - _currentPath = configuration.AllowSchemaReferences ? new() : null; - _currentDepth = 0; - } - - public readonly JsonSchemaMapperConfiguration Configuration => _configuration; - public readonly NullabilityInfoContext? NullabilityInfoContext => _nullabilityInfoContext; - public readonly int CurrentDepth => _currentDepth; - - public void Push(string nodeId) - { - if (_currentDepth == Configuration.MaxDepth) - { - ThrowHelpers.ThrowInvalidOperationException_MaxDepthReached(); - } - - _currentDepth++; - - if (Configuration.AllowSchemaReferences) - { - Debug.Assert(_currentPath != null); - _currentPath!.Add(nodeId); - } - } - - public void Pop() - { - Debug.Assert(_currentDepth > 0); - _currentDepth--; - - if (Configuration.AllowSchemaReferences) - { - Debug.Assert(_currentPath != null); - _currentPath!.RemoveAt(_currentPath.Count - 1); - } - } - - /// - /// For a type with a given configuration, either return a JSON pointer value - /// if already generated or register the current path for later reference. - /// - public readonly string? GetOrAddSchemaPointer(Type type, JsonConverter? converter, bool isNullableReferenceType, JsonNumberHandling? numberHandling) - { - if (Configuration.AllowSchemaReferences) - { - Debug.Assert(_currentPath != null); - Debug.Assert(_generatedTypePaths != null); - - var key = (type, converter, isNullableReferenceType, numberHandling); -#if NETCOREAPP - ref string? pointer = ref CollectionsMarshal.GetValueRefOrAddDefault(_generatedTypePaths, key, out bool exists); -#else - bool exists = _generatedTypePaths!.TryGetValue(key, out string? pointer); -#endif - if (exists) - { - return pointer; - } - else - { - pointer = _currentDepth == 0 ? "#" : "#/" + string.Join("/", _currentPath); -#if !NETCOREAPP - _generatedTypePaths.Add(key, pointer); -#endif - } - } - - return null; - } - - public readonly void HandleGenerationCallback(JsonObject schema, JsonTypeInfo typeInfo, Type? parentType, JsonPropertyInfo? propertyInfo, ParameterInfo? parameterInfo) - { - if (Configuration.OnSchemaGenerated is { } callback) - { - var ctx = new JsonSchemaGenerationContext(typeInfo, parentType, propertyInfo, parameterInfo); - callback(ctx, schema); - } - } - } - - private static JsonObject CreateSchemaDocument( - ref GenerationState state, - string? title = null, - string? description = null, - JsonSchemaType schemaType = JsonSchemaType.Any, - string? format = null, - string? pattern = null, - JsonObject? properties = null, - JsonArray? requiredProperties = null, - JsonObject? arrayItems = null, - JsonNode? additionalProperties = null, - JsonArray? enumValues = null, - JsonArray? anyOfSchema = null, - bool hasDefaultValue = false, - JsonNode? defaultValue = null) - { - var schema = new JsonObject(); - - if (state.CurrentDepth == 0 && state.Configuration.IncludeSchemaVersion) - { - schema.Add(SchemaPropertyName, SchemaVersion); - } - - if (title is not null) - { - schema.Add(TitlePropertyName, title); - } - - if (description is not null) - { - schema.Add(DescriptionPropertyName, description); - } - - if (MapSchemaType(schemaType) is JsonNode type) - { - schema.Add(TypePropertyName, type); - } - - if (format is not null) - { - schema.Add(FormatPropertyName, format); - } - - if (pattern is not null) - { - schema.Add(PatternPropertyName, pattern); - } - - if (properties is not null) - { - schema.Add(PropertiesPropertyName, properties); - } - - if (requiredProperties is not null) - { - schema.Add(RequiredPropertyName, requiredProperties); - } - - if (arrayItems is not null) - { - schema.Add(ItemsPropertyName, arrayItems); - } - - if (additionalProperties is not null) - { - schema.Add(AdditionalPropertiesPropertyName, additionalProperties); - } - - if (enumValues is not null) - { - schema.Add(EnumPropertyName, enumValues); - } - - if (anyOfSchema is not null) - { - schema.Add(AnyOfPropertyName, anyOfSchema); - } - - if (hasDefaultValue) - { - schema.Add(DefaultPropertyName, defaultValue); - } - - return schema; - } - - [Flags] - private enum JsonSchemaType - { - Any = 0, // No type declared on the schema - Null = 1, - Boolean = 2, - Integer = 4, - Number = 8, - String = 16, - Array = 32, - Object = 64, - } - - private static readonly JsonSchemaType[] s_schemaValues = new[] - { - // NB the order of these values influences order of types in the rendered schema - JsonSchemaType.String, - JsonSchemaType.Integer, - JsonSchemaType.Number, - JsonSchemaType.Boolean, - JsonSchemaType.Array, - JsonSchemaType.Object, - JsonSchemaType.Null, - }; - - private static JsonNode? MapSchemaType(JsonSchemaType schemaType) - { - return schemaType switch - { - JsonSchemaType.Any => null, - JsonSchemaType.Null => "null", - JsonSchemaType.Boolean => "boolean", - JsonSchemaType.Integer => "integer", - JsonSchemaType.Number => "number", - JsonSchemaType.String => "string", - JsonSchemaType.Array => "array", - JsonSchemaType.Object => "object", - _ => MapCompositeSchemaType(schemaType), - }; - - static JsonArray MapCompositeSchemaType(JsonSchemaType schemaType) - { - var array = new JsonArray(); - foreach (JsonSchemaType type in s_schemaValues) - { - if ((schemaType & type) != 0) - { - array.Add(MapSchemaType(type)); - } - } - - return array; - } - } - - private const string SchemaPropertyName = "$schema"; - private const string RefPropertyName = "$ref"; - private const string TitlePropertyName = "title"; - private const string DescriptionPropertyName = "description"; - private const string TypePropertyName = "type"; - private const string FormatPropertyName = "format"; - private const string PatternPropertyName = "pattern"; - private const string PropertiesPropertyName = "properties"; - private const string RequiredPropertyName = "required"; - private const string ItemsPropertyName = "items"; - private const string AdditionalPropertiesPropertyName = "additionalProperties"; - private const string EnumPropertyName = "enum"; - private const string AnyOfPropertyName = "anyOf"; - private const string ConstPropertyName = "const"; - private const string DefaultPropertyName = "default"; - private const string StjValuesMetadataProperty = "$values"; - - private readonly struct SimpleTypeJsonSchema - { - public SimpleTypeJsonSchema(JsonSchemaType schemaType, string? format = null, string? pattern = null, bool isIeeeFloatingPoint = false) - { - SchemaType = schemaType; - Format = format; - Pattern = pattern; - IsIeeeFloatingPoint = isIeeeFloatingPoint; - } - - public JsonSchemaType SchemaType { get; } - public string? Format { get; } - public string? Pattern { get; } - public bool IsIeeeFloatingPoint { get; } - } - - private static readonly Dictionary s_simpleTypeInfo = new() - { - [typeof(object)] = new(JsonSchemaType.Any), - [typeof(bool)] = new(JsonSchemaType.Boolean), - [typeof(byte)] = new(JsonSchemaType.Integer), - [typeof(ushort)] = new(JsonSchemaType.Integer), - [typeof(uint)] = new(JsonSchemaType.Integer), - [typeof(ulong)] = new(JsonSchemaType.Integer), - [typeof(sbyte)] = new(JsonSchemaType.Integer), - [typeof(short)] = new(JsonSchemaType.Integer), - [typeof(int)] = new(JsonSchemaType.Integer), - [typeof(long)] = new(JsonSchemaType.Integer), - [typeof(float)] = new(JsonSchemaType.Number, isIeeeFloatingPoint: true), - [typeof(double)] = new(JsonSchemaType.Number, isIeeeFloatingPoint: true), - [typeof(decimal)] = new(JsonSchemaType.Number), -#if NET6_0_OR_GREATER - [typeof(Half)] = new(JsonSchemaType.Number, isIeeeFloatingPoint: true), -#endif -#if NET7_0_OR_GREATER - [typeof(UInt128)] = new(JsonSchemaType.Integer), - [typeof(Int128)] = new(JsonSchemaType.Integer), -#endif - [typeof(char)] = new(JsonSchemaType.String), - [typeof(string)] = new(JsonSchemaType.String), - [typeof(byte[])] = new(JsonSchemaType.String), - [typeof(Memory)] = new(JsonSchemaType.String), - [typeof(ReadOnlyMemory)] = new(JsonSchemaType.String), - [typeof(DateTime)] = new(JsonSchemaType.String, format: "date-time"), - [typeof(DateTimeOffset)] = new(JsonSchemaType.String, format: "date-time"), - - // TimeSpan is represented as a string in the format "[-][d.]hh:mm:ss[.fffffff]". - [typeof(TimeSpan)] = new(JsonSchemaType.String, pattern: @"^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$"), -#if NET6_0_OR_GREATER - [typeof(DateOnly)] = new(JsonSchemaType.String, format: "date"), - [typeof(TimeOnly)] = new(JsonSchemaType.String, format: "time"), -#endif - [typeof(Guid)] = new(JsonSchemaType.String, format: "uuid"), - [typeof(Uri)] = new(JsonSchemaType.String, format: "uri"), - [typeof(Version)] = new(JsonSchemaType.String, format: @"^\d+(\.\d+){1,3}$"), - [typeof(JsonDocument)] = new(JsonSchemaType.Any), - [typeof(JsonElement)] = new(JsonSchemaType.Any), - [typeof(JsonNode)] = new(JsonSchemaType.Any), - [typeof(JsonValue)] = new(JsonSchemaType.Any), - [typeof(JsonObject)] = new(JsonSchemaType.Object), - [typeof(JsonArray)] = new(JsonSchemaType.Array), - }; - - private static void ValidateOptions(JsonSerializerOptions options) - { - if (options.ReferenceHandler == ReferenceHandler.Preserve) - { - ThrowHelpers.ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported(); - } - - options.MakeReadOnly(); - } - - private static class ThrowHelpers - { - [DoesNotReturn] - public static void ThrowArgumentNullException(string name) => throw new ArgumentNullException(name); - - [DoesNotReturn] - public static void ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported() => - throw new NotSupportedException("Schema generation not supported with ReferenceHandler.Preserve enabled."); - - [DoesNotReturn] - public static void ThrowInvalidOperationException_TrimmedMethodParameters(MethodBase method) => - throw new InvalidOperationException($"The parameters for method '{method}' have been trimmed away."); - - [DoesNotReturn] - public static void ThrowInvalidOperationException_MaxDepthReached() => - throw new InvalidOperationException("The maximum depth of the schema has been reached."); - } -} diff --git a/src/OpenApi/src/Schemas/JsonSchemaMapper/JsonSchemaMapperConfiguration.cs b/src/OpenApi/src/Schemas/JsonSchemaMapper/JsonSchemaMapperConfiguration.cs deleted file mode 100644 index 32b09fad2050..000000000000 --- a/src/OpenApi/src/Schemas/JsonSchemaMapper/JsonSchemaMapperConfiguration.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.ComponentModel; -using System.Text.Json.Nodes; - -namespace JsonSchemaMapper; - -/// -/// Controls the behavior of the class. -/// -#if EXPOSE_JSON_SCHEMA_MAPPER -public -#else -internal -#endif - class JsonSchemaMapperConfiguration -{ - /// - /// Gets the default configuration object used by . - /// - public static JsonSchemaMapperConfiguration Default { get; } = new(); - - private readonly int _maxDepth = 64; - - /// - /// Determines whether schema references using JSON pointers should be generated for repeated complex types. - /// - /// - /// Defaults to . Should be left enabled if recursive types (e.g. trees, linked lists) are expected. - /// - public bool AllowSchemaReferences { get; init; } = true; - - /// - /// Determines whether the '$schema' property should be included in the root schema document. - /// - /// - /// Defaults to true. - /// - public bool IncludeSchemaVersion { get; init; } = true; - - /// - /// Determines whether the should be resolved for types and properties. - /// - /// - /// Defaults to true. - /// - public bool ResolveDescriptionAttributes { get; init; } = true; - - /// - /// Determines the nullability behavior of reference types in the generated schema. - /// - /// - /// Defaults to . Currently JsonSerializer - /// doesn't recognize non-nullable reference types (https://github.com/dotnet/runtime/issues/1256) - /// so the serializer will always treat them as nullable. Setting to - /// improves accuracy of the generated schema with respect to the actual serialization behavior but can result in more noise. - /// - public ReferenceTypeNullability ReferenceTypeNullability { get; init; } = ReferenceTypeNullability.Annotated; - - /// - /// Determines whether properties bound to non-optional constructor parameters should be flagged as required. - /// - /// - /// Defaults to true. Current STJ treats all constructor parameters as optional - /// (https://github.com/dotnet/runtime/issues/100075) so disabling this option - /// will generate schemas that are more compatible with the actual serialization behavior. - /// - public bool RequireConstructorParameters { get; init; } = true; - - /// - /// Defines a callback that is invoked for every schema that is generated within the type graph. - /// - public Action? OnSchemaGenerated { get; init; } - - /// - /// Determines the maximum permitted depth when traversing the generated type graph. - /// - /// Thrown when the value is less than 0. - /// - /// Defaults to 64. - /// - public int MaxDepth - { - get => _maxDepth; - init - { - if (value < 0) - { - Throw(); - static void Throw() => throw new ArgumentOutOfRangeException(nameof(value)); - } - - _maxDepth = value; - } - } -} diff --git a/src/OpenApi/src/Schemas/JsonSchemaMapper/NullabilityInfo.cs b/src/OpenApi/src/Schemas/JsonSchemaMapper/NullabilityInfo.cs deleted file mode 100644 index 9d39a80325c5..000000000000 --- a/src/OpenApi/src/Schemas/JsonSchemaMapper/NullabilityInfo.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET6_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; - -namespace System.Reflection -{ - /// - /// A class that represents nullability info. - /// - [ExcludeFromCodeCoverage] - internal sealed class NullabilityInfo - { - internal NullabilityInfo(Type type, NullabilityState readState, NullabilityState writeState, - NullabilityInfo? elementType, NullabilityInfo[] typeArguments) - { - Type = type; - ReadState = readState; - WriteState = writeState; - ElementType = elementType; - GenericTypeArguments = typeArguments; - } - - /// - /// The of the member or generic parameter - /// to which this NullabilityInfo belongs. - /// - public Type Type { get; } - - /// - /// The nullability read state of the member. - /// - public NullabilityState ReadState { get; internal set; } - - /// - /// The nullability write state of the member. - /// - public NullabilityState WriteState { get; internal set; } - - /// - /// If the member type is an array, gives the of the elements of the array, null otherwise. - /// - public NullabilityInfo? ElementType { get; } - - /// - /// If the member type is a generic type, gives the array of for each type parameter. - /// - public NullabilityInfo[] GenericTypeArguments { get; } - } - - /// - /// An enum that represents nullability state. - /// - internal enum NullabilityState - { - /// - /// Nullability context not enabled (oblivious) - /// - Unknown, - - /// - /// Non nullable value or reference type - /// - NotNull, - - /// - /// Nullable value or reference type - /// - Nullable, - } -} -#endif \ No newline at end of file diff --git a/src/OpenApi/src/Schemas/JsonSchemaMapper/NullabilityInfoContext.cs b/src/OpenApi/src/Schemas/JsonSchemaMapper/NullabilityInfoContext.cs deleted file mode 100644 index aa332afc1aa4..000000000000 --- a/src/OpenApi/src/Schemas/JsonSchemaMapper/NullabilityInfoContext.cs +++ /dev/null @@ -1,667 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET6_0_OR_GREATER -namespace System.Reflection -{ - /// - /// Provides APIs for populating nullability information/context from reflection members: - /// , , and . - /// - [ExcludeFromCodeCoverage] - internal sealed class NullabilityInfoContext - { - private const string CompilerServicesNameSpace = "System.Runtime.CompilerServices"; - private readonly Dictionary _publicOnlyModules = new(); - private readonly Dictionary _context = new(); - - internal static bool IsSupported { get; } = - AppContext.TryGetSwitch("System.Reflection.NullabilityInfoContext.IsSupported", out bool isSupported) ? isSupported : true; - - [Flags] - private enum NotAnnotatedStatus - { - None = 0x0, // no restriction, all members annotated - Private = 0x1, // private members not annotated - Internal = 0x2, // internal members not annotated - } - - private NullabilityState? GetNullableContext(MemberInfo? memberInfo) - { - while (memberInfo != null) - { - if (_context.TryGetValue(memberInfo, out NullabilityState state)) - { - return state; - } - - foreach (CustomAttributeData attribute in memberInfo.GetCustomAttributesData()) - { - if (attribute.AttributeType.Name == "NullableContextAttribute" && - attribute.AttributeType.Namespace == CompilerServicesNameSpace && - attribute.ConstructorArguments.Count == 1) - { - state = TranslateByte(attribute.ConstructorArguments[0].Value); - _context.Add(memberInfo, state); - return state; - } - } - - memberInfo = memberInfo.DeclaringType; - } - - return null; - } - - /// - /// Populates for the given . - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the parameterInfo parameter is null. - /// . - public NullabilityInfo Create(ParameterInfo parameterInfo) - { - EnsureIsSupported(); - - IList attributes = parameterInfo.GetCustomAttributesData(); - NullableAttributeStateParser parser = parameterInfo.Member is MethodBase method && IsPrivateOrInternalMethodAndAnnotationDisabled(method) - ? NullableAttributeStateParser.Unknown - : CreateParser(attributes); - NullabilityInfo nullability = GetNullabilityInfo(parameterInfo.Member, parameterInfo.ParameterType, parser); - - if (nullability.ReadState != NullabilityState.Unknown) - { - CheckParameterMetadataType(parameterInfo, nullability); - } - - CheckNullabilityAttributes(nullability, attributes); - return nullability; - } - - private void CheckParameterMetadataType(ParameterInfo parameter, NullabilityInfo nullability) - { - ParameterInfo? metaParameter; - MemberInfo metaMember; - - switch (parameter.Member) - { - case ConstructorInfo ctor: - var metaCtor = (ConstructorInfo)GetMemberMetadataDefinition(ctor); - metaMember = metaCtor; - metaParameter = GetMetaParameter(metaCtor, parameter); - break; - - case MethodInfo method: - MethodInfo metaMethod = GetMethodMetadataDefinition(method); - metaMember = metaMethod; - metaParameter = string.IsNullOrEmpty(parameter.Name) ? metaMethod.ReturnParameter : GetMetaParameter(metaMethod, parameter); - break; - - default: - return; - } - - if (metaParameter != null) - { - CheckGenericParameters(nullability, metaMember, metaParameter.ParameterType, parameter.Member.ReflectedType); - } - } - - private static ParameterInfo? GetMetaParameter(MethodBase metaMethod, ParameterInfo parameter) - { - var parameters = metaMethod.GetParameters(); - for (int i = 0; i < parameters.Length; i++) - { - if (parameter.Position == i && - parameter.Name == parameters[i].Name) - { - return parameters[i]; - } - } - - return null; - } - - private static MethodInfo GetMethodMetadataDefinition(MethodInfo method) - { - if (method.IsGenericMethod && !method.IsGenericMethodDefinition) - { - method = method.GetGenericMethodDefinition(); - } - - return (MethodInfo)GetMemberMetadataDefinition(method); - } - - private static void CheckNullabilityAttributes(NullabilityInfo nullability, IList attributes) - { - var codeAnalysisReadState = NullabilityState.Unknown; - var codeAnalysisWriteState = NullabilityState.Unknown; - - foreach (CustomAttributeData attribute in attributes) - { - if (attribute.AttributeType.Namespace == "System.Diagnostics.CodeAnalysis") - { - if (attribute.AttributeType.Name == "NotNullAttribute") - { - codeAnalysisReadState = NullabilityState.NotNull; - } - else if ((attribute.AttributeType.Name == "MaybeNullAttribute" || - attribute.AttributeType.Name == "MaybeNullWhenAttribute") && - codeAnalysisReadState == NullabilityState.Unknown && - !IsValueTypeOrValueTypeByRef(nullability.Type)) - { - codeAnalysisReadState = NullabilityState.Nullable; - } - else if (attribute.AttributeType.Name == "DisallowNullAttribute") - { - codeAnalysisWriteState = NullabilityState.NotNull; - } - else if (attribute.AttributeType.Name == "AllowNullAttribute" && - codeAnalysisWriteState == NullabilityState.Unknown && - !IsValueTypeOrValueTypeByRef(nullability.Type)) - { - codeAnalysisWriteState = NullabilityState.Nullable; - } - } - } - - if (codeAnalysisReadState != NullabilityState.Unknown) - { - nullability.ReadState = codeAnalysisReadState; - } - - if (codeAnalysisWriteState != NullabilityState.Unknown) - { - nullability.WriteState = codeAnalysisWriteState; - } - } - - /// - /// Populates for the given . - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the propertyInfo parameter is null. - /// . - public NullabilityInfo Create(PropertyInfo propertyInfo) - { - EnsureIsSupported(); - - MethodInfo? getter = propertyInfo.GetGetMethod(true); - MethodInfo? setter = propertyInfo.GetSetMethod(true); - bool annotationsDisabled = (getter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(getter)) - && (setter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(setter)); - NullableAttributeStateParser parser = annotationsDisabled ? NullableAttributeStateParser.Unknown : CreateParser(propertyInfo.GetCustomAttributesData()); - NullabilityInfo nullability = GetNullabilityInfo(propertyInfo, propertyInfo.PropertyType, parser); - - if (getter != null) - { - CheckNullabilityAttributes(nullability, getter.ReturnParameter.GetCustomAttributesData()); - } - else - { - nullability.ReadState = NullabilityState.Unknown; - } - - if (setter != null) - { - CheckNullabilityAttributes(nullability, setter.GetParameters().Last().GetCustomAttributesData()); - } - else - { - nullability.WriteState = NullabilityState.Unknown; - } - - return nullability; - } - - private bool IsPrivateOrInternalMethodAndAnnotationDisabled(MethodBase method) - { - if ((method.IsPrivate || method.IsFamilyAndAssembly || method.IsAssembly) && - IsPublicOnly(method.IsPrivate, method.IsFamilyAndAssembly, method.IsAssembly, method.Module)) - { - return true; - } - - return false; - } - - /// - /// Populates for the given . - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the eventInfo parameter is null. - /// . - public NullabilityInfo Create(EventInfo eventInfo) - { - EnsureIsSupported(); - - return GetNullabilityInfo(eventInfo, eventInfo.EventHandlerType!, CreateParser(eventInfo.GetCustomAttributesData())); - } - - /// - /// Populates for the given - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the fieldInfo parameter is null. - /// . - public NullabilityInfo Create(FieldInfo fieldInfo) - { - EnsureIsSupported(); - - IList attributes = fieldInfo.GetCustomAttributesData(); - NullableAttributeStateParser parser = IsPrivateOrInternalFieldAndAnnotationDisabled(fieldInfo) ? NullableAttributeStateParser.Unknown : CreateParser(attributes); - NullabilityInfo nullability = GetNullabilityInfo(fieldInfo, fieldInfo.FieldType, parser); - CheckNullabilityAttributes(nullability, attributes); - return nullability; - } - - private static void EnsureIsSupported() - { - if (!IsSupported) - { - throw new InvalidOperationException("NullabilityInfoContext is not supported"); - } - } - - private bool IsPrivateOrInternalFieldAndAnnotationDisabled(FieldInfo fieldInfo) - { - if ((fieldInfo.IsPrivate || fieldInfo.IsFamilyAndAssembly || fieldInfo.IsAssembly) && - IsPublicOnly(fieldInfo.IsPrivate, fieldInfo.IsFamilyAndAssembly, fieldInfo.IsAssembly, fieldInfo.Module)) - { - return true; - } - - return false; - } - - private bool IsPublicOnly(bool isPrivate, bool isFamilyAndAssembly, bool isAssembly, Module module) - { - if (!_publicOnlyModules.TryGetValue(module, out NotAnnotatedStatus value)) - { - value = PopulateAnnotationInfo(module.GetCustomAttributesData()); - _publicOnlyModules.Add(module, value); - } - - if (value == NotAnnotatedStatus.None) - { - return false; - } - - if (((isPrivate || isFamilyAndAssembly) && value.HasFlag(NotAnnotatedStatus.Private)) || - (isAssembly && value.HasFlag(NotAnnotatedStatus.Internal))) - { - return true; - } - - return false; - } - - private static NotAnnotatedStatus PopulateAnnotationInfo(IList customAttributes) - { - foreach (CustomAttributeData attribute in customAttributes) - { - if (attribute.AttributeType.Name == "NullablePublicOnlyAttribute" && - attribute.AttributeType.Namespace == CompilerServicesNameSpace && - attribute.ConstructorArguments.Count == 1) - { - if (attribute.ConstructorArguments[0].Value is bool boolValue && boolValue) - { - return NotAnnotatedStatus.Internal | NotAnnotatedStatus.Private; - } - else - { - return NotAnnotatedStatus.Private; - } - } - } - - return NotAnnotatedStatus.None; - } - - private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser) - { - int index = 0; - NullabilityInfo nullability = GetNullabilityInfo(memberInfo, type, parser, ref index); - - if (nullability.ReadState != NullabilityState.Unknown) - { - TryLoadGenericMetaTypeNullability(memberInfo, nullability); - } - - return nullability; - } - - private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser, ref int index) - { - NullabilityState state = NullabilityState.Unknown; - NullabilityInfo? elementState = null; - NullabilityInfo[] genericArgumentsState = Array.Empty(); - Type underlyingType = type; - - if (underlyingType.IsByRef || underlyingType.IsPointer) - { - underlyingType = underlyingType.GetElementType()!; - } - - if (underlyingType.IsValueType) - { - if (Nullable.GetUnderlyingType(underlyingType) is { } nullableUnderlyingType) - { - underlyingType = nullableUnderlyingType; - state = NullabilityState.Nullable; - } - else - { - state = NullabilityState.NotNull; - } - - if (underlyingType.IsGenericType) - { - ++index; - } - } - else - { - if (!parser.ParseNullableState(index++, ref state) - && GetNullableContext(memberInfo) is { } contextState) - { - state = contextState; - } - - if (underlyingType.IsArray) - { - elementState = GetNullabilityInfo(memberInfo, underlyingType.GetElementType()!, parser, ref index); - } - } - - if (underlyingType.IsGenericType) - { - Type[] genericArguments = underlyingType.GetGenericArguments(); - genericArgumentsState = new NullabilityInfo[genericArguments.Length]; - - for (int i = 0; i < genericArguments.Length; i++) - { - genericArgumentsState[i] = GetNullabilityInfo(memberInfo, genericArguments[i], parser, ref index); - } - } - - return new NullabilityInfo(type, state, state, elementState, genericArgumentsState); - } - - private static NullableAttributeStateParser CreateParser(IList customAttributes) - { - foreach (CustomAttributeData attribute in customAttributes) - { - if (attribute.AttributeType.Name == "NullableAttribute" && - attribute.AttributeType.Namespace == CompilerServicesNameSpace && - attribute.ConstructorArguments.Count == 1) - { - return new NullableAttributeStateParser(attribute.ConstructorArguments[0].Value); - } - } - - return new NullableAttributeStateParser(null); - } - - private void TryLoadGenericMetaTypeNullability(MemberInfo memberInfo, NullabilityInfo nullability) - { - MemberInfo? metaMember = GetMemberMetadataDefinition(memberInfo); - Type? metaType = null; - if (metaMember is FieldInfo field) - { - metaType = field.FieldType; - } - else if (metaMember is PropertyInfo property) - { - metaType = GetPropertyMetaType(property); - } - - if (metaType != null) - { - CheckGenericParameters(nullability, metaMember!, metaType, memberInfo.ReflectedType); - } - } - - private static MemberInfo GetMemberMetadataDefinition(MemberInfo member) - { - Type? type = member.DeclaringType; - if ((type != null) && type.IsGenericType && !type.IsGenericTypeDefinition) - { - return NullabilityInfoHelpers.GetMemberWithSameMetadataDefinitionAs(type.GetGenericTypeDefinition(), member); - } - - return member; - } - - private static Type GetPropertyMetaType(PropertyInfo property) - { - if (property.GetGetMethod(true) is MethodInfo method) - { - return method.ReturnType; - } - - return property.GetSetMethod(true)!.GetParameters()[0].ParameterType; - } - - private void CheckGenericParameters(NullabilityInfo nullability, MemberInfo metaMember, Type metaType, Type? reflectedType) - { - if (metaType.IsGenericParameter) - { - if (nullability.ReadState == NullabilityState.NotNull) - { - TryUpdateGenericParameterNullability(nullability, metaType, reflectedType); - } - } - else if (metaType.ContainsGenericParameters) - { - if (nullability.GenericTypeArguments.Length > 0) - { - Type[] genericArguments = metaType.GetGenericArguments(); - - for (int i = 0; i < genericArguments.Length; i++) - { - CheckGenericParameters(nullability.GenericTypeArguments[i], metaMember, genericArguments[i], reflectedType); - } - } - else if (nullability.ElementType is { } elementNullability && metaType.IsArray) - { - CheckGenericParameters(elementNullability, metaMember, metaType.GetElementType()!, reflectedType); - } - - // We could also follow this branch for metaType.IsPointer, but since pointers must be unmanaged this - // will be a no-op regardless - else if (metaType.IsByRef) - { - CheckGenericParameters(nullability, metaMember, metaType.GetElementType()!, reflectedType); - } - } - } - - private bool TryUpdateGenericParameterNullability(NullabilityInfo nullability, Type genericParameter, Type? reflectedType) - { - Debug.Assert(genericParameter.IsGenericParameter); - - if (reflectedType is not null - && !genericParameter.IsGenericMethodParameter() - && TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, reflectedType, reflectedType)) - { - return true; - } - - if (IsValueTypeOrValueTypeByRef(nullability.Type)) - { - return true; - } - - var state = NullabilityState.Unknown; - if (CreateParser(genericParameter.GetCustomAttributesData()).ParseNullableState(0, ref state)) - { - nullability.ReadState = state; - nullability.WriteState = state; - return true; - } - - if (GetNullableContext(genericParameter) is { } contextState) - { - nullability.ReadState = contextState; - nullability.WriteState = contextState; - return true; - } - - return false; - } - - private bool TryUpdateGenericTypeParameterNullabilityFromReflectedType(NullabilityInfo nullability, Type genericParameter, Type context, Type reflectedType) - { - Debug.Assert(genericParameter.IsGenericParameter && !genericParameter.IsGenericMethodParameter()); - - Type contextTypeDefinition = context.IsGenericType && !context.IsGenericTypeDefinition ? context.GetGenericTypeDefinition() : context; - if (genericParameter.DeclaringType == contextTypeDefinition) - { - return false; - } - - Type? baseType = contextTypeDefinition.BaseType; - if (baseType is null) - { - return false; - } - - if (!baseType.IsGenericType - || (baseType.IsGenericTypeDefinition ? baseType : baseType.GetGenericTypeDefinition()) != genericParameter.DeclaringType) - { - return TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, baseType, reflectedType); - } - - Type[] genericArguments = baseType.GetGenericArguments(); - Type genericArgument = genericArguments[genericParameter.GenericParameterPosition]; - if (genericArgument.IsGenericParameter) - { - return TryUpdateGenericParameterNullability(nullability, genericArgument, reflectedType); - } - - NullableAttributeStateParser parser = CreateParser(contextTypeDefinition.GetCustomAttributesData()); - int nullabilityStateIndex = 1; // start at 1 since index 0 is the type itself - for (int i = 0; i < genericParameter.GenericParameterPosition; i++) - { - nullabilityStateIndex += CountNullabilityStates(genericArguments[i]); - } - - return TryPopulateNullabilityInfo(nullability, parser, ref nullabilityStateIndex); - - static int CountNullabilityStates(Type type) - { - Type underlyingType = Nullable.GetUnderlyingType(type) ?? type; - if (underlyingType.IsGenericType) - { - int count = 1; - foreach (Type genericArgument in underlyingType.GetGenericArguments()) - { - count += CountNullabilityStates(genericArgument); - } - - return count; - } - - if (underlyingType.HasElementType) - { - return (underlyingType.IsArray ? 1 : 0) + CountNullabilityStates(underlyingType.GetElementType()!); - } - - return type.IsValueType ? 0 : 1; - } - } - -#pragma warning disable SA1204 // Static elements should appear before instance elements - private static bool TryPopulateNullabilityInfo(NullabilityInfo nullability, NullableAttributeStateParser parser, ref int index) -#pragma warning restore SA1204 // Static elements should appear before instance elements - { - bool isValueType = IsValueTypeOrValueTypeByRef(nullability.Type); - if (!isValueType) - { - var state = NullabilityState.Unknown; - if (!parser.ParseNullableState(index, ref state)) - { - return false; - } - - nullability.ReadState = state; - nullability.WriteState = state; - } - - if (!isValueType || (Nullable.GetUnderlyingType(nullability.Type) ?? nullability.Type).IsGenericType) - { - index++; - } - - if (nullability.GenericTypeArguments.Length > 0) - { - foreach (NullabilityInfo genericTypeArgumentNullability in nullability.GenericTypeArguments) - { - TryPopulateNullabilityInfo(genericTypeArgumentNullability, parser, ref index); - } - } - else if (nullability.ElementType is { } elementTypeNullability) - { - TryPopulateNullabilityInfo(elementTypeNullability, parser, ref index); - } - - return true; - } - - private static NullabilityState TranslateByte(object? value) - { - return value is byte b ? TranslateByte(b) : NullabilityState.Unknown; - } - - private static NullabilityState TranslateByte(byte b) => - b switch - { - 1 => NullabilityState.NotNull, - 2 => NullabilityState.Nullable, - _ => NullabilityState.Unknown - }; - - private static bool IsValueTypeOrValueTypeByRef(Type type) => - type.IsValueType || ((type.IsByRef || type.IsPointer) && type.GetElementType()!.IsValueType); - - private readonly struct NullableAttributeStateParser - { - private static readonly object UnknownByte = (byte)0; - - private readonly object? _nullableAttributeArgument; - - public NullableAttributeStateParser(object? nullableAttributeArgument) - { - this._nullableAttributeArgument = nullableAttributeArgument; - } - - public static NullableAttributeStateParser Unknown => new(UnknownByte); - - public bool ParseNullableState(int index, ref NullabilityState state) - { - switch (this._nullableAttributeArgument) - { - case byte b: - state = TranslateByte(b); - return true; - case ReadOnlyCollection args - when index < args.Count && args[index].Value is byte elementB: - state = TranslateByte(elementB); - return true; - default: - return false; - } - } - } - } -} -#endif diff --git a/src/OpenApi/src/Schemas/JsonSchemaMapper/NullabilityInfoHelpers.cs b/src/OpenApi/src/Schemas/JsonSchemaMapper/NullabilityInfoHelpers.cs deleted file mode 100644 index e50325bbb00a..000000000000 --- a/src/OpenApi/src/Schemas/JsonSchemaMapper/NullabilityInfoHelpers.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET6_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; - -namespace System.Reflection -{ - /// - /// Polyfills for System.Private.CoreLib internals. - /// - [ExcludeFromCodeCoverage] - internal static class NullabilityInfoHelpers - { - public static MemberInfo GetMemberWithSameMetadataDefinitionAs(Type type, MemberInfo member) - { - const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; - foreach (var info in type.GetMembers(all)) - { - if (info.HasSameMetadataDefinitionAs(member)) - { - return info; - } - } - - throw new MissingMemberException(type.FullName, member.Name); - } - - // https://github.com/dotnet/runtime/blob/main/src/coreclr/System.Private.CoreLib/src/System/Reflection/MemberInfo.Internal.cs - public static bool HasSameMetadataDefinitionAs(this MemberInfo target, MemberInfo other) - { - return target.MetadataToken == other.MetadataToken && - target.Module.Equals(other.Module); - } - - // https://github.com/dotnet/runtime/issues/23493 - public static bool IsGenericMethodParameter(this Type target) - { - return target.IsGenericParameter && - target.DeclaringMethod != null; - } - } -} -#endif \ No newline at end of file diff --git a/src/OpenApi/src/Schemas/JsonSchemaMapper/ReferenceTypeNullability.cs b/src/OpenApi/src/Schemas/JsonSchemaMapper/ReferenceTypeNullability.cs deleted file mode 100644 index d61bd4724141..000000000000 --- a/src/OpenApi/src/Schemas/JsonSchemaMapper/ReferenceTypeNullability.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace JsonSchemaMapper; - -/// -/// Controls the nullable behavior of reference types in the generated schema. -/// -#if EXPOSE_JSON_SCHEMA_MAPPER -public -#else -internal -#endif - enum ReferenceTypeNullability -{ - /// - /// Always treat reference types as nullable. Follows the built-in behavior - /// of the serializer (cf. https://github.com/dotnet/runtime/issues/1256). - /// - AlwaysNullable, - - /// - /// Treat reference types as nullable only if they are annotated with a nullable reference type modifier. - /// - Annotated, - - /// - /// Always treat reference types as non-nullable. - /// - NeverNullable, -} diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 7e7ed1ef53a2..34987212ab95 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -1,12 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO.Pipelines; using System.Linq; +using System.Reflection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc; @@ -268,7 +270,9 @@ private async Task GetResponseAsync(ApiDescription apiDescripti }, Required = IsRequired(parameter), Schema = await _componentService.GetOrCreateSchemaAsync(parameter.Type, parameter, cancellationToken), + Description = GetParameterDescriptionFromAttribute(parameter) }; + parameters ??= []; parameters.Add(openApiParameter); } @@ -284,6 +288,13 @@ private static bool IsRequired(ApiParameterDescription parameter) return parameter.Source == BindingSource.Path || parameter.IsRequired || hasRequiredAttribute; } + // Apply [Description] attributes on the parameter to the top-level OpenApiParameter object and not the schema. + private static string? GetParameterDescriptionFromAttribute(ApiParameterDescription parameter) => + parameter.ParameterDescriptor is IParameterInfoParameterDescriptor { ParameterInfo: { } parameterInfo } && + parameterInfo.GetCustomAttributes().OfType().LastOrDefault() is { } descriptionAttribute ? + descriptionAttribute.Description : + null; + private async Task GetRequestBodyAsync(ApiDescription description, CancellationToken cancellationToken) { // Only one parameter can be bound from the body in each request. @@ -447,7 +458,8 @@ private async Task GetJsonRequestBody(IList() + Content = new Dictionary(), + Description = GetParameterDescriptionFromAttribute(bodyParameter) }; foreach (var requestForm in supportedRequestFormats) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index d51f8d32c80c..8ee0c89ec365 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -8,8 +8,8 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Schema; using System.Text.Json.Serialization.Metadata; -using JsonSchemaMapper; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Mvc.ApiExplorer; @@ -53,39 +53,54 @@ internal sealed class OpenApiSchemaService( }) }; - private readonly JsonSchemaMapperConfiguration _configuration = new() + private readonly JsonSchemaExporterOptions _configuration = new() { - OnSchemaGenerated = (context, schema) => + TreatNullObliviousAsNonNullable = true, + TransformSchemaNode = (context, schema) => { var type = context.TypeInfo.Type; // Fix up schemas generated for IFormFile, IFormFileCollection, Stream, and PipeReader // that appear as properties within complex types. if (type == typeof(IFormFile) || type == typeof(Stream) || type == typeof(PipeReader)) { - schema.Clear(); - schema[OpenApiSchemaKeywords.TypeKeyword] = "string"; - schema[OpenApiSchemaKeywords.FormatKeyword] = "binary"; + schema = new JsonObject + { + [OpenApiSchemaKeywords.TypeKeyword] = "string", + [OpenApiSchemaKeywords.FormatKeyword] = "binary" + }; } else if (type == typeof(IFormFileCollection)) { - schema.Clear(); - schema[OpenApiSchemaKeywords.TypeKeyword] = "array"; - schema[OpenApiSchemaKeywords.ItemsKeyword] = new JsonObject + schema = new JsonObject { - [OpenApiSchemaKeywords.TypeKeyword] = "string", - [OpenApiSchemaKeywords.FormatKeyword] = "binary" + [OpenApiSchemaKeywords.TypeKeyword] = "array", + [OpenApiSchemaKeywords.ItemsKeyword] = new JsonObject + { + [OpenApiSchemaKeywords.TypeKeyword] = "string", + [OpenApiSchemaKeywords.FormatKeyword] = "binary" + } }; } schema.ApplyPrimitiveTypesAndFormats(context); schema.ApplySchemaReferenceId(context); - if (context.GetCustomAttributes(typeof(ValidationAttribute)) is { } validationAttributes) + if (context.PropertyInfo is { AttributeProvider: { } attributeProvider }) { - schema.ApplyValidationAttributes(validationAttributes); - } - if (context.GetCustomAttributes(typeof(DefaultValueAttribute)).LastOrDefault() is DefaultValueAttribute defaultValueAttribute) - { - schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); + schema.ApplyNullabilityContextInfo(attributeProvider); + if (attributeProvider.GetCustomAttributes(inherit: false).OfType() is { } validationAttributes) + { + schema.ApplyValidationAttributes(validationAttributes); + } + if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DefaultValueAttribute defaultValueAttribute) + { + schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo); + } + if (attributeProvider.GetCustomAttributes(inherit: false).OfType().LastOrDefault() is DescriptionAttribute descriptionAttribute) + { + schema[OpenApiSchemaKeywords.DescriptionKeyword] = descriptionAttribute.Description; + } } + + return schema; } }; @@ -123,8 +138,6 @@ internal async Task ApplySchemaTransformersAsync(OpenApiSchema schema, Type type } } - private JsonObject CreateSchema(OpenApiSchemaKey key) - => key.ParameterInfo is not null - ? JsonSchemaMapper.JsonSchemaMapper.GetJsonSchema(_jsonSerializerOptions, key.ParameterInfo, _configuration) - : JsonSchemaMapper.JsonSchemaMapper.GetJsonSchema(_jsonSerializerOptions, key.Type, _configuration); + private JsonNode CreateSchema(OpenApiSchemaKey key) + => JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, key.Type, _configuration); } diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs index 4d27ee5792e9..19fe48c57a2e 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs @@ -15,7 +15,7 @@ namespace Microsoft.AspNetCore.OpenApi; /// internal sealed class OpenApiSchemaStore { - private readonly Dictionary _schemas = new() + private readonly Dictionary _schemas = new() { // Pre-populate OpenAPI schemas for well-defined types in ASP.NET Core. [new OpenApiSchemaKey(typeof(IFormFile), null)] = new JsonObject @@ -58,7 +58,7 @@ internal sealed class OpenApiSchemaStore /// The key associated with the generated schema. /// A function used to generated the JSON object representing the schema. /// A representing the JSON schema associated with the key. - public JsonObject GetOrAdd(OpenApiSchemaKey key, Func valueFactory) + public JsonNode GetOrAdd(OpenApiSchemaKey key, Func valueFactory) { if (_schemas.TryGetValue(key, out var schema)) { diff --git a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index b829606d7471..7e4b0ff5dc5e 100644 --- a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -78,30 +78,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "anyOf": [ - { - "required": [ - "$type" - ], - "type": "object", - "properties": { - "$type": { } - } - }, - { - "required": [ - "$type" - ], - "type": "object", - "properties": { - "$type": { } - } - }, - { - "type": "object" - } - ] + "type": "object" } } } diff --git a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index f85c1f035a70..7bfff186438b 100644 --- a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -93,21 +93,19 @@ { "name": "id", "in": "query", + "description": "The ID associated with the Todo item.", "required": true, "schema": { - "type": "integer", - "description": "The ID associated with the Todo item.", - "format": "int32" + "$ref": "#/components/schemas/int" } }, { "name": "size", "in": "query", + "description": "The number of Todos to fetch", "required": true, "schema": { - "type": "integer", - "description": "The number of Todos to fetch", - "format": "int32" + "$ref": "#/components/schemas/int" } } ], diff --git a/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.ParameterSchemas.cs b/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.ParameterSchemas.cs index 419075b5adab..4356e98ee5db 100644 --- a/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.ParameterSchemas.cs +++ b/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.ParameterSchemas.cs @@ -418,6 +418,24 @@ await VerifyOpenApiDocument(builder, document => }); } + [Fact] + public async Task GetOpenApiParameters_HandlesParametersWithDescriptionAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api", ([Description("The ID of the entity")] int id) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[OperationType.Get]; + var parameter = Assert.Single(operation.Parameters); + Assert.Equal("The ID of the entity", parameter.Description); + }); + } + [Route("/api/{id}/{date}")] private void AcceptsParametersInModel(RouteParamsContainer model) { } diff --git a/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.RequestBodySchemas.cs b/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.RequestBodySchemas.cs index 5677ea5c8cb2..0524fb4cb7f8 100644 --- a/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.RequestBodySchemas.cs +++ b/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.RequestBodySchemas.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.IO.Pipelines; using Microsoft.AspNetCore.Builder; @@ -311,4 +312,147 @@ await VerifyOpenApiDocument(builder, document => }); }); } + + [Fact] + public async Task GetOpenApiRequestBody_HandlesDescriptionAttributeOnProperties() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (DescriptionTodo todo) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + var requestBody = operation.RequestBody.Content; + Assert.True(requestBody.TryGetValue("application/json", out var mediaType)); + Assert.Equal("object", mediaType.Schema.Type); + Assert.Collection(mediaType.Schema.Properties, + property => + { + Assert.Equal("id", property.Key); + Assert.Equal("integer", property.Value.Type); + Assert.Equal("int32", property.Value.Format); + Assert.Equal("The unique identifier for a todo item.", property.Value.Description); + }, + property => + { + Assert.Equal("title", property.Key); + Assert.Equal("string", property.Value.Type); + Assert.Equal("The title of the todo item.", property.Value.Description); + }, + property => + { + Assert.Equal("completed", property.Key); + Assert.Equal("boolean", property.Value.Type); + Assert.Equal("The completion status of the todo item.", property.Value.Description); + }, + property => + { + Assert.Equal("createdAt", property.Key); + Assert.Equal("string", property.Value.Type); + Assert.Equal("date-time", property.Value.Format); + Assert.Equal("The date and time the todo item was created.", property.Value.Description); + }); + }); + } + + [Fact] + public async Task GetOpenApiRequestBody_HandlesDescriptionAttributeOnParameter() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", ([Description("The todo item to create.")] DescriptionTodo todo) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.Equal("The todo item to create.", operation.RequestBody.Description); + }); + } + + [Fact] + public async Task GetOpenApiRequestBody_HandlesNullableProperties() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (NullablePropertiesType type) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[OperationType.Post]; + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + var schema = content.Value.Schema; + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("nullableInt", property.Key); + Assert.Equal("integer", property.Value.Type); + Assert.True(property.Value.Nullable); + }, + property => + { + Assert.Equal("nullableString", property.Key); + Assert.Equal("string", property.Value.Type); + Assert.True(property.Value.Nullable); + }, + property => + { + Assert.Equal("nullableBool", property.Key); + Assert.Equal("boolean", property.Value.Type); + Assert.True(property.Value.Nullable); + }, + property => + { + Assert.Equal("nullableDateTime", property.Key); + Assert.Equal("string", property.Value.Type); + Assert.Equal("date-time", property.Value.Format); + Assert.True(property.Value.Nullable); + }, + property => + { + Assert.Equal("nullableUri", property.Key); + Assert.Equal("string", property.Value.Type); + Assert.Equal("uri", property.Value.Format); + Assert.True(property.Value.Nullable); + }); + }); + } + + private class DescriptionTodo + { + [Description("The unique identifier for a todo item.")] + public int Id { get; set; } + + [Description("The title of the todo item.")] + public string Title { get; set; } + + [Description("The completion status of the todo item.")] + public bool Completed { get; set; } + + [Description("The date and time the todo item was created.")] + public DateTime CreatedAt { get; set; } + } + +#nullable enable + private class NullablePropertiesType + { + public int? NullableInt { get; set; } + public string? NullableString { get; set; } + public bool? NullableBool { get; set; } + public DateTime? NullableDateTime { get; set; } + public Uri? NullableUri { get; set; } + } +#nullable restore }