diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index dca41b3f155bdc40ab2b3a738db247407facce30..c8ae7b48af73d9318a9b02d01e6f8b315b98ab7c 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -183,7 +183,7 @@ internal sealed class EndpointMetadataApiDescriptionProvider : IApiDescriptionPr var nullabilityContext = new NullabilityInfoContext(); var nullability = nullabilityContext.Create(parameter); var isOptional = parameter.HasDefaultValue || nullability.ReadState != NullabilityState.NotNull || allowEmpty; - var parameterDescriptor = CreateParameterDescriptor(parameter); + var parameterDescriptor = CreateParameterDescriptor(parameter, pattern); var routeInfo = CreateParameterRouteInfo(pattern, parameter, isOptional); return new ApiParameterDescription @@ -199,13 +199,17 @@ internal sealed class EndpointMetadataApiDescriptionProvider : IApiDescriptionPr }; } - private static ParameterDescriptor CreateParameterDescriptor(ParameterInfo parameter) - => new EndpointParameterDescriptor + private static ParameterDescriptor CreateParameterDescriptor(ParameterInfo parameter, RoutePattern pattern) + { + var parameterName = parameter.Name ?? string.Empty; + var name = pattern.GetParameter(parameterName)?.Name ?? parameterName; + return new EndpointParameterDescriptor { - Name = parameter.Name ?? string.Empty, + Name = name, ParameterInfo = parameter, ParameterType = parameter.ParameterType, }; + } private ApiParameterRouteInfo? CreateParameterRouteInfo(RoutePattern pattern, ParameterInfo parameter, bool isOptional) { @@ -250,7 +254,9 @@ internal sealed class EndpointMetadataApiDescriptionProvider : IApiDescriptionPr if (attributes.OfType<IFromRouteMetadata>().FirstOrDefault() is { } routeAttribute) { - return (BindingSource.Path, routeAttribute.Name ?? parameter.Name ?? string.Empty, false, parameter.ParameterType); + var parameterName = parameter.Name ?? string.Empty; + var name = pattern.GetParameter(parameterName)?.Name ?? parameterName; + return (BindingSource.Path, routeAttribute.Name ?? name, false, parameter.ParameterType); } else if (attributes.OfType<IFromQueryMetadata>().FirstOrDefault() is { } queryAttribute) { @@ -285,9 +291,9 @@ internal sealed class EndpointMetadataApiDescriptionProvider : IApiDescriptionPr var displayType = !parameter.ParameterType.IsPrimitive && Nullable.GetUnderlyingType(parameter.ParameterType)?.IsPrimitive != true ? typeof(string) : parameter.ParameterType; // Path vs query cannot be determined by RequestDelegateFactory at startup currently because of the layering, but can be done here. - if (parameter.Name is { } name && pattern.GetParameter(name) is not null) + if (parameter.Name is { } name && pattern.GetParameter(name) is { } routeParam) { - return (BindingSource.Path, name, false, displayType); + return (BindingSource.Path, routeParam.Name, false, displayType); } else { diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index 88f4f06e74f2b5a565b835f0a726d979a972d1ef..d3a8fc96eab1b16d5e77e7a1ab78d53120608a34 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -450,13 +450,13 @@ public class EndpointMetadataApiDescriptionProviderTest [Fact] public void AddsMultipleParametersFromParametersAttribute() { - static void AssertParameters(ApiDescription apiDescription) + static void AssertParameters(ApiDescription apiDescription, string capturedName = "Foo") { Assert.Collection( apiDescription.ParameterDescriptions, param => { - Assert.Equal("Foo", param.Name); + Assert.Equal(capturedName, param.Name); Assert.Equal(typeof(int), param.ModelMetadata.ModelType); Assert.Equal(BindingSource.Path, param.Source); Assert.True(param.IsRequired); @@ -485,7 +485,7 @@ public class EndpointMetadataApiDescriptionProviderTest AssertParameters(GetApiDescription(([AsParameters] ArgumentListRecord req) => { })); AssertParameters(GetApiDescription(([AsParameters] ArgumentListRecordStruct req) => { })); AssertParameters(GetApiDescription(([AsParameters] ArgumentListRecordWithoutPositionalParameters req) => { })); - AssertParameters(GetApiDescription(([AsParameters] ArgumentListRecordWithoutAttributes req) => { }, "/{foo}")); + AssertParameters(GetApiDescription(([AsParameters] ArgumentListRecordWithoutAttributes req) => { }, "/{foo}"), "foo"); AssertParameters(GetApiDescription(([AsParameters] ArgumentListRecordWithoutAttributes req) => { }, "/{Foo}")); } @@ -1250,6 +1250,34 @@ public class EndpointMetadataApiDescriptionProviderTest Assert.Equal("A summary", summaryMetadata.Summary); } + [Theory] + [InlineData("/todos/{id}", "id")] + [InlineData("/todos/{Id}", "Id")] + [InlineData("/todos/{id:minlen(2)}", "id")] + public void FavorsParameterCasingInRoutePattern(string pattern, string expectedName) + { + var builder = CreateBuilder(); + builder.MapGet(pattern, (int Id) => ""); + + var context = new ApiDescriptionProviderContext(Array.Empty<ActionDescriptor>()); + + var endpointDataSource = builder.DataSources.OfType<EndpointDataSource>().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var apiDescription = Assert.Single(context.Results); + var parameter = Assert.Single(apiDescription.ParameterDescriptions); + Assert.Equal(expectedName, parameter.Name); + Assert.Equal(expectedName, parameter.ParameterDescriptor.Name); + } + private static IEnumerable<string> GetSortedMediaTypes(ApiResponseType apiResponseType) { return apiResponseType.ApiResponseFormats diff --git a/src/OpenApi/src/OpenApiGenerator.cs b/src/OpenApi/src/OpenApiGenerator.cs index 1683af0bd528047a2cc9504bbd06bb2415f5dc78..4936bfba13ce9e4bbfc191d230df1a38fe53e80d 100644 --- a/src/OpenApi/src/OpenApiGenerator.cs +++ b/src/OpenApi/src/OpenApiGenerator.cs @@ -354,6 +354,11 @@ internal sealed class OpenApiGenerator foreach (var parameter in parameters) { + if (parameter.Name is null) + { + throw new InvalidOperationException($"Encountered a parameter of type '{parameter.ParameterType}' without a name. Parameters must have a name."); + } + var (isBodyOrFormParameter, parameterLocation) = GetOpenApiParameterLocation(parameter, pattern, disableInferredBody); // If the parameter isn't something that would be populated in RequestBody @@ -367,9 +372,10 @@ internal sealed class OpenApiGenerator var nullabilityContext = new NullabilityInfoContext(); var nullability = nullabilityContext.Create(parameter); var isOptional = parameter.HasDefaultValue || nullability.ReadState != NullabilityState.NotNull; + var name = pattern.GetParameter(parameter.Name) is { } routeParameter ? routeParameter.Name : parameter.Name; var openApiParameter = new OpenApiParameter() { - Name = parameter.Name, + Name = name, In = parameterLocation, Content = GetOpenApiParameterContent(metadata), Schema = OpenApiSchemaGenerator.GetOpenApiSchema(parameter.ParameterType), diff --git a/src/OpenApi/test/OpenApiGeneratorTests.cs b/src/OpenApi/test/OpenApiGeneratorTests.cs index 42e00cffa700b9ea081265b0ccdb83041428d444..1d9b84f359a0a05da9888b3377a84818342cbf0a 100644 --- a/src/OpenApi/test/OpenApiGeneratorTests.cs +++ b/src/OpenApi/test/OpenApiGeneratorTests.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.Linq.Expressions; using System.Reflection; using System.Security.Claims; using Microsoft.AspNetCore.Http; @@ -49,6 +50,15 @@ public class OpenApiOperationGeneratorTests Assert.Equal(declaringTypeName, tag.Name); } + [Fact] + public void ThrowsInvalidOperationExceptionGivenUnnamedParameter() + { + var unnamedParameter = Expression.Parameter(typeof(int)); + var lambda = Expression.Lambda(Expression.Block(), unnamedParameter); + var ex = Assert.Throws<InvalidOperationException>(() => GetOpenApiOperation(lambda.Compile())); + Assert.Equal("Encountered a parameter of type 'System.Runtime.CompilerServices.Closure' without a name. Parameters must have a name.", ex.Message); + } + [Fact] public void AddsRequestFormatFromMetadata() { @@ -357,13 +367,13 @@ public class OpenApiOperationGeneratorTests [Fact] public void AddsMultipleParametersFromParametersAttribute() { - static void AssertParameters(OpenApiOperation operation) + static void AssertParameters(OpenApiOperation operation, string capturedName = "Foo") { Assert.Collection( operation.Parameters, param => { - Assert.Equal("Foo", param.Name); + Assert.Equal(capturedName, param.Name); Assert.Equal("integer", param.Schema.Type); Assert.Equal(ParameterLocation.Path, param.In); Assert.True(param.Required); @@ -391,7 +401,7 @@ public class OpenApiOperationGeneratorTests AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListRecord req) => { })); AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListRecordStruct req) => { })); AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListRecordWithoutPositionalParameters req) => { })); - AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListRecordWithoutAttributes req) => { }, "/{foo}")); + AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListRecordWithoutAttributes req) => { }, "/{foo}"), "foo"); AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListRecordWithoutAttributes req) => { }, "/{Foo}")); } @@ -787,6 +797,19 @@ public class OpenApiOperationGeneratorTests Assert.Equal("object", content.Value.Schema.Type); Assert.Equal("200", response.Key); Assert.Equal("application/json", content.Key); + + } + + [Theory] + [InlineData("/todos/{id}", "id")] + [InlineData("/todos/{Id}", "Id")] + [InlineData("/todos/{id:minlen(2)}", "id")] + public void FavorsParameterCasingInRoutePattern(string pattern, string expectedName) + { + var operation = GetOpenApiOperation((int Id) => "", pattern); + + var param = Assert.Single(operation.Parameters); + Assert.Equal(expectedName, param.Name); } private static OpenApiOperation GetOpenApiOperation( diff --git a/src/Shared/PropertyAsParameterInfo.cs b/src/Shared/PropertyAsParameterInfo.cs index 31e96c3b8b7b0426ab0325adbe16d62ac84d39ee..aa2a29314bd098a23d1f5d04b6b5d39b00041469 100644 --- a/src/Shared/PropertyAsParameterInfo.cs +++ b/src/Shared/PropertyAsParameterInfo.cs @@ -74,6 +74,11 @@ internal sealed class PropertyAsParameterInfo : ParameterInfo for (var i = 0; i < parameters.Length; i++) { + if (parameters[i].Name is null) + { + throw new InvalidOperationException($"Encountered a parameter of type '{parameters[i].ParameterType}' without a name. Parameters must have a name."); + } + if (parameters[i].CustomAttributes.Any(a => a.AttributeType == typeof(AsParametersAttribute))) { // Initialize the list with all parameter already processed