File: Services\OpenApiSchemaService\OpenApiSchemaService.ParameterSchemas.cs
Web Access
Project: src\src\OpenApi\test\Microsoft.AspNetCore.OpenApi.Tests\Microsoft.AspNetCore.OpenApi.Tests.csproj (Microsoft.AspNetCore.OpenApi.Tests)
// 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.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
 
public partial class OpenApiSchemaServiceTests : OpenApiDocumentServiceTestBase
{
#nullable enable
    public static object?[][] RouteParametersWithPrimitiveTypes =>
    [
        [(int id) => {}, "integer", "int32"],
        [(long id) => {}, "integer", "int64"],
        [(float id) => {}, "number", "float"],
        [(double id) => {}, "number", "double"],
        [(decimal id) => {}, "number", "double"],
        [(bool id) => {}, "boolean", null],
        [(string id) => {}, "string", null],
        [(char id) => {}, "string", "char"],
        [(byte id) => {}, "integer", "uint8"],
        [(byte[] id) => {}, "string", "byte"],
        [(short id) => {}, "integer", "int16"],
        [(ushort id) => {}, "integer", "uint16"],
        [(uint id) => {}, "integer", "uint32"],
        [(ulong id) => {}, "integer", "uint64"],
        [(Uri id) => {}, "string", "uri"],
        [(TimeOnly id) => {}, "string", "time"],
        [(DateOnly id) => {}, "string", "date"],
        [(int? id) => {}, "integer", "int32"],
        [(long? id) => {}, "integer", "int64"],
        [(float? id) => {}, "number", "float"],
        [(double? id) => {}, "number", "double"],
        [(decimal? id) => {}, "number", "double"],
        [(bool? id) => {}, "boolean", null],
        [(string? id) => {}, "string", null],
        [(char? id) => {}, "string", "char"],
        [(byte? id) => {}, "integer", "uint8"],
        [(byte[]? id) => {}, "string", "byte"],
        [(short? id) => {}, "integer", "int16"],
        [(ushort? id) => {}, "integer", "uint16"],
        [(uint? id) => {}, "integer", "uint32"],
        [(ulong? id) => {}, "integer", "uint64"],
        [(Uri? id) => {}, "string", "uri"],
        [(TimeOnly? id) => {}, "string", "time"],
        [(DateOnly? id) => {}, "string", "date"]
    ];
#nullable restore
 
    [Theory]
    [MemberData(nameof(RouteParametersWithPrimitiveTypes))]
    public async Task GetOpenApiParameters_HandlesRouteParameterWithPrimitiveType(Delegate requestHandler, string schemaType, string schemaFormat)
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        builder.MapGet("/api/{id}", requestHandler);
 
        // Assert
        await VerifyOpenApiDocument(builder, document =>
        {
            var operation = document.Paths["/api/{id}"].Operations[OperationType.Get];
            var parameter = Assert.Single(operation.Parameters);
            Assert.Equal(schemaType, parameter.Schema.Type);
            Assert.Equal(schemaFormat, parameter.Schema.Format);
            Assert.False(parameter.Schema.Nullable);
        });
    }
 
    public static object[][] RouteParametersWithParsableTypes =>
    [
        [(Guid id) => {}, "string", "uuid"],
        [(DateTime id) => {}, "string", "date-time"],
        [(DateTimeOffset id) => {}, "string", "date-time"],
        [(Uri id) => {}, "string", "uri"]
    ];
 
    [Theory]
    [MemberData(nameof(RouteParametersWithParsableTypes))]
    public async Task GetOpenApiParameters_HandlesRouteParameterWithParsableType(Delegate requestHandler, string schemaType, string schemaFormat)
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        builder.MapGet("/api/{id}", requestHandler);
 
        // Assert
        await VerifyOpenApiDocument(builder, document =>
        {
            var operation = document.Paths["/api/{id}"].Operations[OperationType.Get];
            var parameter = Assert.Single(operation.Parameters);
            Assert.Equal(schemaType, parameter.Schema.Type);
            Assert.Equal(schemaFormat, parameter.Schema.Format);
        });
    }
 
    [Theory]
    [InlineData("/api/{id:int}", "integer", "int32", null, null, null, null, null)]
    [InlineData("/api/{id:bool}", "boolean", null, null, null, null, null, null)]
    [InlineData("/api/{id:datetime}", "string", "date-time", null, null, null, null, null)]
    [InlineData("/api/{id:decimal}", "number", "double", null, null, null, null, null)]
    [InlineData("/api/{id:double}", "number", "double", null, null, null, null, null)]
    [InlineData("/api/{id:float}", "number", "float", null, null, null, null, null)]
    [InlineData("/api/{id:guid}", "string", "uuid", null, null, null, null, null)]
    [InlineData("/api/{id:long}", "integer", "int64", null, null, null, null, null)]
    [InlineData("/api/{id:minLength(4)}", "integer", "int32", null, null, 4, null, null)]
    [InlineData("/api/{id:maxLength(8)}", "integer", "int32", null, null, null, 8, null)]
    [InlineData("/api/{id:length(4, 8)}", "integer", "int32", null, null, 4, 8, null)]
    [InlineData("/api/{id:min(4)}", "integer", "int32", 4, null, null, null, null)]
    [InlineData("/api/{id:max(8)}", "integer", "int32", null, 8, null, null, null)]
    [InlineData("/api/{id:range(4, 8)}", "integer", "int32", 4, 8, null, null, null)]
    [InlineData("/api/{id:alpha}", "string", null, null, null, null, null, "^[A-Za-z]*$")]
    [InlineData("/api/{id:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)}", "string", null, null, null, null, null, "^\\d{3}-\\d{2}-\\d{4}$")]
    // First route constraint wins
    [InlineData("/api/{id:min(2):range(4, 8)}", "integer", "int32", 2, 8, null, null, null)]
    [InlineData("/api/{id::double:float}", "number", "double", null, null, null, null, null)]
    [InlineData("/api/{id::long:int}", "integer", "int64", null, null, null, null, null)]
    // Compatible route constraints are combined
    [InlineData("/api/{id:max(8):min(4)}", "integer", "int32", 4, 8, null, null, null)]
    [InlineData("/api/{id:maxLength(8):minLength(4)}", "integer", "int32", null, null, 4, 8, null)]
    public async Task GetOpenApiParameters_HandlesRouteParameterWithRouteConstraint(string routeTemplate, string type, string format, int? minimum, int? maximum, int? minLength, int? maxLength, string pattern)
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        builder.MapGet(routeTemplate, (int id) => {});
 
        // Assert
        await VerifyOpenApiDocument(builder, document =>
        {
            var path = Assert.Single(document.Paths);
            var operation = path.Value.Operations[OperationType.Get];
            var parameter = Assert.Single(operation.Parameters);
            Assert.Equal(type, parameter.Schema.Type);
            Assert.Equal(format, parameter.Schema.Format);
            Assert.Equal(minimum, parameter.Schema.Minimum);
            Assert.Equal(maximum, parameter.Schema.Maximum);
            Assert.Equal(minLength, parameter.Schema.MinLength);
            Assert.Equal(maxLength, parameter.Schema.MaxLength);
            Assert.Equal(pattern, parameter.Schema.Pattern);
        });
    }
 
#nullable enable
    public static object[][] RouteParametersWithDefaultValues =>
    [
        [(int id = 2) => { }, (IOpenApiAny defaultValue) => Assert.Equal(2, ((OpenApiInteger)defaultValue).Value)],
        [(float id = 3f) => { }, (IOpenApiAny defaultValue) => Assert.Equal(3, ((OpenApiInteger)defaultValue).Value)],
        [(string id = "test") => { }, (IOpenApiAny defaultValue) => Assert.Equal("test", ((OpenApiString)defaultValue).Value)],
        [(bool id = true) => { }, (IOpenApiAny defaultValue) => Assert.True(((OpenApiBoolean)defaultValue).Value)],
        [(TaskStatus status = TaskStatus.Canceled) => { }, (IOpenApiAny defaultValue) => Assert.Equal(6, ((OpenApiInteger)defaultValue).Value)],
        // Default value for enums is serialized as string when a converter is registered.
        [(Status status = Status.Pending) => { }, (IOpenApiAny defaultValue) => Assert.Equal("Pending", ((OpenApiString)defaultValue).Value)],
        [([DefaultValue(2)] int id) => { }, (IOpenApiAny defaultValue) => Assert.Equal(2, ((OpenApiInteger)defaultValue).Value)],
        [([DefaultValue(3f)] float id) => { }, (IOpenApiAny defaultValue) => Assert.Equal(3, ((OpenApiInteger)defaultValue).Value)],
        [([DefaultValue("test")] string id) => { }, (IOpenApiAny defaultValue) => Assert.Equal("test", ((OpenApiString)defaultValue).Value)],
        [([DefaultValue(true)] bool id) => { }, (IOpenApiAny defaultValue) => Assert.True(((OpenApiBoolean)defaultValue).Value)],
        [([DefaultValue(TaskStatus.Canceled)] TaskStatus status) => { }, (IOpenApiAny defaultValue) => Assert.Equal(6, ((OpenApiInteger)defaultValue).Value)],
        [([DefaultValue(Status.Pending)] Status status) => { }, (IOpenApiAny defaultValue) => Assert.Equal("Pending", ((OpenApiString)defaultValue).Value)],
        [([DefaultValue(null)] int? id) => { }, (IOpenApiAny defaultValue) => Assert.True(defaultValue is OpenApiNull)],
        [([DefaultValue(2)] int? id) => { }, (IOpenApiAny defaultValue) => Assert.Equal(2, ((OpenApiInteger)defaultValue).Value)],
        [([DefaultValue(null)] string? id) => { }, (IOpenApiAny defaultValue) => Assert.True(defaultValue is OpenApiNull)],
        [([DefaultValue("foo")] string? id) => { }, (IOpenApiAny defaultValue) => Assert.Equal("foo", ((OpenApiString)defaultValue).Value)],
        [([DefaultValue(null)] TaskStatus? status) => { }, (IOpenApiAny defaultValue) => Assert.True(defaultValue is OpenApiNull)],
        [([DefaultValue(TaskStatus.Canceled)] TaskStatus? status) => { }, (IOpenApiAny defaultValue) => Assert.Equal(6, ((OpenApiInteger)defaultValue).Value)],
    ];
 
    [Theory]
    [MemberData(nameof(RouteParametersWithDefaultValues))]
    public async Task GetOpenApiParameters_HandlesRouteParametersWithDefaultValue(Delegate requestHandler, Action<IOpenApiAny> assert)
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        builder.MapPost("/api/{id}", requestHandler);
 
        // Assert
        await VerifyOpenApiDocument(builder, document =>
        {
            var operation = document.Paths["/api/{id}"].Operations[OperationType.Post];
            var parameter = Assert.Single(operation.Parameters);
            var openApiDefault = parameter.Schema.Default;
            assert(openApiDefault);
        });
    }
#nullable restore
 
    [Fact]
    public async Task GetOpenApiParameters_HandlesEnumParameterWithoutConverter()
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        builder.MapGet("/api", (TaskStatus status) => { });
 
        // Assert -- that enums without a converter registered are
        // consumed as integer
        await VerifyOpenApiDocument(builder, document =>
        {
            var operation = document.Paths["/api"].Operations[OperationType.Get];
            var parameter = Assert.Single(operation.Parameters);
            Assert.Equal("integer", parameter.Schema.Type);
            Assert.Empty(parameter.Schema.Enum);
        });
    }
 
    [Fact]
    public async Task GetOpenApiParameters_HandlesEnumParameterWithConverter()
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        builder.MapGet("/api", (Status status) => { });
 
        // Assert -- that enums with a converter registered
        // are serialized with the `enum` value in the schema.
        await VerifyOpenApiDocument(builder, document =>
        {
            var operation = document.Paths["/api"].Operations[OperationType.Get];
            var parameter = Assert.Single(operation.Parameters);
            Assert.Null(parameter.Schema.Type);
            Assert.Collection(parameter.Schema.Enum,
            value =>
            {
                var openApiString = Assert.IsType<OpenApiString>(value);
                Assert.Equal("Pending", openApiString.Value);
            },
            value =>
            {
                var openApiString = Assert.IsType<OpenApiString>(value);
                Assert.Equal("Approved", openApiString.Value);
            },
            value =>
            {
                var openApiString = Assert.IsType<OpenApiString>(value);
                Assert.Equal("Rejected", openApiString.Value);
            });
        });
    }
 
    [Fact]
    public async Task GetOpenApiParameters_HandlesRouteParameterFromAsParameters()
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        builder.MapGet("/api/{id}/{date}", ([AsParameters] RouteParamsContainer routeParams) => {});
 
        // Assert
        await VerifyOpenApiDocument(builder, document =>
        {
            var operation = document.Paths["/api/{id}/{date}"].Operations[OperationType.Get];
            Assert.Collection(operation.Parameters, parameter =>
            {
                Assert.Equal("id", parameter.Name);
                Assert.Equal("string", parameter.Schema.Type);
                Assert.Equal("uuid", parameter.Schema.Format);
            },
            parameter =>
            {
                Assert.Equal("date", parameter.Name);
                Assert.Equal("string", parameter.Schema.Type);
                Assert.Equal("date-time", parameter.Schema.Format);
            });
        });
    }
 
    [Fact]
    public async Task GetOpenApiParameters_HandlesRouteParametersWithMvcModelBinding()
    {
        // Arrange
        var action = CreateActionDescriptor(nameof(AcceptsParametersInModel));
 
        // Assert
        await VerifyOpenApiDocument(action, document =>
        {
            var operation = document.Paths["/api/{id}/{date}"].Operations[OperationType.Get];
            Assert.Collection(operation.Parameters, parameter =>
            {
                Assert.Equal("Id", parameter.Name);
                Assert.Equal("string", parameter.Schema.Type);
                Assert.Equal("uuid", parameter.Schema.Format);
            },
            parameter =>
            {
                Assert.Equal("Date", parameter.Name);
                Assert.Equal("string", parameter.Schema.Type);
                Assert.Equal("date-time", parameter.Schema.Format);
            });
        });
    }
 
    [Fact]
    public async Task GetOpenApiParameters_HandlesRouteParametersWithValidationsInMvcModelBinding()
    {
        // Arrange
        var action = CreateActionDescriptor(nameof(AcceptsValidatableParametersInModel));
 
        // Assert
        await VerifyOpenApiDocument(action, document =>
        {
            var operation = document.Paths["/api/{id}/{name}"].Operations[OperationType.Get];
            Assert.Collection(operation.Parameters, parameter =>
            {
                Assert.Equal("Id", parameter.Name);
                Assert.Equal("string", parameter.Schema.Type);
                Assert.Equal("uuid", parameter.Schema.Format);
            },
            parameter =>
            {
                Assert.Equal("Name", parameter.Name);
                Assert.Equal("string", parameter.Schema.Type);
                Assert.Equal(5, parameter.Schema.MaxLength);
            });
        });
    }
 
    public static object[][] RouteParametersWithValidationAttributes =>
    [
        [([MaxLength(5)] string id) => {}, (OpenApiSchema schema) => Assert.Equal(5, schema.MaxLength)],
        [([MinLength(2)] string id) => {}, (OpenApiSchema schema) => Assert.Equal(2, schema.MinLength)],
        [([MaxLength(5)] int[] ids) => {}, (OpenApiSchema schema) => Assert.Equal(5, schema.MaxItems)],
        [([MinLength(2)] int[] id) => {}, (OpenApiSchema schema) => Assert.Equal(2, schema.MinItems)],
        [([Length(4, 8)] int[] id) => {}, (OpenApiSchema schema) => { Assert.Equal(4, schema.MinItems); Assert.Equal(8, schema.MaxItems); }],
        [([Range(4, 8)]int id) => {}, (OpenApiSchema schema) => { Assert.Equal(4, schema.Minimum); Assert.Equal(8, schema.Maximum); }],
        [([Range(typeof(DateTime), "2024-02-01", "2024-02-031")] DateTime id) => {}, (OpenApiSchema schema) => { Assert.Null(schema.Minimum); Assert.Null(schema.Maximum); }],
        [([StringLength(10)] string name) => {}, (OpenApiSchema schema) => { Assert.Equal(10, schema.MaxLength); Assert.Equal(0, schema.MinLength); }],
        [([StringLength(10, MinimumLength = 5)] string name) => {}, (OpenApiSchema schema) => { Assert.Equal(10, schema.MaxLength); Assert.Equal(5, schema.MinLength); }],
        [([Url] string url) => {}, (OpenApiSchema schema) => { Assert.Equal("string", schema.Type); Assert.Equal("uri", schema.Format); }],
        // Check that multiple attributes get applied correctly
        [([Url][StringLength(10)] string url) => {}, (OpenApiSchema schema) => { Assert.Equal("string", schema.Type); Assert.Equal("uri", schema.Format); Assert.Equal(10, schema.MaxLength); }],
        [([Base64String] string base64string) => {}, (OpenApiSchema schema) => { Assert.Equal("string", schema.Type); Assert.Equal("byte", schema.Format); }],
    ];
 
    [Theory]
    [MemberData(nameof(RouteParametersWithValidationAttributes))]
    public async Task GetOpenApiParameters_HandlesRouteParameterWithValidationAttributes(Delegate requestHandler, Action<OpenApiSchema> verifySchema)
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        builder.MapGet("/api/{id}", requestHandler);
 
        // Assert
        await VerifyOpenApiDocument(builder, document =>
        {
            var operation = document.Paths["/api/{id}"].Operations[OperationType.Get];
            var parameter = Assert.Single(operation.Parameters);
            verifySchema(parameter.Schema);
        });
    }
 
    public static object[][] RouteParametersWithRangeAttributes =>
    [
        [([Range(4, 8)] int id) => {}, (OpenApiSchema schema) => { Assert.Equal(4, schema.Minimum); Assert.Equal(8, schema.Maximum); }],
        [([Range(int.MinValue, int.MaxValue)] int id) => {}, (OpenApiSchema schema) => { Assert.Equal(int.MinValue, schema.Minimum); Assert.Equal(int.MaxValue, schema.Maximum); }],
        [([Range(0, double.MaxValue)] double id) => {}, (OpenApiSchema schema) => { Assert.Equal(0, schema.Minimum); Assert.Null(schema.Maximum); }],
        [([Range(typeof(double), "0", "1.79769313486232E+308")] double id) => {}, (OpenApiSchema schema) => { Assert.Equal(0, schema.Minimum); Assert.Null(schema.Maximum); }],
        [([Range(typeof(long), "-9223372036854775808", "9223372036854775807")] long id) => {}, (OpenApiSchema schema) => { Assert.Equal(long.MinValue, schema.Minimum); Assert.Equal(long.MaxValue, schema.Maximum); }],
        [([Range(typeof(DateTime), "2024-02-01", "2024-02-031")] DateTime id) => {}, (OpenApiSchema schema) => { Assert.Null(schema.Minimum); Assert.Null(schema.Maximum); }],
    ];
 
    [Theory]
    [MemberData(nameof(RouteParametersWithRangeAttributes))]
    public async Task GetOpenApiParameters_HandlesRouteParametersWithRangeAttributes(Delegate requestHandler, Action<OpenApiSchema> verifySchema)
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        builder.MapGet("/api/{id}", requestHandler);
 
        // Assert
        await VerifyOpenApiDocument(builder, document =>
        {
            var operation = document.Paths["/api/{id}"].Operations[OperationType.Get];
            var parameter = Assert.Single(operation.Parameters);
            verifySchema(parameter.Schema);
        });
    }
 
    public static object[][] RouteParametersWithRangeAttributes_CultureInfo =>
    [
        [([Range(typeof(DateTime), "2024-02-01", "2024-02-031")] DateTime id) => {}, (OpenApiSchema schema) => { Assert.Null(schema.Minimum); Assert.Null(schema.Maximum); }],
        [([Range(typeof(decimal), "1,99", "3,99")] decimal id) => {}, (OpenApiSchema schema) => { Assert.Equal(1.99m, schema.Minimum); Assert.Equal(3.99m, schema.Maximum); }],
        [([Range(typeof(decimal), "1,99", "3,99", ParseLimitsInInvariantCulture = true)] decimal id) => {}, (OpenApiSchema schema) => { Assert.Equal(199, schema.Minimum); Assert.Equal(399, schema.Maximum); }],
        [([Range(1000, 2000)] int id) => {}, (OpenApiSchema schema) => { Assert.Equal(1000, schema.Minimum); Assert.Equal(2000, schema.Maximum); }]
    ];
 
    [Theory]
    [MemberData(nameof(RouteParametersWithRangeAttributes_CultureInfo))]
    [UseCulture("fr-FR")]
    public async Task GetOpenApiParameters_HandlesRouteParametersWithRangeAttributes_CultureInfo(Delegate requestHandler, Action<OpenApiSchema> verifySchema)
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        builder.MapGet("/api/{id}", requestHandler);
 
        // Assert
        await VerifyOpenApiDocument(builder, document =>
        {
            var operation = document.Paths["/api/{id}"].Operations[OperationType.Get];
            var parameter = Assert.Single(operation.Parameters);
            verifySchema(parameter.Schema);
        });
    }
 
    [Fact]
    public async Task GetOpenApiParameters_HandlesParametersWithRequiredAttribute()
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act -- route parameters are always required so we test other
        // parameter sources here.
        builder.MapGet("/api-1", ([Required] string id) => { });
        builder.MapGet("/api-2", ([Required] int? age) => { });
        builder.MapGet("/api-3", ([Required] Guid guid) => { });
        builder.MapGet("/api-4", ([Required][FromHeader] DateTime date) => { });
 
        // Assert
        await VerifyOpenApiDocument(builder, document =>
        {
            foreach (var path in document.Paths.Values)
            {
                var operation = path.Operations[OperationType.Get];
                var parameter = Assert.Single(operation.Parameters);
                Assert.True(parameter.Required);
            }
        });
    }
 
    public static object[][] ArrayBasedQueryParameters =>
    [
        [(int[] id) => { }, "integer", false],
        [(int?[] id) => { }, "integer", true],
        [(Guid[] id) => { }, "string", false],
        [(Guid?[] id) => { }, "string", true],
        [(DateTime[] id) => { }, "string", false],
        [(DateTime?[] id) => { }, "string", true],
        [(DateTimeOffset[] id) => { }, "string", false],
        [(DateTimeOffset?[] id) => { }, "string", true],
        [(Uri[] id) => { }, "string", false],
    ];
 
    [Theory]
    [MemberData(nameof(ArrayBasedQueryParameters))]
    public async Task GetOpenApiParameters_HandlesArrayBasedTypes(Delegate requestHandler, string innerSchemaType, bool isNullable)
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        builder.MapGet("/api/", requestHandler);
 
        // Assert
        await VerifyOpenApiDocument(builder, document =>
        {
            var operation = document.Paths["/api"].Operations[OperationType.Get];
            var parameter = Assert.Single(operation.Parameters);
            Assert.Equal("array", parameter.Schema.Type);
            Assert.Equal(innerSchemaType, parameter.Schema.Items.Type);
            // Array items can be serialized to nullable values when the element
            // type is nullable. For example, array-of-ints?ints=1&ints=2&ints=&ints=4
            // will produce [1, 2, null, 4] when the parameter is int?[] ints.
            // When the element type is not nullable (int[] ints), the binding
            // will produce [1, 2, 0, 4]
            Assert.Equal(isNullable, parameter.Schema.Items.Nullable);
        });
    }
 
    [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) { }
 
    [Route("/api/{id}/{name}")]
    private void AcceptsValidatableParametersInModel(RouteParamsWithValidationsContainer model) { }
 
    private class RouteParamsContainer
    {
        [FromRoute]
        public Guid Id { get; set; }
 
        [FromRoute]
        public DateTime Date { get; set; }
    }
 
    private class RouteParamsWithValidationsContainer
    {
        [FromRoute]
        public Guid Id { get; set; }
 
        [FromRoute]
        [MaxLength(5)]
        public string Name { get; set; }
    }
 
    [Fact]
    public async Task SupportsParametersWithTypeConverter()
    {
        // Arrange
        var serviceCollection = new ServiceCollection();
        serviceCollection.ConfigureHttpJsonOptions(options =>
        {
            options.SerializerOptions.Converters.Add(new CustomTypeConverter());
        });
        var builder = CreateBuilder(serviceCollection);
 
        // Act
        builder.MapPost("/api", (CustomType id) => { });
 
        // Assert
        await VerifyOpenApiDocument(builder, document =>
        {
            var operation = document.Paths["/api"].Operations[OperationType.Post];
            Assert.NotNull(operation.RequestBody);
            Assert.NotNull(operation.RequestBody.Content);
            Assert.NotNull(operation.RequestBody.Content["application/json"]);
            Assert.NotNull(operation.RequestBody.Content["application/json"].Schema);
            // Type is null, it's up to the user to configure this via a custom schema
            // transformer for types with a converter.
            Assert.Null(operation.RequestBody.Content["application/json"].Schema.Type);
        });
    }
 
    public struct CustomType { }
 
    public class CustomTypeConverter : JsonConverter<CustomType>
    {
        public override CustomType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            return new CustomType();
        }
 
        public override void Write(Utf8JsonWriter writer, CustomType value, JsonSerializerOptions options)
        {
            throw new NotImplementedException();
        }
    }
 
    [Fact]
    public async Task SupportsParameterWithDynamicType()
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        builder.MapPost("/api", (dynamic id) => { });
 
        // Assert
        await VerifyOpenApiDocument(builder, document =>
        {
            var operation = document.Paths["/api"].Operations[OperationType.Post];
            Assert.NotNull(operation.RequestBody);
            Assert.NotNull(operation.RequestBody.Content);
            Assert.NotNull(operation.RequestBody.Content["application/json"]);
            Assert.NotNull(operation.RequestBody.Content["application/json"].Schema);
            // Type is null, it's up to the user to configure this via a custom schema
            // transformer for types with a converter.
            Assert.Null(operation.RequestBody.Content["application/json"].Schema.Type);
        });
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task SupportsParameterWithEnumType(bool useAction)
    {
        // Arrange
        if (!useAction)
        {
            var builder = CreateBuilder();
            builder.MapGet("/api/with-enum", (Status status) => status);
            await VerifyOpenApiDocument(builder, AssertOpenApiDocument);
        }
        else
        {
            var action = CreateActionDescriptor(nameof(GetItemStatus));
            await VerifyOpenApiDocument(action, AssertOpenApiDocument);
        }
 
        static void AssertOpenApiDocument(OpenApiDocument document)
        {
            var operation = document.Paths["/api/with-enum"].Operations[OperationType.Get];
            var parameter = Assert.Single(operation.Parameters);
            var response = Assert.Single(operation.Responses).Value.Content["application/json"].Schema;
            Assert.NotNull(parameter.Schema.Reference);
            Assert.Equal(parameter.Schema.Reference.Id, response.Reference.Id);
            var schema = parameter.Schema.GetEffective(document);
            Assert.Collection(schema.Enum,
            value =>
            {
                var openApiString = Assert.IsType<OpenApiString>(value);
                Assert.Equal("Pending", openApiString.Value);
            },
            value =>
            {
                var openApiString = Assert.IsType<OpenApiString>(value);
                Assert.Equal("Approved", openApiString.Value);
            },
            value =>
            {
                var openApiString = Assert.IsType<OpenApiString>(value);
                Assert.Equal("Rejected", openApiString.Value);
            });
        }
    }
 
    [Route("/api/with-enum")]
    private Status GetItemStatus([FromQuery] Status status) => status;
 
    [Fact]
    public async Task SupportsMvcActionWithAmbientRouteParameter()
    {
        // Arrange
        var action = CreateActionDescriptor(nameof(AmbientRouteParameter));
 
        // Assert
        await VerifyOpenApiDocument(action, document =>
        {
            var operation = document.Paths["/api/with-ambient-route-param/{versionId}"].Operations[OperationType.Get];
            var parameter = Assert.Single(operation.Parameters);
            Assert.Equal("string", parameter.Schema.Type);
        });
    }
 
    [Route("/api/with-ambient-route-param/{versionId}")]
    private void AmbientRouteParameter() { }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task SupportsRouteParameterWithCustomTryParse(bool useAction)
    {
        // Arrange
        var builder = CreateBuilder();
 
        // Act
        if (!useAction)
        {
            builder.MapGet("/api/{student}", (Student student) => student);
            await VerifyOpenApiDocument(builder, AssertOpenApiDocument);
        }
        else
        {
            var action = CreateActionDescriptor(nameof(GetStudent));
            await VerifyOpenApiDocument(action, AssertOpenApiDocument);
        }
 
        // Assert
        static void AssertOpenApiDocument(OpenApiDocument document)
        {
            // Parameter is a plain-old string when it comes from the route or query
            var operation = document.Paths["/api/{student}"].Operations[OperationType.Get];
            var parameter = Assert.Single(operation.Parameters);
            Assert.Equal("string", parameter.Schema.Type);
 
            // Type is fully serialized in the response
            var response = Assert.Single(operation.Responses).Value;
            Assert.True(response.Content.TryGetValue("application/json", out var mediaType));
            var schema = mediaType.Schema.GetEffective(document);
            Assert.Equal("object", schema.Type);
            Assert.Collection(schema.Properties, property =>
            {
                Assert.Equal("name", property.Key);
                Assert.Equal("string", property.Value.Type);
            });
        }
    }
 
    [Route("/api/{student}")]
    private Student GetStudent(Student student) => student;
 
    public record Student(string Name)
    {
        public static bool TryParse(string value, out Student result)
        {
            if (value is null)
            {
                result = null;
                return false;
            }
 
            result = new Student(value);
            return true;
        }
    }
}