diff --git a/AspNetCore.sln b/AspNetCore.sln index de3f6f05c9b7..5fe59552e095 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1694,6 +1694,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildAfterTargetingPack", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildAfterTargetingPack", "src\BuildAfterTargetingPack\BuildAfterTargetingPack.csproj", "{8FED7E65-A7DD-4F13-8980-BF03E77B6C85}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{85520C50-CF33-4A27-BEC9-272100870D9D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OpenApi", "OpenApi", "{2D2D1107-7389-473B-BDCE-BFA060EAC453}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E47D1385-64B3-429B-9B1D-B0D0B7B6E506}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{25642C23-0BB8-4FF7-9181-9599489679EB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.OpenApi", "src\Http\OpenApi\src\Microsoft.AspNetCore.OpenApi.csproj", "{EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenApi.Tests", "src\Http\OpenApi\test\Microsoft.AspNetCore.OpenApi.Tests.csproj", "{77305727-1A53-402A-A4E8-4CFA0DBFACC6}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ResultsOfTGenerator", "src\Http\Http.Results\tools\ResultsOfTGenerator\ResultsOfTGenerator.csproj", "{9716D0D0-2251-44DD-8596-67D253EEF41C}" EndProject Global @@ -10137,6 +10149,38 @@ Global {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|x64.Build.0 = Release|Any CPU {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|x86.ActiveCfg = Release|Any CPU {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|x86.Build.0 = Release|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Debug|arm64.ActiveCfg = Debug|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Debug|arm64.Build.0 = Debug|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Debug|x64.ActiveCfg = Debug|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Debug|x64.Build.0 = Debug|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Debug|x86.ActiveCfg = Debug|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Debug|x86.Build.0 = Debug|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Release|Any CPU.Build.0 = Release|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Release|arm64.ActiveCfg = Release|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Release|arm64.Build.0 = Release|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Release|x64.ActiveCfg = Release|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Release|x64.Build.0 = Release|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Release|x86.ActiveCfg = Release|Any CPU + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4}.Release|x86.Build.0 = Release|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Debug|arm64.ActiveCfg = Debug|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Debug|arm64.Build.0 = Debug|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Debug|x64.ActiveCfg = Debug|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Debug|x64.Build.0 = Debug|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Debug|x86.ActiveCfg = Debug|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Debug|x86.Build.0 = Debug|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Release|Any CPU.Build.0 = Release|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Release|arm64.ActiveCfg = Release|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Release|arm64.Build.0 = Release|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Release|x64.ActiveCfg = Release|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Release|x64.Build.0 = Release|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Release|x86.ActiveCfg = Release|Any CPU + {77305727-1A53-402A-A4E8-4CFA0DBFACC6}.Release|x86.Build.0 = Release|Any CPU {9716D0D0-2251-44DD-8596-67D253EEF41C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9716D0D0-2251-44DD-8596-67D253EEF41C}.Debug|Any CPU.Build.0 = Debug|Any CPU {9716D0D0-2251-44DD-8596-67D253EEF41C}.Debug|arm64.ActiveCfg = Debug|Any CPU @@ -10992,6 +11036,11 @@ Global {B7DAA48B-8E5E-4A5D-9FEB-E6D49AE76A04} = {41BB7BA4-AC08-4E9A-83EA-6D587A5B951C} {489020F2-80D9-4468-A5D3-07E785837A5D} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} {8FED7E65-A7DD-4F13-8980-BF03E77B6C85} = {489020F2-80D9-4468-A5D3-07E785837A5D} + {2D2D1107-7389-473B-BDCE-BFA060EAC453} = {627BE8B3-59E6-4F1D-8C9C-76B804D41724} + {E47D1385-64B3-429B-9B1D-B0D0B7B6E506} = {2D2D1107-7389-473B-BDCE-BFA060EAC453} + {25642C23-0BB8-4FF7-9181-9599489679EB} = {2D2D1107-7389-473B-BDCE-BFA060EAC453} + {EF5062E5-93FA-48B3-B8DB-7B7B263A64D4} = {E47D1385-64B3-429B-9B1D-B0D0B7B6E506} + {77305727-1A53-402A-A4E8-4CFA0DBFACC6} = {25642C23-0BB8-4FF7-9181-9599489679EB} {9716D0D0-2251-44DD-8596-67D253EEF41C} = {323C3EB6-1D15-4B3D-918D-699D7F64DED9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/eng/Dependencies.props b/eng/Dependencies.props index 18be2fda9f68..0fbdbd667a71 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -62,6 +62,7 @@ and are generated based on the last package release. + diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 94a43b1c5331..0411bbd7c587 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -31,6 +31,7 @@ + diff --git a/eng/Versions.props b/eng/Versions.props index a2b293ab007e..7276af273499 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -282,6 +282,7 @@ 2.4.3 4.0.1 6.0.0-preview.3.21167.1 + 1.2.3 diff --git a/src/Http/Http.Abstractions/src/Extensions/EndpointBuilder.cs b/src/Http/Http.Abstractions/src/Extensions/EndpointBuilder.cs index e133399f4ad0..873a1dbe8bae 100644 --- a/src/Http/Http.Abstractions/src/Extensions/EndpointBuilder.cs +++ b/src/Http/Http.Abstractions/src/Extensions/EndpointBuilder.cs @@ -25,6 +25,11 @@ public abstract class EndpointBuilder /// public IList Metadata { get; } = new List(); + /// + /// Gets the associated with the endpoint. + /// + public IServiceProvider? ServiceProvider { get; set; } + /// /// Creates an instance of from the . /// diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index dc7700f1d4a5..4532909a61d7 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -1,5 +1,7 @@ #nullable enable *REMOVED*abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string! +Microsoft.AspNetCore.Builder.EndpointBuilder.ServiceProvider.get -> System.IServiceProvider? +Microsoft.AspNetCore.Builder.EndpointBuilder.ServiceProvider.set -> void Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata() -> T! Microsoft.AspNetCore.Http.IRouteHandlerFilter.InvokeAsync(Microsoft.AspNetCore.Http.RouteHandlerInvocationContext! context, Microsoft.AspNetCore.Http.RouteHandlerFilterDelegate! next) -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata diff --git a/src/Http/HttpAbstractions.slnf b/src/Http/HttpAbstractions.slnf index 70fc8b56f6a8..f57c31fe5b62 100644 --- a/src/Http/HttpAbstractions.slnf +++ b/src/Http/HttpAbstractions.slnf @@ -26,6 +26,8 @@ "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj", "src\\Http\\Http\\test\\Microsoft.AspNetCore.Http.Tests.csproj", "src\\Http\\Metadata\\src\\Microsoft.AspNetCore.Metadata.csproj", + "src\\Http\\OpenApi\\src\\Microsoft.AspNetCore.OpenApi.csproj", + "src\\Http\\OpenApi\\test\\Microsoft.AspNetCore.OpenApi.Tests.csproj", "src\\Http\\Owin\\src\\Microsoft.AspNetCore.Owin.csproj", "src\\Http\\Owin\\test\\Microsoft.AspNetCore.Owin.Tests.csproj", "src\\Http\\Routing.Abstractions\\src\\Microsoft.AspNetCore.Routing.Abstractions.csproj", @@ -40,11 +42,16 @@ "src\\Http\\WebUtilities\\perf\\Microbenchmarks\\Microsoft.AspNetCore.WebUtilities.Microbenchmarks.csproj", "src\\Http\\WebUtilities\\src\\Microsoft.AspNetCore.WebUtilities.csproj", "src\\Http\\WebUtilities\\test\\Microsoft.AspNetCore.WebUtilities.Tests.csproj", - "src\\Http\\samples\\MinimalSample\\MinimalSample.csproj", "src\\Http\\samples\\SampleApp\\HttpAbstractions.SampleApp.csproj", "src\\Middleware\\CORS\\src\\Microsoft.AspNetCore.Cors.csproj", + "src\\Middleware\\Diagnostics.Abstractions\\src\\Microsoft.AspNetCore.Diagnostics.Abstractions.csproj", + "src\\Middleware\\Diagnostics\\src\\Microsoft.AspNetCore.Diagnostics.csproj", + "src\\Middleware\\HostFiltering\\src\\Microsoft.AspNetCore.HostFiltering.csproj", "src\\Middleware\\HttpOverrides\\src\\Microsoft.AspNetCore.HttpOverrides.csproj", + "src\\Middleware\\ResponseCaching.Abstractions\\src\\Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj", "src\\Middleware\\StaticFiles\\src\\Microsoft.AspNetCore.StaticFiles.csproj", + "src\\Mvc\\Mvc.Abstractions\\src\\Microsoft.AspNetCore.Mvc.Abstractions.csproj", + "src\\Mvc\\Mvc.Core\\src\\Microsoft.AspNetCore.Mvc.Core.csproj", "src\\ObjectPool\\src\\Microsoft.Extensions.ObjectPool.csproj", "src\\Security\\Authorization\\Core\\src\\Microsoft.AspNetCore.Authorization.csproj", "src\\Security\\Authorization\\Policy\\src\\Microsoft.AspNetCore.Authorization.Policy.csproj", @@ -52,6 +59,7 @@ "src\\Servers\\IIS\\IISIntegration\\src\\Microsoft.AspNetCore.Server.IISIntegration.csproj", "src\\Servers\\Kestrel\\Core\\src\\Microsoft.AspNetCore.Server.Kestrel.Core.csproj", "src\\Servers\\Kestrel\\Kestrel\\src\\Microsoft.AspNetCore.Server.Kestrel.csproj", + "src\\Servers\\Kestrel\\Transport.Quic\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Quic.csproj", "src\\Servers\\Kestrel\\Transport.Sockets\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj", "src\\Testing\\src\\Microsoft.AspNetCore.Testing.csproj", "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj" diff --git a/src/Http/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj b/src/Http/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj new file mode 100644 index 000000000000..632d266fc38e --- /dev/null +++ b/src/Http/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj @@ -0,0 +1,25 @@ + + + + $(DefaultNetCoreTargetFramework) + true + aspnetcore;openapi + Provides APIs for annotating route handler endpoints in ASP.NET Core with OpenAPI annotations. + enable + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Http/OpenApi/src/OpenApiGenerator.cs b/src/Http/OpenApi/src/OpenApiGenerator.cs new file mode 100644 index 000000000000..0860054d0f3a --- /dev/null +++ b/src/Http/OpenApi/src/OpenApiGenerator.cs @@ -0,0 +1,472 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Microsoft.OpenApi.Models; +using Microsoft.AspNetCore.Http; +using System.Linq; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Routing; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Internal; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Primitives; +using Microsoft.AspNetCore.Routing.Patterns; +using System.Security.Claims; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Defines a set of methods for generating OpenAPI definitions for endpoints. +/// +internal class OpenApiGenerator +{ + private readonly IHostEnvironment? _environment; + private readonly IServiceProviderIsService? _serviceProviderIsService; + private readonly ParameterBindingMethodCache ParameterBindingMethodCache = new(); + + /// + /// Creates an instance given a + /// and a instance. + /// + /// The host environment. + /// The service to determine if the a type is available from the . + internal OpenApiGenerator( + IHostEnvironment? environment, + IServiceProviderIsService? serviceProviderIsService) + { + _environment = environment; + _serviceProviderIsService = serviceProviderIsService; + } + + /// + /// Generates an for a given . + /// + /// The associated with the route handler of the endpoint. + /// The endpoint . + /// The route pattern. + /// An annotation derived from the given inputs. + internal OpenApiOperation? GetOpenApiOperation( + MethodInfo methodInfo, + EndpointMetadataCollection metadata, + RoutePattern pattern) + { + if (metadata.GetMetadata() is { } httpMethodMetadata && + httpMethodMetadata.HttpMethods.SingleOrDefault() is { } method && + metadata.GetMetadata() is null or { ExcludeFromDescription: false }) + { + return GetOperation(method, methodInfo, metadata, pattern); + } + + return null; + } + + private OpenApiOperation GetOperation(string httpMethod, MethodInfo methodInfo, EndpointMetadataCollection metadata, RoutePattern pattern) + { + var disableInferredBody = ShouldDisableInferredBody(httpMethod); + return new OpenApiOperation + { + OperationId = metadata.GetMetadata()?.EndpointName, + Summary = metadata.GetMetadata()?.Summary, + Description = metadata.GetMetadata()?.Description, + Tags = GetOperationTags(methodInfo, metadata), + Parameters = GetOpenApiParameters(methodInfo, metadata, pattern, disableInferredBody), + RequestBody = GetOpenApiRequestBody(methodInfo, metadata, pattern), + Responses = GetOpenApiResponses(methodInfo, metadata) + }; + + static bool ShouldDisableInferredBody(string method) + { + // GET, DELETE, HEAD, CONNECT, TRACE, and OPTIONS normally do not contain bodies + return method.Equals(HttpMethods.Get, StringComparison.Ordinal) || + method.Equals(HttpMethods.Delete, StringComparison.Ordinal) || + method.Equals(HttpMethods.Head, StringComparison.Ordinal) || + method.Equals(HttpMethods.Options, StringComparison.Ordinal) || + method.Equals(HttpMethods.Trace, StringComparison.Ordinal) || + method.Equals(HttpMethods.Connect, StringComparison.Ordinal); + } + } + + private static OpenApiResponses GetOpenApiResponses(MethodInfo method, EndpointMetadataCollection metadata) + { + var responses = new OpenApiResponses(); + var responseType = method.ReturnType; + if (AwaitableInfo.IsTypeAwaitable(responseType, out var awaitableInfo)) + { + responseType = awaitableInfo.ResultType; + } + + if (typeof(IResult).IsAssignableFrom(responseType)) + { + responseType = typeof(void); + } + + var errorMetadata = metadata.GetMetadata(); + var defaultErrorType = errorMetadata?.Type; + + var responseProviderMetadata = metadata.GetOrderedMetadata(); + var producesResponseMetadata = metadata.GetOrderedMetadata(); + + var eligibileAnnotations = new Dictionary(); + + foreach (var responseMetadata in producesResponseMetadata) + { + var statusCode = responseMetadata.StatusCode; + + var discoveredTypeAnnotation = responseMetadata.Type; + var discoveredContentTypeAnnotation = new MediaTypeCollection(); + + if (discoveredTypeAnnotation == typeof(void)) + { + if (responseType != null && (statusCode == StatusCodes.Status200OK || statusCode == StatusCodes.Status201Created)) + { + discoveredTypeAnnotation = responseType; + } + } + + foreach (var contentType in responseMetadata.ContentTypes) + { + discoveredContentTypeAnnotation.Add(contentType); + } + + discoveredTypeAnnotation = discoveredTypeAnnotation == null || discoveredTypeAnnotation == typeof(void) + ? responseType + : discoveredTypeAnnotation; + + if (discoveredTypeAnnotation is not null) + { + GenerateDefaultContent(discoveredContentTypeAnnotation, discoveredTypeAnnotation); + eligibileAnnotations.Add(statusCode, (discoveredTypeAnnotation, discoveredContentTypeAnnotation)); + } + } + + foreach (var providerMetadata in responseProviderMetadata) + { + var statusCode = providerMetadata.StatusCode; + + var discoveredTypeAnnotation = providerMetadata.Type; + var discoveredContentTypeAnnotation = new MediaTypeCollection(); + + if (discoveredTypeAnnotation == typeof(void)) + { + if (responseType != null && (statusCode == StatusCodes.Status200OK || statusCode == StatusCodes.Status201Created)) + { + // ProducesResponseTypeAttribute's constructor defaults to setting "Type" to void when no value is specified. + // In this event, use the action's return type for 200 or 201 status codes. This lets you decorate an action with a + // [ProducesResponseType(201)] instead of [ProducesResponseType(typeof(Person), 201] when typeof(Person) can be inferred + // from the return type. + discoveredTypeAnnotation = responseType; + } + else if (statusCode >= 400 && statusCode < 500) + { + // Determine whether or not the type was provided by the user. If so, favor it over the default + // error type for 4xx client errors if no response type is specified. + discoveredTypeAnnotation = defaultErrorType is not null ? defaultErrorType : discoveredTypeAnnotation; + } + else if (providerMetadata is IApiDefaultResponseMetadataProvider) + { + discoveredTypeAnnotation = defaultErrorType; + } + } + + providerMetadata.SetContentTypes(discoveredContentTypeAnnotation); + + discoveredTypeAnnotation = discoveredTypeAnnotation == null || discoveredTypeAnnotation == typeof(void) + ? responseType + : discoveredTypeAnnotation; + + GenerateDefaultContent(discoveredContentTypeAnnotation, discoveredTypeAnnotation); + eligibileAnnotations.Add(statusCode, (discoveredTypeAnnotation, discoveredContentTypeAnnotation)); + } + + if (eligibileAnnotations.Count == 0) + { + GenerateDefaultResponses(eligibileAnnotations, responseType); + } + + foreach (var annotation in eligibileAnnotations) + { + var statusCode = $"{annotation.Key}"; + var (type, contentTypes) = annotation.Value; + var responseContent = new Dictionary(); + + foreach (var contentType in contentTypes) + { + responseContent[contentType] = new OpenApiMediaType + { + Schema = new OpenApiSchema { Type = SchemaGenerator.GetOpenApiSchemaType(type) } + }; + } + + responses[statusCode] = new OpenApiResponse { Content = responseContent }; + } + + return responses; + } + + private static void GenerateDefaultContent(MediaTypeCollection discoveredContentTypeAnnotation, Type? discoveredTypeAnnotation) + { + if (discoveredContentTypeAnnotation.Count == 0) + { + if (discoveredTypeAnnotation == typeof(void) || discoveredTypeAnnotation == null) + { + return; + } + if (discoveredTypeAnnotation == typeof(string)) + { + discoveredContentTypeAnnotation.Add("text/plain"); + } + else + { + discoveredContentTypeAnnotation.Add("application/json"); + } + } + } + + private static void GenerateDefaultResponses(Dictionary eligibleAnnotations, Type responseType) + { + if (responseType == typeof(void)) + { + eligibleAnnotations.Add(StatusCodes.Status200OK, (responseType, new MediaTypeCollection())); + } + else if (responseType == typeof(string)) + { + eligibleAnnotations.Add(StatusCodes.Status200OK, (responseType, new MediaTypeCollection() { "text/plain" })); + } + else + { + eligibleAnnotations.Add(StatusCodes.Status200OK, (responseType, new MediaTypeCollection() { "application/json" })); + } + } + + private OpenApiRequestBody? GetOpenApiRequestBody(MethodInfo methodInfo, EndpointMetadataCollection metadata, RoutePattern pattern) + { + var hasFormOrBodyParameter = false; + ParameterInfo? requestBodyParameter = null; + + foreach (var parameter in methodInfo.GetParameters()) + { + var (bodyOrFormParameter, _) = GetOpenApiParameterLocation(parameter, pattern, false); + hasFormOrBodyParameter |= bodyOrFormParameter; + if (hasFormOrBodyParameter) + { + requestBodyParameter = parameter; + break; + } + } + + var acceptsMetadata = metadata.GetMetadata(); + var requestBodyContent = new Dictionary(); + var isRequired = false; + + if (acceptsMetadata is not null) + { + foreach (var contentType in acceptsMetadata.ContentTypes) + { + requestBodyContent[contentType] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = SchemaGenerator.GetOpenApiSchemaType(acceptsMetadata.RequestType ?? requestBodyParameter?.ParameterType) + } + }; + } + isRequired = !acceptsMetadata.IsOptional; + } + + if (!hasFormOrBodyParameter) + { + return new OpenApiRequestBody() + { + Required = isRequired, + Content = requestBodyContent + }; + } + + if (requestBodyParameter is not null) + { + if (requestBodyContent.Count == 0) + { + var isFormType = requestBodyParameter.ParameterType == typeof(IFormFile) || requestBodyParameter.ParameterType == typeof(IFormFileCollection); + var hasFormAttribute = requestBodyParameter.GetCustomAttributes().OfType().FirstOrDefault() != null; + if (isFormType || hasFormAttribute) + { + requestBodyContent["multipart/form-data"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = SchemaGenerator.GetOpenApiSchemaType(requestBodyParameter.ParameterType) + } + }; + } + else + { + requestBodyContent["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = SchemaGenerator.GetOpenApiSchemaType(requestBodyParameter.ParameterType) + } + }; ; + } + } + + var nullabilityContext = new NullabilityInfoContext(); + var nullability = nullabilityContext.Create(requestBodyParameter); + var allowEmpty = requestBodyParameter.GetCustomAttributes().OfType().SingleOrDefault()?.AllowEmpty ?? false; + var isOptional = requestBodyParameter.HasDefaultValue + || nullability.ReadState != NullabilityState.NotNull + || allowEmpty; + + return new OpenApiRequestBody + { + Required = !isOptional, + Content = requestBodyContent + }; + } + + return null; + } + + private IList GetOperationTags(MethodInfo methodInfo, EndpointMetadataCollection metadata) + { + var tags = metadata.GetMetadata(); + string controllerName; + + if (methodInfo.DeclaringType is not null && !TypeHelper.IsCompilerGeneratedType(methodInfo.DeclaringType)) + { + controllerName = methodInfo.DeclaringType.Name; + } + else + { + // If the declaring type is null or compiler-generated (e.g. lambdas), + // group the methods under the application name. + controllerName = _environment?.ApplicationName ?? string.Empty; + } + + return tags is not null + ? tags.Tags.Select(tag => new OpenApiTag() { Name = tag }).ToList() + : new List() { new OpenApiTag() { Name = controllerName } }; + } + + private IList GetOpenApiParameters(MethodInfo methodInfo, EndpointMetadataCollection metadata, RoutePattern pattern, bool disableInferredBody) + { + var parameters = methodInfo.GetParameters(); + var openApiParameters = new List(); + + foreach (var parameter in parameters) + { + var (isBodyOrFormParameter, parameterLocation) = GetOpenApiParameterLocation(parameter, pattern, disableInferredBody); + + // If the parameter isn't something that would be populated in RequestBody + // or doesn't have a valid ParameterLocation, then it must be a service + // parameter that we can ignore. + if (!isBodyOrFormParameter && parameterLocation is null) + { + continue; + } + + var nullabilityContext = new NullabilityInfoContext(); + var nullability = nullabilityContext.Create(parameter); + var isOptional = parameter.HasDefaultValue || nullability.ReadState != NullabilityState.NotNull; + var openApiParameter = new OpenApiParameter() + { + Name = parameter.Name, + In = parameterLocation, + Content = GetOpenApiParameterContent(metadata), + Schema = new OpenApiSchema { Type = SchemaGenerator.GetOpenApiSchemaType(parameter.ParameterType) }, + Required = !isOptional + + }; + openApiParameters.Add(openApiParameter); + } + + return openApiParameters; + } + + private static IDictionary GetOpenApiParameterContent(EndpointMetadataCollection metadata) + { + var openApiParameterContent = new Dictionary(); + var acceptsMetadata = metadata.GetMetadata(); + if (acceptsMetadata is not null) + { + foreach (var contentType in acceptsMetadata.ContentTypes) + { + openApiParameterContent.Add(contentType, new OpenApiMediaType()); + } + } + + return openApiParameterContent; + } + + private (bool, ParameterLocation?) GetOpenApiParameterLocation(ParameterInfo parameter, RoutePattern pattern, bool disableInferredBody) + { + var attributes = parameter.GetCustomAttributes(); + + if (attributes.OfType().FirstOrDefault() is { } routeAttribute) + { + return (false, ParameterLocation.Path); + } + else if (attributes.OfType().FirstOrDefault() is { } queryAttribute) + { + return (false, ParameterLocation.Query); + } + else if (attributes.OfType().FirstOrDefault() is { } headerAttribute) + { + return (false, ParameterLocation.Header); + } + else if (attributes.OfType().FirstOrDefault() is { } fromBodyAttribute) + { + return (true, null); + } + else if (attributes.OfType().FirstOrDefault() is { } fromFormAttribute) + { + return (true, null); + } + else if (parameter.CustomAttributes.Any(a => typeof(IFromServiceMetadata).IsAssignableFrom(a.AttributeType)) || + parameter.ParameterType == typeof(HttpContext) || + parameter.ParameterType == typeof(HttpRequest) || + parameter.ParameterType == typeof(HttpResponse) || + parameter.ParameterType == typeof(ClaimsPrincipal) || + parameter.ParameterType == typeof(CancellationToken) || + ParameterBindingMethodCache.HasBindAsyncMethod(parameter) || + _serviceProviderIsService?.IsService(parameter.ParameterType) == true) + { + return (false, null); + } + else if (parameter.ParameterType == typeof(string) || ParameterBindingMethodCache.HasTryParseMethod(parameter.ParameterType)) + { + // complex types will display as strings since they use custom parsing via TryParse on a string + 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) + { + return (false, ParameterLocation.Path); + } + else + { + return (false, ParameterLocation.Query); + } + } + else if (parameter.ParameterType == typeof(IFormFile) || parameter.ParameterType == typeof(IFormFileCollection)) + { + return (true, null); + } + else if (disableInferredBody && ( + (parameter.ParameterType.IsArray && ParameterBindingMethodCache.HasTryParseMethod(parameter.ParameterType.GetElementType()!)) || + parameter.ParameterType == typeof(string[]) || + parameter.ParameterType == typeof(StringValues))) + { + return (false, ParameterLocation.Query); + } + else + { + return (true, null); + } + } +} diff --git a/src/Http/OpenApi/src/OpenApiRouteHandlerBuilderExtensions.cs b/src/Http/OpenApi/src/OpenApiRouteHandlerBuilderExtensions.cs new file mode 100644 index 000000000000..9439926e9a45 --- /dev/null +++ b/src/Http/OpenApi/src/OpenApiRouteHandlerBuilderExtensions.cs @@ -0,0 +1,85 @@ +// 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 Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.OpenApi.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Extension methods for annotating OpenAPI descriptions on an . +/// +public static class OpenApiRouteHandlerBuilderExtensions +{ + /// + /// Adds an OpenAPI annotation to associated + /// with the current endpoint. + /// + /// The . + /// A that can be used to further customize the endpoint. + + public static RouteHandlerBuilder WithOpenApi(this RouteHandlerBuilder builder) + { + builder.Add(endpointBuilder => + { + if (endpointBuilder is RouteEndpointBuilder routeEndpointBuilder) + { + var openApiOperation = GetOperationForEndpoint(routeEndpointBuilder); + if (openApiOperation != null) + { + routeEndpointBuilder.Metadata.Add(openApiOperation); + } + }; + }); + return builder; + + } + + /// + /// Adds an OpenAPI annotation to associated + /// with the current endpoint and modifies it with the given . + /// + /// The . + /// An that mutates an OpenAPI annotation. + /// A that can be used to further customize the endpoint. + public static RouteHandlerBuilder WithOpenApi(this RouteHandlerBuilder builder, Func configureOperation) + { + builder.Add(endpointBuilder => + { + if (endpointBuilder is RouteEndpointBuilder routeEndpointBuilder) + { + var openApiOperation = GetOperationForEndpoint(routeEndpointBuilder); + if (openApiOperation != null) + { + routeEndpointBuilder.Metadata.Add(configureOperation(openApiOperation)); + } + + }; + }); + return builder; + } + + private static OpenApiOperation? GetOperationForEndpoint(RouteEndpointBuilder routeEndpointBuilder) + { + var pattern = routeEndpointBuilder.RoutePattern; + var metadata = new EndpointMetadataCollection(routeEndpointBuilder.Metadata); + var methodInfo = metadata.OfType().SingleOrDefault(); + var serviceProvider = routeEndpointBuilder.ServiceProvider; + + if (methodInfo == null || serviceProvider == null) + { + return null; + } + + var hostEnvironment = serviceProvider.GetService(); + var serviceProviderIsService = serviceProvider.GetService(); + var generator = new OpenApiGenerator(hostEnvironment, serviceProviderIsService); + return generator.GetOpenApiOperation(methodInfo, metadata, pattern); + } +} diff --git a/src/Http/OpenApi/src/Properties/AssemblyInfo.cs b/src/Http/OpenApi/src/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..d500965b263d --- /dev/null +++ b/src/Http/OpenApi/src/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.OpenApi.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Http/OpenApi/src/PublicAPI.Shipped.txt b/src/Http/OpenApi/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..ab058de62d44 --- /dev/null +++ b/src/Http/OpenApi/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Http/OpenApi/src/PublicAPI.Unshipped.txt b/src/Http/OpenApi/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..d83debd1c5d6 --- /dev/null +++ b/src/Http/OpenApi/src/PublicAPI.Unshipped.txt @@ -0,0 +1,4 @@ +#nullable enable +Microsoft.AspNetCore.OpenApi.OpenApiRouteHandlerBuilderExtensions +static Microsoft.AspNetCore.OpenApi.OpenApiRouteHandlerBuilderExtensions.WithOpenApi(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! +static Microsoft.AspNetCore.OpenApi.OpenApiRouteHandlerBuilderExtensions.WithOpenApi(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, System.Func! configureOperation) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! diff --git a/src/Http/OpenApi/src/SchemaGenerator.cs b/src/Http/OpenApi/src/SchemaGenerator.cs new file mode 100644 index 000000000000..3eeb16fe4e79 --- /dev/null +++ b/src/Http/OpenApi/src/SchemaGenerator.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.OpenApi; + +internal static class SchemaGenerator +{ + internal static string GetOpenApiSchemaType(Type? inputType) + { + if (inputType == null) + { + throw new ArgumentNullException(nameof(inputType)); + } + + var type = Nullable.GetUnderlyingType(inputType) ?? inputType; + + if (typeof(string).IsAssignableFrom(type) || typeof(DateTime).IsAssignableTo(type)) + { + return "string"; + } + else if (typeof(bool).IsAssignableFrom(type)) + { + return "boolean"; + } + else if (typeof(int).IsAssignableFrom(type) + || typeof(double).IsAssignableFrom(type) + || typeof(float).IsAssignableFrom(type)) + { + return "number"; + } + else if (typeof(long).IsAssignableFrom(type)) + { + return "integer"; + } + else if (type.IsArray) + { + return "array"; + } + else + { + return "object"; + } + } +} diff --git a/src/Http/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj b/src/Http/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj new file mode 100644 index 000000000000..84077279d0ac --- /dev/null +++ b/src/Http/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj @@ -0,0 +1,20 @@ + + + + $(DefaultNetCoreTargetFramework) + true + + + + + + + + + + + + + + + diff --git a/src/Http/OpenApi/test/OpenApiGeneratorTests.cs b/src/Http/OpenApi/test/OpenApiGeneratorTests.cs new file mode 100644 index 000000000000..58ea9d226764 --- /dev/null +++ b/src/Http/OpenApi/test/OpenApiGeneratorTests.cs @@ -0,0 +1,814 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Microsoft.OpenApi.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.FileProviders; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.Primitives; +using System.Security.Claims; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.OpenApi.Tests; + +public class OpenApiOperationGeneratorTests +{ + [Fact] + public void OperationNotCreatedIfNoHttpMethods() + { + var operation = GetOpenApiOperation(() => { }, "/", Array.Empty()); + + Assert.Null(operation); + } + + [Fact] + public void UsesDeclaringTypeAsOperationTags() + { + var operation = GetOpenApiOperation(TestAction); + + var declaringTypeName = typeof(OpenApiOperationGeneratorTests).Name; + var tag = Assert.Single(operation.Tags); + + Assert.Equal(declaringTypeName, tag.Name); + + } + + [Fact] + public void UsesApplicationNameAsOperationTagsIfNoDeclaringType() + { + var operation = GetOpenApiOperation(() => { }); + + var declaringTypeName = nameof(OpenApiOperationGeneratorTests); + var tag = Assert.Single(operation.Tags); + + Assert.Equal(declaringTypeName, tag.Name); + } + + [Fact] + public void AddsRequestFormatFromMetadata() + { + static void AssertCustomRequestFormat(OpenApiOperation operation) + { + var request = Assert.Single(operation.Parameters); + var content = Assert.Single(request.Content); + Assert.Equal("application/custom", content.Key); + } + + AssertCustomRequestFormat(GetOpenApiOperation( + [Consumes("application/custom")] + (InferredJsonClass fromBody) => + { })); + + AssertCustomRequestFormat(GetOpenApiOperation( + [Consumes("application/custom")] + ([FromBody] int fromBody) => + { })); + } + + [Fact] + public void AddsMultipleRequestFormatsFromMetadata() + { + var operation = GetOpenApiOperation( + [Consumes("application/custom0", "application/custom1")] + (InferredJsonClass fromBody) => + { }); + + var request = Assert.Single(operation.Parameters); + + Assert.Equal(2, request.Content.Count); + Assert.Equal(new[] { "application/custom0", "application/custom1" }, request.Content.Keys); + } + + [Fact] + public void AddsMultipleRequestFormatsFromMetadataWithRequestTypeAndOptionalBodyParameter() + { + var operation = GetOpenApiOperation( + [Consumes(typeof(InferredJsonClass), "application/custom0", "application/custom1", IsOptional = true)] + () => + { }); ; + var request = operation.RequestBody; + Assert.NotNull(request); + + Assert.Equal(2, request.Content.Count); + + Assert.Equal("object", request.Content.First().Value.Schema.Type); + Assert.Equal("object", request.Content.Last().Value.Schema.Type); + Assert.False(request.Required); + } + +#nullable enable + + [Fact] + public void AddsMultipleRequestFormatsFromMetadataWithRequiredBodyParameter() + { + var operation = GetOpenApiOperation( + [Consumes(typeof(InferredJsonClass), "application/custom0", "application/custom1", IsOptional = false)] + (InferredJsonClass fromBody) => + { }); + + var request = operation.RequestBody; + Assert.NotNull(request); + + Assert.Equal("object", request.Content.First().Value.Schema.Type); + Assert.True(request.Required); + } + +#nullable disable + + [Fact] + public void AddsJsonResponseFormatWhenFromBodyInferred() + { + static void AssertJsonResponse(OpenApiOperation operation, string expectedType) + { + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + var formats = Assert.Single(response.Value.Content); + Assert.Equal(expectedType, formats.Value.Schema.Type); + + Assert.Equal("application/json", formats.Key); + } + + AssertJsonResponse(GetOpenApiOperation(() => new InferredJsonClass()), "object"); + AssertJsonResponse(GetOpenApiOperation(() => (IInferredJsonInterface)null), "object"); + } + + [Fact] + public void AddsTextResponseFormatWhenFromBodyInferred() + { + var operation = GetOpenApiOperation(() => "foo"); + + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + var formats = Assert.Single(response.Value.Content); + Assert.Equal("string", formats.Value.Schema.Type); + Assert.Equal("text/plain", formats.Key); + } + + [Fact] + public void AddsNoResponseFormatWhenItCannotBeInferredAndTheresNoMetadata() + { + static void AssertVoid(OpenApiOperation operation) + { + ; + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Empty(response.Value.Content); + } + + AssertVoid(GetOpenApiOperation(() => { })); + AssertVoid(GetOpenApiOperation(() => Task.CompletedTask)); + AssertVoid(GetOpenApiOperation(() => new ValueTask())); + } + + [Fact] + public void AddsMultipleResponseFormatsFromMetadataWithPoco() + { + var operation = GetOpenApiOperation( + [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + () => new InferredJsonClass()); + + var responses = operation.Responses; + + Assert.Equal(2, responses.Count); + + var createdResponseType = responses["201"]; + var content = Assert.Single(createdResponseType.Content); + + Assert.NotNull(createdResponseType); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("application/json", createdResponseType.Content.Keys.First()); + + var badRequestResponseType = responses["400"]; + + Assert.NotNull(badRequestResponseType); + Assert.Equal("object", badRequestResponseType.Content.Values.First().Schema.Type); + Assert.Equal("application/json", badRequestResponseType.Content.Keys.First()); + } + + [Fact] + public void AddsMultipleResponseFormatsFromMetadataWithIResult() + { + var operation = GetOpenApiOperation( + [ProducesResponseType(typeof(InferredJsonClass), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + () => Results.Ok(new InferredJsonClass())); + + Assert.Equal(2, operation.Responses.Count); + + var createdResponseType = operation.Responses["201"]; + var createdResponseContent = Assert.Single(createdResponseType.Content); + + Assert.NotNull(createdResponseType); + Assert.Equal("object", createdResponseContent.Value.Schema.Type); + Assert.Equal("application/json", createdResponseContent.Key); + + var badRequestResponseType = operation.Responses["400"]; + + Assert.NotNull(badRequestResponseType); + Assert.Empty(badRequestResponseType.Content); + } + + [Fact] + public void AddsFromRouteParameterAsPath() + { + static void AssertPathParameter(OpenApiOperation operation) + { + var param = Assert.Single(operation.Parameters); + Assert.Equal("number", param.Schema.Type); + Assert.Equal(ParameterLocation.Path, param.In); + } + + AssertPathParameter(GetOpenApiOperation((int foo) => { }, "/{foo}")); + AssertPathParameter(GetOpenApiOperation(([FromRoute] int foo) => { })); + } + + [Fact] + public void AddsFromRouteParameterAsPathWithCustomClassWithTryParse() + { + static void AssertPathParameter(OpenApiOperation operation) + { + var param = Assert.Single(operation.Parameters); + Assert.Equal("object", param.Schema.Type); + Assert.Equal(ParameterLocation.Path, param.In); + } + AssertPathParameter(GetOpenApiOperation((TryParseStringRecord foo) => { }, pattern: "/{foo}")); + } + + [Fact] + public void AddsFromRouteParameterAsPathWithNullablePrimitiveType() + { + static void AssertPathParameter(OpenApiOperation operation) + { + var param = Assert.Single(operation.Parameters); + Assert.Equal("number", param.Schema.Type); + Assert.Equal(ParameterLocation.Path, param.In); + } + + AssertPathParameter(GetOpenApiOperation((int? foo) => { }, "/{foo}")); + AssertPathParameter(GetOpenApiOperation(([FromRoute] int? foo) => { })); + } + + [Fact] + public void AddsFromRouteParameterAsPathWithStructTypeWithTryParse() + { + static void AssertPathParameter(OpenApiOperation operation) + { + var param = Assert.Single(operation.Parameters); + Assert.Equal("object", param.Schema.Type); + Assert.Equal(ParameterLocation.Path, param.In); + } + AssertPathParameter(GetOpenApiOperation((TryParseStringRecordStruct foo) => { }, pattern: "/{foo}")); + } + + [Fact] + public void AddsFromQueryParameterAsQuery() + { + static void AssertQueryParameter(OpenApiOperation operation, string type) + { + var param = Assert.Single(operation.Parameters); ; + Assert.Equal(type, param.Schema.Type); + Assert.Equal(ParameterLocation.Query, param.In); + } + + AssertQueryParameter(GetOpenApiOperation((int foo) => { }, "/"), "number"); + AssertQueryParameter(GetOpenApiOperation(([FromQuery] int foo) => { }), "number"); + AssertQueryParameter(GetOpenApiOperation(([FromQuery] TryParseStringRecordStruct foo) => { }), "object"); + AssertQueryParameter(GetOpenApiOperation((int[] foo) => { }, "/"), "array"); + AssertQueryParameter(GetOpenApiOperation((string[] foo) => { }, "/"), "array"); + AssertQueryParameter(GetOpenApiOperation((StringValues foo) => { }, "/"), "object"); + AssertQueryParameter(GetOpenApiOperation((TryParseStringRecordStruct[] foo) => { }, "/"), "array"); + } + + [Theory] + [InlineData("Put")] + [InlineData("Post")] + public void BodyIsInferredForArraysInsteadOfQuerySomeHttpMethods(string httpMethod) + { + static void AssertBody(OpenApiOperation operation, string expectedType) + { + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal(expectedType, content.Value.Schema.Type); + } + + AssertBody(GetOpenApiOperation((int[] foo) => { }, "/", httpMethods: new[] { httpMethod }), "array"); + AssertBody(GetOpenApiOperation((string[] foo) => { }, "/", httpMethods: new[] { httpMethod }), "array"); + AssertBody(GetOpenApiOperation((TryParseStringRecordStruct[] foo) => { }, "/", httpMethods: new[] { httpMethod }), "array"); + } + + [Fact] + public void AddsFromHeaderParameterAsHeader() + { + var operation = GetOpenApiOperation(([FromHeader] int foo) => { }); + var param = Assert.Single(operation.Parameters); + + Assert.Equal("number", param.Schema.Type); + Assert.Equal(ParameterLocation.Header, param.In); + } + + [Fact] + public void DoesNotAddFromServiceParameterAsService() + { + Assert.Empty(GetOpenApiOperation((IInferredServiceInterface foo) => { }).Parameters); + Assert.Empty(GetOpenApiOperation(([FromServices] int foo) => { }).Parameters); + Assert.Empty(GetOpenApiOperation((HttpContext context) => { }).Parameters); + Assert.Empty(GetOpenApiOperation((HttpRequest request) => { }).Parameters); + Assert.Empty(GetOpenApiOperation((HttpResponse response) => { }).Parameters); + Assert.Empty(GetOpenApiOperation((ClaimsPrincipal user) => { }).Parameters); + Assert.Empty(GetOpenApiOperation((CancellationToken token) => { }).Parameters); + Assert.Empty(GetOpenApiOperation((BindAsyncRecord context) => { }).Parameters); + } + + [Fact] + public void AddsBodyParameterInTheParameterDescription() + { + static void AssertBodyParameter(OpenApiOperation operation, string expectedName, string expectedType) + { + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal(expectedType, content.Value.Schema.Type); + } + + AssertBodyParameter(GetOpenApiOperation((InferredJsonClass foo) => { }), "foo", "object"); + AssertBodyParameter(GetOpenApiOperation(([FromBody] int bar) => { }), "bar", "number"); + } + +#nullable enable + + [Fact] + public void AddsMultipleParameters() + { + var operation = GetOpenApiOperation(([FromRoute] int foo, int bar, InferredJsonClass fromBody) => { }); + Assert.Equal(3, operation.Parameters.Count); + + var fooParam = operation.Parameters[0]; + Assert.Equal("foo", fooParam.Name); + Assert.Equal("number", fooParam.Schema.Type); + Assert.Equal(ParameterLocation.Path, fooParam.In); + Assert.True(fooParam.Required); + + var barParam = operation.Parameters[1]; + Assert.Equal("bar", barParam.Name); + Assert.Equal("number", barParam.Schema.Type); + Assert.Equal(ParameterLocation.Query, barParam.In); + Assert.True(barParam.Required); + + var fromBodyParam = operation.RequestBody; + Assert.Equal("object", fromBodyParam.Content.First().Value.Schema.Type); + Assert.True(fromBodyParam.Required); + } + +#nullable disable + + [Fact] + public void TestParameterIsRequired() + { + var operation = GetOpenApiOperation(([FromRoute] int foo, int? bar) => { }); + Assert.Equal(2, operation.Parameters.Count); + + var fooParam = operation.Parameters[0]; + Assert.Equal("foo", fooParam.Name); + Assert.Equal("number", fooParam.Schema.Type); + Assert.Equal(ParameterLocation.Path, fooParam.In); + Assert.True(fooParam.Required); + + var barParam = operation.Parameters[1]; + Assert.Equal("bar", barParam.Name); + Assert.Equal("number", barParam.Schema.Type); + Assert.Equal(ParameterLocation.Query, barParam.In); + Assert.False(barParam.Required); + } + + [Fact] + public void TestParameterIsRequiredForObliviousNullabilityContext() + { + // In an oblivious nullability context, reference type parameters without + // annotations are optional. Value type parameters are always required. + var operation = GetOpenApiOperation((string foo, int bar) => { }); + Assert.Equal(2, operation.Parameters.Count); + + var fooParam = operation.Parameters[0]; + Assert.Equal("string", fooParam.Schema.Type); + Assert.Equal(ParameterLocation.Query, fooParam.In); + Assert.False(fooParam.Required); + + var barParam = operation.Parameters[1]; + Assert.Equal("number", barParam.Schema.Type); + Assert.Equal(ParameterLocation.Query, barParam.In); + Assert.True(barParam.Required); + } + + [Fact] + public void RespectProducesProblemMetadata() + { + // Arrange + var operation = GetOpenApiOperation(() => "", + additionalMetadata: new[] { + new ProducesResponseTypeMetadata(typeof(ProblemDetails), StatusCodes.Status400BadRequest, "application/json+problem") }); + + // Assert + var responses = Assert.Single(operation.Responses); + var content = Assert.Single(responses.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + } + + [Fact] + public void RespectsProducesWithGroupNameExtensionMethod() + { + // Arrange + var endpointGroupName = "SomeEndpointGroupName"; + var operation = GetOpenApiOperation(() => "", + additionalMetadata: new object[] + { + new ProducesResponseTypeMetadata(typeof(InferredJsonClass), StatusCodes.Status200OK, "application/json"), + new EndpointNameMetadata(endpointGroupName) + }); + + var responses = Assert.Single(operation.Responses); + var content = Assert.Single(responses.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + } + + [Fact] + public void RespectsExcludeFromDescription() + { + // Arrange + var operation = GetOpenApiOperation(() => "", + additionalMetadata: new object[] + { + new ProducesResponseTypeMetadata(typeof(InferredJsonClass), StatusCodes.Status200OK, "application/json"), + new ExcludeFromDescriptionAttribute() + }); + + Assert.Null(operation); + } + + [Fact] + public void HandlesProducesWithProducesProblem() + { + // Arrange + var operation = GetOpenApiOperation(() => "", + additionalMetadata: new[] + { + new ProducesResponseTypeMetadata(typeof(InferredJsonClass), StatusCodes.Status200OK, "application/json"), + new ProducesResponseTypeMetadata(typeof(HttpValidationProblemDetails), StatusCodes.Status400BadRequest, "application/problem+json"), + new ProducesResponseTypeMetadata(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json"), + new ProducesResponseTypeMetadata(typeof(ProblemDetails), StatusCodes.Status409Conflict, "application/problem+json") + }); + var responses = operation.Responses; + + // Assert + Assert.Collection( + responses.OrderBy(response => response.Key), + responseType => + { + var content = Assert.Single(responseType.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("200", responseType.Key); + Assert.Equal("application/json", content.Key); + }, + responseType => + { + var content = Assert.Single(responseType.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("400", responseType.Key); + Assert.Equal("application/problem+json", content.Key); + }, + responseType => + { + var content = Assert.Single(responseType.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("404", responseType.Key); + Assert.Equal("application/problem+json", content.Key); + }, + responseType => + { + var content = Assert.Single(responseType.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("409", responseType.Key); + Assert.Equal("application/problem+json", content.Key); + }); + } + + [Fact] + public void HandleMultipleProduces() + { + // Arrange + var operation = GetOpenApiOperation(() => "", + additionalMetadata: new[] + { + new ProducesResponseTypeMetadata(typeof(InferredJsonClass), StatusCodes.Status200OK, "application/json"), + new ProducesResponseTypeMetadata(typeof(InferredJsonClass), StatusCodes.Status201Created, "application/json") + }); + + var responses = operation.Responses; + + // Assert + Assert.Collection( + responses.OrderBy(response => response.Key), + responseType => + { + var content = Assert.Single(responseType.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("200", responseType.Key); + Assert.Equal("application/json", content.Key); + }, + responseType => + { + var content = Assert.Single(responseType.Value.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("201", responseType.Key); + Assert.Equal("application/json", content.Key); + }); + } + + [Fact] + public void HandleAcceptsMetadata() + { + // Arrange + var operation = GetOpenApiOperation(() => "", + additionalMetadata: new[] + { + new AcceptsMetadata(typeof(string), true, new string[] { "application/json", "application/xml"}) + }); + + var requestBody = operation.RequestBody; + + // Assert + Assert.Collection( + requestBody.Content, + parameter => + { + Assert.Equal("application/json", parameter.Key); + }, + parameter => + { + Assert.Equal("application/xml", parameter.Key); + }); + } + + [Fact] + public void HandleAcceptsMetadataWithTypeParameter() + { + // Arrange + var operation = GetOpenApiOperation((InferredJsonClass inferredJsonClass) => "", + additionalMetadata: new[] + { + new AcceptsMetadata(typeof(InferredJsonClass), true, new string[] { "application/json"}) + }); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("object", content.Value.Schema.Type); + Assert.False(requestBody.Required); + } + +#nullable enable + + [Fact] + public void HandleDefaultIAcceptsMetadataForRequiredBodyParameter() + { + // Arrange + var operation = GetOpenApiOperation((InferredJsonClass inferredJsonClass) => ""); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("application/json", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.True(requestBody.Required); + } + + [Fact] + public void HandleDefaultIAcceptsMetadataForOptionalBodyParameter() + { + // Arrange + var operation = GetOpenApiOperation((InferredJsonClass? inferredJsonClass) => ""); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("application/json", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.False(requestBody.Required); + } + + [Fact] + public void HandleIAcceptsMetadataWithConsumesAttributeAndInferredOptionalFromBodyType() + { + // Arrange + var operation = GetOpenApiOperation([Consumes("application/xml")] (InferredJsonClass? inferredJsonClass) => ""); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("application/xml", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.False(requestBody.Required); + } + + [Fact] + public void HandleDefaultIAcceptsMetadataForRequiredFormFileParameter() + { + // Arrange + var operation = GetOpenApiOperation((IFormFile inferredFormFile) => ""); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.True(requestBody.Required); + } + + [Fact] + public void HandleDefaultIAcceptsMetadataForOptionalFormFileParameter() + { + // Arrange + var operation = GetOpenApiOperation((IFormFile? inferredFormFile) => ""); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.False(requestBody.Required); + } + + [Fact] + public void AddsMultipartFormDataRequestFormatWhenFormFileSpecified() + { + // Arrange + var operation = GetOpenApiOperation((IFormFile file) => Results.NoContent()); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.True(requestBody.Required); + } + + [Fact] + public void HasMultipleRequestFormatsWhenFormFileSpecifiedWithConsumesAttribute() + { + var operation = GetOpenApiOperation( + [Consumes("application/custom0", "application/custom1")] (IFormFile file) => Results.NoContent()); + + var requestBody = operation.RequestBody; + var content = requestBody.Content; + + Assert.Equal(2, content.Count); + + var requestFormat0 = content["application/custom0"]; + Assert.NotNull(requestFormat0); + + var requestFormat1 = content["application/custom1"]; + Assert.NotNull(requestFormat1); + } + + [Fact] + public void TestIsRequiredFromFormFile() + { + var operation0 = GetOpenApiOperation((IFormFile fromFile) => { }); + var operation1 = GetOpenApiOperation((IFormFile? fromFile) => { }); + Assert.NotNull(operation0.RequestBody); + Assert.NotNull(operation1.RequestBody); + + var fromFileParam0 = operation0.RequestBody; + Assert.Equal("object", fromFileParam0.Content.Values.Single().Schema.Type); + Assert.True(fromFileParam0.Required); + + var fromFileParam1 = operation1.RequestBody; + Assert.Equal("object", fromFileParam1.Content.Values.Single().Schema.Type); + Assert.False(fromFileParam1.Required); + } + + [Fact] + public void AddsFromFormParameterAsFormFile() + { + static void AssertFormFileParameter(OpenApiOperation operation, string expectedType, string expectedName) + { + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal(expectedType, content.Value.Schema.Type); + Assert.Equal("multipart/form-data", content.Key); + } + + AssertFormFileParameter(GetOpenApiOperation((IFormFile file) => { }), "object", "file"); + AssertFormFileParameter(GetOpenApiOperation(([FromForm(Name = "file_name")] IFormFile file) => { }), "object", "file_name"); + } + + [Fact] + public void AddsMultipartFormDataResponseFormatWhenFormFileCollectionSpecified() + { + AssertFormFileCollection((IFormFileCollection files) => Results.NoContent(), "files"); + AssertFormFileCollection(([FromForm] IFormFileCollection uploads) => Results.NoContent(), "uploads"); + + static void AssertFormFileCollection(Delegate handler, string expectedName) + { + // Arrange + var operation = GetOpenApiOperation(handler); + + // Assert + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.True(requestBody.Required); + } + } + +#nullable restore + + [Fact] + public void HandlesEndpointWithDescriptionAndSummary_WithAttributes() + { + var operation = GetOpenApiOperation( + [EndpointSummary("A summary")][EndpointDescription("A description")] (int id) => ""); + + // Assert + Assert.Equal("A description", operation.Description); + Assert.Equal("A summary", operation.Summary); + } + + private static OpenApiOperation GetOpenApiOperation( + Delegate action, + string pattern = null, + IEnumerable httpMethods = null, + string displayName = null, + object[] additionalMetadata = null) + { + var methodInfo = action.Method; + var attributes = methodInfo.GetCustomAttributes(); + + var httpMethodMetadata = new HttpMethodMetadata(httpMethods ?? new[] { "GET" }); + var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiOperationGeneratorTests) }; + var metadataItems = new List(attributes) { methodInfo, httpMethodMetadata }; + metadataItems.AddRange(additionalMetadata ?? Array.Empty()); + var endpointMetadata = new EndpointMetadataCollection(metadataItems.ToArray()); + var routePattern = RoutePatternFactory.Parse(pattern ?? "/"); + + var generator = new OpenApiGenerator( + hostEnvironment, + new ServiceProviderIsService()); + + return generator.GetOpenApiOperation(methodInfo, endpointMetadata, routePattern); + } + + private static void TestAction() + { + } + + private class ServiceProviderIsService : IServiceProviderIsService + { + public bool IsService(Type serviceType) => serviceType == typeof(IInferredServiceInterface); + } + + private class HostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } + public string ApplicationName { get; set; } + public string ContentRootPath { get; set; } + public IFileProvider ContentRootFileProvider { get; set; } + } + + private class InferredJsonClass + { + } + + private interface IInferredJsonInterface + { + } + + private record TryParseStringRecord(int Value) + { + public static bool TryParse(string value, out TryParseStringRecord result) => + throw new NotImplementedException(); + } + + private record struct TryParseStringRecordStruct(int Value) + { + public static bool TryParse(string value, out TryParseStringRecordStruct result) => + throw new NotImplementedException(); + } + + private interface IInferredServiceInterface + { + } + + private record BindAsyncRecord(int Value) + { + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => + throw new NotImplementedException(); + public static bool TryParse(string value, out BindAsyncRecord result) => + throw new NotImplementedException(); + } +} diff --git a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs index 1c25bbede9c7..7af7e03f097d 100644 --- a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs @@ -507,6 +507,7 @@ private static RouteHandlerBuilder Map( defaultOrder) { DisplayName = pattern.RawText ?? pattern.DebuggerToString(), + ServiceProvider = endpoints.ServiceProvider, }; // Methods defined in a top-level program are generated as statics so the delegate diff --git a/src/Http/Routing/src/Properties/AssemblyInfo.cs b/src/Http/Routing/src/Properties/AssemblyInfo.cs index 93c67c32e9c9..4f841ae3ed02 100644 --- a/src/Http/Routing/src/Properties/AssemblyInfo.cs +++ b/src/Http/Routing/src/Properties/AssemblyInfo.cs @@ -7,3 +7,4 @@ [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Routing.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ApiExplorer.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.OpenApi.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Mvc/Mvc.Core/src/ProducesResponseTypeAttribute.cs b/src/Mvc/Mvc.Core/src/ProducesResponseTypeAttribute.cs index e04a7bf152e4..58a01176a520 100644 --- a/src/Mvc/Mvc.Core/src/ProducesResponseTypeAttribute.cs +++ b/src/Mvc/Mvc.Core/src/ProducesResponseTypeAttribute.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Mvc.ApiExplorer; diff --git a/src/ProjectTemplates/Web.ProjectTemplates/Microsoft.DotNet.Web.ProjectTemplates.csproj b/src/ProjectTemplates/Web.ProjectTemplates/Microsoft.DotNet.Web.ProjectTemplates.csproj index bd9def6c3dc2..33d0e2038550 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/Microsoft.DotNet.Web.ProjectTemplates.csproj +++ b/src/ProjectTemplates/Web.ProjectTemplates/Microsoft.DotNet.Web.ProjectTemplates.csproj @@ -35,6 +35,7 @@ + diff --git a/src/ProjectTemplates/Web.ProjectTemplates/WebApi-CSharp.csproj.in b/src/ProjectTemplates/Web.ProjectTemplates/WebApi-CSharp.csproj.in index 4b3f835282a0..5b949b47fba0 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/WebApi-CSharp.csproj.in +++ b/src/ProjectTemplates/Web.ProjectTemplates/WebApi-CSharp.csproj.in @@ -14,6 +14,7 @@ + diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.cs index 2ce30ac64fd0..8af0c06784f2 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.cs @@ -5,6 +5,9 @@ #if (WindowsAuth) using Microsoft.AspNetCore.Authentication.Negotiate; #endif +#if (EnableOpenAPI) +using Microsoft.AspNetCore.OpenApi; +#endif #if (GenerateGraph) using Graph = Microsoft.Graph; #endif