diff --git a/src/Http/Http.Abstractions/src/Metadata/IDescriptionMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IDescriptionMetadata.cs new file mode 100644 index 000000000000..0d8fa7b6648e --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/IDescriptionMetadata.cs @@ -0,0 +1,15 @@ +// 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.Http.Metadata; + +/// +/// Defines a contract used to specify a description in . +/// +public interface IDescriptionMetadata +{ + /// + /// Gets the description associated with the endpoint. + /// + string Description { get; } +} diff --git a/src/Http/Http.Abstractions/src/Metadata/IExampleMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IExampleMetadata.cs new file mode 100644 index 000000000000..e7fe3de1d4c8 --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/IExampleMetadata.cs @@ -0,0 +1,39 @@ +// 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.Http.Metadata; + +/// +/// Defines a contract used to specify an example for a parameter, request body, or response +/// associated with an . +/// +public interface IExampleMetadata +{ + /// + /// Gets the summary associated with the example. + /// + string Summary { get; } + + /// + /// Gets the description associated with the example. + /// + string Description { get; } + + /// + /// Gets an example value associated with an example. + /// This property is mutually exclusibe with . + /// + object? Value { get; } + + /// + /// Gets a reference to an external value associated with an example. + /// This property is mutually exclusibe with . + /// + string? ExternalValue { get; } + + /// + /// If the example targets a parameter, gets + /// or sets the name of the parameter associated with the target. + /// + string? ParameterName { get; set; } +} diff --git a/src/Http/Http.Abstractions/src/Metadata/IProducesResponseTypeMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IProducesResponseTypeMetadata.cs index 92bc264271ba..cd9cc52ff732 100644 --- a/src/Http/Http.Abstractions/src/Metadata/IProducesResponseTypeMetadata.cs +++ b/src/Http/Http.Abstractions/src/Metadata/IProducesResponseTypeMetadata.cs @@ -22,4 +22,9 @@ public interface IProducesResponseTypeMetadata /// Gets the content types supported by the metadata. /// IEnumerable ContentTypes { get; } + + /// + /// Gets the description of the response. + /// + string? Description { get; } } diff --git a/src/Http/Http.Abstractions/src/Metadata/ISummaryMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/ISummaryMetadata.cs new file mode 100644 index 000000000000..e7bdb78a202e --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/ISummaryMetadata.cs @@ -0,0 +1,15 @@ +// 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.Http.Metadata; + +/// +/// Defines a contract used to specify a summary in . +/// +public interface ISummaryMetadata +{ + /// + /// Gets the summary associated with the endpoint. + /// + string Summary { get; } +} diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 92c400c0bac6..19ce9f1a1480 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -4,3 +4,15 @@ Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata.Name.get -> string? abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string? Microsoft.AspNetCore.Http.Metadata.ISkipStatusCodePagesMetadata +Microsoft.AspNetCore.Http.Metadata.IDescriptionMetadata +Microsoft.AspNetCore.Http.Metadata.IDescriptionMetadata.Description.get -> string! +Microsoft.AspNetCore.Http.Metadata.IExampleMetadata +Microsoft.AspNetCore.Http.Metadata.IExampleMetadata.Summary.get -> string! +Microsoft.AspNetCore.Http.Metadata.IExampleMetadata.Description.get -> string! +Microsoft.AspNetCore.Http.Metadata.IExampleMetadata.Value.get -> object? +Microsoft.AspNetCore.Http.Metadata.IExampleMetadata.ExternalValue.get -> string? +Microsoft.AspNetCore.Http.Metadata.IExampleMetadata.ParameterName.get -> string? +Microsoft.AspNetCore.Http.Metadata.IExampleMetadata.ParameterName.set -> void +Microsoft.AspNetCore.Http.Metadata.ISummaryMetadata +Microsoft.AspNetCore.Http.Metadata.ISummaryMetadata.Summary.get -> string! +Microsoft.AspNetCore.Http.Metadata.IProducesResponseTypeMetadata.Description.get -> string? diff --git a/src/Http/Http.Extensions/src/DescriptionAttribute.cs b/src/Http/Http.Extensions/src/DescriptionAttribute.cs new file mode 100644 index 000000000000..ee840710b578 --- /dev/null +++ b/src/Http/Http.Extensions/src/DescriptionAttribute.cs @@ -0,0 +1,30 @@ +// 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.Http.Metadata; + +namespace Microsoft.AspNetCore.Http; + +/// +/// Specifies a description for the endpoint in . +/// +/// +/// The OpenAPI specification supports a description attribute on operations and parameters that +/// can be used to annotate endpoints with detailed, multiline descriptors of their behavior. +/// behavior. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Delegate | AttributeTargets.Parameter, Inherited = false, AllowMultiple = false)] +public sealed class DescriptionAttribute : Attribute, IDescriptionMetadata +{ + /// + /// Initializes an instance of the . + /// + /// The description associated with the endpoint or parameter. + public DescriptionAttribute(string description) + { + Description = description; + } + + /// + public string Description { get; } +} diff --git a/src/Http/Http.Extensions/src/ExampleAttribute.cs b/src/Http/Http.Extensions/src/ExampleAttribute.cs new file mode 100644 index 000000000000..898fccd7cc59 --- /dev/null +++ b/src/Http/Http.Extensions/src/ExampleAttribute.cs @@ -0,0 +1,55 @@ +// 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.Http.Metadata; + +namespace Microsoft.AspNetCore.Http; + +/// +/// Specifies an example associated with a parameter, request body, or response of an . +/// +/// +/// The OpenAPI specification supports an examples property that can be used to annotate +/// request bodies, parameters, and responses with examples of the data type associated +/// with each element. +/// +[AttributeUsage(AttributeTargets.Parameter, Inherited = false, AllowMultiple = false)] +public sealed class ExampleAttribute : Attribute, IExampleMetadata +{ + /// + /// Initializes an instance of the given + /// a . + /// + public ExampleAttribute(string summary, string description, object value) + { + Summary = summary; + Description = description; + Value = value; + } + + /// + /// Initializes an instance of the given + /// an . + /// + public ExampleAttribute(string summary, string description, string externalValue) + { + Summary = summary; + Description = description; + ExternalValue = externalValue; + } + + /// + public string Description { get; } + + /// + public string Summary { get; } + + /// + public object? Value { get; } + + /// + public string? ExternalValue { get; } + + /// + public string? ParameterName { get; set; } +} diff --git a/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt index 1030d0f0793e..92de37bd91e8 100644 --- a/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt @@ -1,3 +1,18 @@ #nullable enable Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions static Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions.ConfigureRouteHandlerJsonOptions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +Microsoft.AspNetCore.Http.DescriptionAttribute +Microsoft.AspNetCore.Http.DescriptionAttribute.DescriptionAttribute(string! description) -> void +Microsoft.AspNetCore.Http.DescriptionAttribute.Description.get -> string! +Microsoft.AspNetCore.Http.SummaryAttribute +Microsoft.AspNetCore.Http.SummaryAttribute.SummaryAttribute(string! summary) -> void +Microsoft.AspNetCore.Http.SummaryAttribute.Summary.get -> string! +Microsoft.AspNetCore.Http.ExampleAttribute +Microsoft.AspNetCore.Http.ExampleAttribute.ExampleAttribute(string! summary, string! description, object! value) -> void +Microsoft.AspNetCore.Http.ExampleAttribute.ExampleAttribute(string! summary, string! description, string! externalValue) -> void +Microsoft.AspNetCore.Http.ExampleAttribute.Description.get -> string! +Microsoft.AspNetCore.Http.ExampleAttribute.Summary.get -> string! +Microsoft.AspNetCore.Http.ExampleAttribute.ExternalValue.get -> string? +Microsoft.AspNetCore.Http.ExampleAttribute.Value.get -> object? +Microsoft.AspNetCore.Http.ExampleAttribute.ParameterName.get -> string? +Microsoft.AspNetCore.Http.ExampleAttribute.ParameterName.set -> void diff --git a/src/Http/Http.Extensions/src/SummaryAttribute.cs b/src/Http/Http.Extensions/src/SummaryAttribute.cs new file mode 100644 index 000000000000..f32ea74128f5 --- /dev/null +++ b/src/Http/Http.Extensions/src/SummaryAttribute.cs @@ -0,0 +1,25 @@ +// 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.Http.Metadata; + +namespace Microsoft.AspNetCore.Http; + +/// +/// Specifies a summary in . +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Delegate, Inherited = false, AllowMultiple = false)] +public sealed class SummaryAttribute : Attribute, ISummaryMetadata +{ + /// + /// Initializes an instance of the . + /// + /// The summary associated with the endpoint or parameter. + public SummaryAttribute(string summary) + { + Summary = summary; + } + + /// + public string Summary { get; } +} diff --git a/src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs b/src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs index 31696a3da7be..9ecbe58f8ac5 100644 --- a/src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs @@ -42,11 +42,11 @@ public static RouteHandlerBuilder ExcludeFromDescription(this RouteHandlerBuilde #pragma warning disable RS0026 public static RouteHandlerBuilder Produces(this RouteHandlerBuilder builder, #pragma warning restore RS0026 - int statusCode = StatusCodes.Status200OK, + int statusCode = StatusCodes.Status200OK, string? contentType = null, params string[] additionalContentTypes) { - return Produces(builder, statusCode, typeof(TResponse), contentType, additionalContentTypes); + return Produces(builder, statusCode, null, typeof(TResponse), contentType, additionalContentTypes); } /// @@ -55,6 +55,7 @@ public static RouteHandlerBuilder Produces(this RouteHandlerBuilder b /// /// The . /// The response status code. + /// The response status code. /// The type of the response. Defaults to null. /// The response content type. Defaults to "application/json" if responseType is not null, otherwise defaults to null. /// Additional response content types the endpoint produces for the supplied status code. @@ -62,7 +63,8 @@ public static RouteHandlerBuilder Produces(this RouteHandlerBuilder b #pragma warning disable RS0026 public static RouteHandlerBuilder Produces(this RouteHandlerBuilder builder, #pragma warning restore RS0026 - int statusCode, + int statusCode, + string? description = null, Type? responseType = null, string? contentType = null, params string[] additionalContentTypes) @@ -74,7 +76,13 @@ public static RouteHandlerBuilder Produces(this RouteHandlerBuilder builder, if (contentType is null) { - builder.WithMetadata(new ProducesResponseTypeMetadata(responseType ?? typeof(void), statusCode)); + builder.WithMetadata(new ProducesResponseTypeMetadata(responseType ?? typeof(void), statusCode, description)); + return builder; + } + + if (description is not null) + { + builder.WithMetadata(new ProducesResponseTypeMetadata(responseType ?? typeof(void), description, statusCode, contentType, additionalContentTypes)); return builder; } @@ -209,6 +217,94 @@ public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder, return builder; } + /// + /// Adds to representing + /// an example of the parameter for all builders produced by . + /// + /// The . + /// A string representing the name of the parameter associated with the example. + /// A string representing a summary of the example. + /// A string representing a detailed description of the example. + /// An object representing the example associated with a particualr type. + /// A that can be used to further customize the endpoint. + public static RouteHandlerBuilder WithParameterExample(this RouteHandlerBuilder builder, string parameterName, string summary, string description, object value) + { + builder.WithMetadata(new ExampleAttribute(summary, description, value) { ParameterName = parameterName }); + return builder; + } + + /// + /// Adds to representing + /// an example of the parameter for all builders produced by . + /// + /// The . + /// A string representing the name of the parameter associated with the example. + /// A string representing a summary of the example. + /// A string representing a detailed description of the example. + /// A string pointing to a reference of the example. + /// A that can be used to further customize the endpoint. + public static RouteHandlerBuilder WithParameterExample(this RouteHandlerBuilder builder, string parameterName, string summary, string description, string externalValue) + { + builder.WithMetadata(new ExampleAttribute(summary, description, externalValue) { ParameterName = parameterName }); + return builder; + } + + /// + /// Adds to representing + /// an example of the response type for all builders produced by . + /// + /// The . + /// A string representing a summary of the example. + /// A string representing a detailed description of the example. + /// An object representing the example associated with a particualr type. + /// A that can be used to further customize the endpoint. + public static RouteHandlerBuilder WithResponseExample(this RouteHandlerBuilder builder, string summary, string description, object value) + { + builder.WithMetadata(new ExampleAttribute(summary, description, value)); + return builder; + } + + /// + /// Adds to representing + /// an example of the response type for all builders produced by . + /// + /// The . + /// A string representing a summary of the example. + /// A string representing a detailed description of the example. + /// A string pointing to a reference of the example. + /// A that can be used to further customize the endpoint. + public static RouteHandlerBuilder WithResponseExample(this RouteHandlerBuilder builder, string summary, string description, string externalValue) + { + builder.WithMetadata(new ExampleAttribute(summary, description, externalValue)); + return builder; + } + + /// + /// Adds to for all builders + /// produced by . + /// + /// The . + /// A string representing a detailed description of the endpoint. + /// A that can be used to further customize the endpoint. + public static RouteHandlerBuilder WithDescription(this RouteHandlerBuilder builder, string description) + { + builder.WithMetadata(new DescriptionAttribute(description)); + return builder; + } + + /// + /// Adds to for all builders + /// produced by . + /// + /// The . + /// A string representation a brief description of the endpoint. + /// A that can be used to further customize the endpoint. + public static RouteHandlerBuilder WithSummary(this RouteHandlerBuilder builder, string summary) + { + builder.WithMetadata(new SummaryAttribute(summary)); + return builder; + } + private static string[] GetAllContentTypes(string contentType, string[] additionalContentTypes) { var allContentTypes = new string[additionalContentTypes.Length + 1]; diff --git a/src/Http/Routing/src/PublicAPI.Shipped.txt b/src/Http/Routing/src/PublicAPI.Shipped.txt index 6d681236b30b..9945a2c3a78a 100644 --- a/src/Http/Routing/src/PublicAPI.Shipped.txt +++ b/src/Http/Routing/src/PublicAPI.Shipped.txt @@ -563,7 +563,7 @@ static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.Accepts(th static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.Accepts(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, bool isOptional, string! contentType, params string![]! additionalContentTypes) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.Accepts(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, string! contentType, params string![]! additionalContentTypes) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.ExcludeFromDescription(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! -static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.Produces(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, int statusCode, System.Type? responseType = null, string? contentType = null, params string![]! additionalContentTypes) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! +static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.Produces(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, int statusCode, string? description = null, System.Type? responseType = null, string? contentType = null, params string![]! additionalContentTypes) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.Produces(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, int statusCode = 200, string? contentType = null, params string![]! additionalContentTypes) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.ProducesProblem(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, int statusCode, string? contentType = null) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.ProducesValidationProblem(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, int statusCode = 400, string? contentType = null) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index ecbe9daba8b5..881edd9bff9a 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -3,3 +3,9 @@ Microsoft.AspNetCore.Routing.RouteOptions.SetParameterPolicy(string! token, Syst Microsoft.AspNetCore.Routing.RouteOptions.SetParameterPolicy(string! token) -> void static Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapPatch(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! handler) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! static Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapPatch(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, Microsoft.AspNetCore.Http.RequestDelegate! requestDelegate) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! +static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithResponseExample(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, string! summary, string! description, object! value) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! +static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithResponseExample(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, string! summary, string! description, string! externalValue) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! +static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithParameterExample(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, string! parameterName, string! summary, string! description, object! value) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! +static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithParameterExample(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, string! parameterName, string! summary, string! description, string! externalValue) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! +static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithDescription(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, string! description) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! +static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithSummary(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, string! summary) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! diff --git a/src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiParameterDescription.cs b/src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiParameterDescription.cs index a06e7e5f18c6..fbd4471e394e 100644 --- a/src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiParameterDescription.cs +++ b/src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiParameterDescription.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Http.Metadata; namespace Microsoft.AspNetCore.Mvc.ApiExplorer; @@ -63,4 +64,14 @@ public class ApiParameterDescription /// Gets or sets the default value for a parameter. /// public object? DefaultValue { get; set; } + + /// + /// Gets or sets a collection of examples associated with the parameter. + /// + public IEnumerable? Examples { get; set; } = default!; + + /// + /// Gets or sets the description associated with the parameter. + /// + public string? Description { get; set; } = default!; } diff --git a/src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiResponseType.cs b/src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiResponseType.cs index 71fc8ecf46d9..ff3d150d6954 100644 --- a/src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiResponseType.cs +++ b/src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiResponseType.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 Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc.ApiExplorer; @@ -47,4 +48,14 @@ public class ApiResponseType /// for communicating error conditions. /// public bool IsDefaultResponse { get; set; } + + /// + /// Gets or sets a collection of elements representing examples for the response type. + /// + public IEnumerable? Examples { get; set; } + + /// + /// Gets or sets the description associated with the response. + /// + public string? Description { get; set; } = default!; } diff --git a/src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..76d37fcd6f68 100644 --- a/src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt @@ -1 +1,9 @@ #nullable enable +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription.Examples.get -> System.Collections.Generic.IEnumerable? +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription.Examples.set -> void +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiResponseType.Examples.get -> System.Collections.Generic.IEnumerable? +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiResponseType.Examples.set -> void +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription.Description.get -> string? +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription.Description.set -> void +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiResponseType.Description.get -> string? +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiResponseType.Description.set -> void diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index 9b22fd7c6e6f..b18533bfdb3c 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -102,7 +102,7 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string foreach (var parameter in methodInfo.GetParameters()) { - var parameterDescription = CreateApiParameterDescription(parameter, routeEndpoint.RoutePattern); + var parameterDescription = CreateApiParameterDescription(parameter, routeEndpoint); if (parameterDescription is null) { @@ -155,8 +155,9 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string return apiDescription; } - private ApiParameterDescription? CreateApiParameterDescription(ParameterInfo parameter, RoutePattern pattern) + private ApiParameterDescription? CreateApiParameterDescription(ParameterInfo parameter, RouteEndpoint endpoint) { + var pattern = endpoint.RoutePattern; var (source, name, allowEmpty, paramType) = GetBindingSourceAndName(parameter, pattern); // Services are ignored because they are not request parameters. @@ -171,6 +172,11 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string var isOptional = parameter.HasDefaultValue || nullability.ReadState != NullabilityState.NotNull || allowEmpty; var parameterDescriptor = CreateParameterDescriptor(parameter); var routeInfo = CreateParameterRouteInfo(pattern, parameter, isOptional); + // Process any value examples for this parameter that are marked as attributes + var examplesInAttributes = parameter.GetCustomAttributes().OfType(); + // Process any example values for this parameter that are registered via an extension method + var examplesInMetadata = endpoint.Metadata.OfType().Where(metadata => metadata.ParameterName == name); + var parameterDescriptionAttribute = parameter.GetCustomAttribute(); return new ApiParameterDescription { @@ -181,7 +187,9 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string Type = parameter.ParameterType, IsRequired = !isOptional, ParameterDescriptor = parameterDescriptor, - RouteInfo = routeInfo + RouteInfo = routeInfo, + Examples = examplesInMetadata.Concat(examplesInAttributes), + Description = parameterDescriptionAttribute?.Description }; } @@ -312,6 +320,7 @@ private static void AddSupportedResponseTypes( // and types added via the extension methods (which implement IProducesResponseTypeMetadata). var responseProviderMetadata = endpointMetadata.GetOrderedMetadata(); var producesResponseMetadata = endpointMetadata.GetOrderedMetadata(); + var examplesMetadata = endpointMetadata.GetOrderedMetadata(); var errorMetadata = endpointMetadata.GetMetadata(); var defaultErrorType = errorMetadata?.Type ?? typeof(void); var contentTypes = new MediaTypeCollection(); @@ -335,6 +344,11 @@ private static void AddSupportedResponseTypes( apiResponseType.Type = responseType; } + if (examplesMetadata is not null) + { + apiResponseType.Examples = examplesMetadata.ToArray(); + } + apiResponseType.ModelMetadata = CreateModelMetadata(apiResponseType.Type); if (contentTypes.Count > 0) @@ -359,6 +373,11 @@ private static void AddSupportedResponseTypes( // Set the default response type only when none has already been set explicitly with metadata. var defaultApiResponseType = CreateDefaultApiResponseType(responseType); + if (examplesMetadata is not null) + { + defaultApiResponseType.Examples = examplesMetadata.ToArray(); + } + if (contentTypes.Count > 0) { // If metadata provided us with response formats, use that instead of the default. @@ -383,6 +402,7 @@ private static Dictionary ReadResponseMetadata( var apiResponseType = new ApiResponseType { Type = metadata.Type, + Description = metadata.Description, StatusCode = statusCode, }; diff --git a/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..7b95681f93ad 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt @@ -1 +1,9 @@ #nullable enable +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription.Examples.get -> System.Collections.Generic.IEnumerable? (forwarded, contained in Microsoft.AspNetCore.Mvc.Abstractions) +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription.Examples.set -> void (forwarded, contained in Microsoft.AspNetCore.Mvc.Abstractions) +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiResponseType.Examples.get -> System.Collections.Generic.IEnumerable? (forwarded, contained in Microsoft.AspNetCore.Mvc.Abstractions) +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiResponseType.Examples.set -> void (forwarded, contained in Microsoft.AspNetCore.Mvc.Abstractions) +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription.Description.get -> string? (forwarded, contained in Microsoft.AspNetCore.Mvc.Abstractions) +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription.Description.set -> void (forwarded, contained in Microsoft.AspNetCore.Mvc.Abstractions) +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiResponseType.Description.get -> string? (forwarded, contained in Microsoft.AspNetCore.Mvc.Abstractions) +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiResponseType.Description.set -> void (forwarded, contained in Microsoft.AspNetCore.Mvc.Abstractions) diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index aef0b7ba2e4b..4f3a13cd29df 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -4,8 +4,10 @@ using System.ComponentModel; using System.Reflection; using System.Security.Claims; +using Microsoft.AspNetCore.Authorization.Infrastructure; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -488,7 +490,7 @@ public void TestParameterIsRequiredForObliviousNullabilityContext() [Fact] public void TestParameterAttributesCanBeInspected() { - var apiDescription = GetApiDescription(([Description("The name.")] string name) => { }); + var apiDescription = GetApiDescription(([System.ComponentModel.Description("The name.")] string name) => { }); Assert.Equal(1, apiDescription.ParameterDescriptions.Count); var nameParam = apiDescription.ParameterDescriptions[0]; @@ -505,7 +507,7 @@ public void TestParameterAttributesCanBeInspected() Assert.NotNull(descriptor.ParameterInfo); - var description = Assert.Single(descriptor.ParameterInfo.GetCustomAttributes()); + var description = Assert.Single(descriptor.ParameterInfo.GetCustomAttributes()); Assert.NotNull(description); Assert.Equal("The name.", description.Description); @@ -1121,6 +1123,263 @@ public void HandlesEndpointWithRouteConstraints() constraint => Assert.IsType(constraint)); } + [Fact] + public void HandlesEndpointWithDescriptionAndSummary_WithExtensionMethods() + { + var builder = CreateBuilder(); + builder.MapGet("/api/todos/{id}", (int id) => "").WithDescription("A description").WithSummary("A summary"); + + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var apiDescription = Assert.Single(context.Results); + Assert.NotEmpty(apiDescription.ActionDescriptor.EndpointMetadata); + + var descriptionMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + Assert.NotNull(descriptionMetadata); + Assert.Equal("A description", descriptionMetadata.Description); + + var summaryMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + Assert.NotNull(summaryMetadata); + Assert.Equal("A summary", summaryMetadata.Summary); + } + + [Fact] + public void HandlesEndpointWithDescriptionAndSummary_WithAttributes() + { + var builder = CreateBuilder(); + builder.MapGet("/api/todos/{id}", [Summary("A summary")] [Http.Description("A description")] (int id) => ""); + + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var apiDescription = Assert.Single(context.Results); + Assert.NotEmpty(apiDescription.ActionDescriptor.EndpointMetadata); + + var descriptionMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + Assert.NotNull(descriptionMetadata); + Assert.Equal("A description", descriptionMetadata.Description); + + var summaryMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + Assert.NotNull(summaryMetadata); + Assert.Equal("A summary", summaryMetadata.Summary); + } + + [Fact] + public void HandlesResponseWithDescription_ViaExtensionMethod() + { + var builder = CreateBuilder(); + builder.MapGet("/api/todos/{id}", (int id) => "").Produces(StatusCodes.Status200OK, "This is a response description", typeof(Todo)); + + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var apiDescription = Assert.Single(context.Results); + var apiResponse = Assert.Single(apiDescription.SupportedResponseTypes); + Assert.Equal("This is a response description", apiResponse.Description); + } + + [Fact] + public void HandlesParameterWithDescription_ViaAttributes() + { + var builder = CreateBuilder(); + builder.MapGet("/api/todos/{id}", ([Http.Description("A description for the parameter")] int id) => ""); + + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var apiDescription = Assert.Single(context.Results); + var apiParameterDescription = Assert.Single(apiDescription.ParameterDescriptions); + Assert.Equal("A description for the parameter", apiParameterDescription.Description); + } + + [Fact] + public void HandleRequestBodyWithExample_ViaExtensionMethod() + { + var builder = CreateBuilder(); + builder + .MapPost("/api/todos", (Todo todo) => "") + .WithParameterExample("todo", "A todo", "A quick description of the todo", new Todo() { Id = 0, Title = "foo" }); + + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().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); + var example = Assert.Single(parameter.Examples); + Assert.Equal("A quick description of the todo", example.Description); + Assert.Equal("A todo", example.Summary); + var todo = Assert.IsType(example.Value); + Assert.Equal(0, todo.Id); + } + + [Fact] + public void CanHandleParameterWithExample_WithAttribute() + { + var builder = CreateBuilder(); + builder.MapPost("/api/todos", ( + [Example("A number", "A detailed description of number", 2)] + int id, + [Example("A bool", "A detailed description of bool", true)] + bool name + ) => ""); + + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var apiDescription = Assert.Single(context.Results); + Assert.Collection(apiDescription.ParameterDescriptions, + intParam => + { + var example = Assert.Single(intParam.Examples); + Assert.Equal("A detailed description of number", example.Description); + Assert.Equal("A number", example.Summary); + var value = Assert.IsType(example.Value); + Assert.Equal(2, value); + }, + boolParam => + { + var example = Assert.Single(boolParam.Examples); + Assert.Equal("A detailed description of bool", example.Description); + Assert.Equal("A bool", example.Summary); + var value = Assert.IsType(example.Value); + Assert.True(value); + } + ); + } + + [Fact] + public void CanHandleParameterWithExample_ExtensionMethod() + { + var date = new DateTime(); + var guid = new Guid(); + var builder = CreateBuilder(); + builder.MapPost("/api/todos", (DateTime startDate, Guid id) => "") + .WithParameterExample("startDate", "A date", "A description of a date", date) + .WithParameterExample("id", "An ID", "A GUID", guid); + + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var apiDescription = Assert.Single(context.Results); + Assert.Collection(apiDescription.ParameterDescriptions, + intParam => + { + var example = Assert.Single(intParam.Examples); + Assert.Equal("A date", example.Summary); + Assert.Equal("A description of a date", example.Description); + var value = Assert.IsType(example.Value); + Assert.Equal(date, value); + }, + boolParam => + { + var example = Assert.Single(boolParam.Examples); + Assert.Equal("An ID", example.Summary); + Assert.Equal("A GUID", example.Description); + var value = Assert.IsType(example.Value); + Assert.Equal(guid, value); + } + ); + } + + [Fact] + public void CanHandleResponseWithExample() + { + var builder = CreateBuilder(); + builder.MapGet("/api/todos", (int id) => "").WithResponseExample("A todo", "A todo description", new Todo() { Id = 1, Title = "foo" }); + + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var apiDescription = Assert.Single(context.Results); + var response = Assert.Single(apiDescription.SupportedResponseTypes); + var example = Assert.Single(response.Examples); + Assert.Equal("A todo description", example.Description); + Assert.Equal("A todo", example.Summary); + var todo = Assert.IsType(example.Value); + Assert.Equal(1, todo.Id); + } + private static IEnumerable GetSortedMediaTypes(ApiResponseType apiResponseType) { return apiResponseType.ApiResponseFormats @@ -1186,6 +1445,12 @@ private interface IInferredJsonInterface { } + private class Todo + { + public int Id { get; set; } + public string Title { get; set; } + } + private class ServiceProviderIsService : IServiceProviderIsService { public bool IsService(Type serviceType) => serviceType == typeof(IInferredServiceInterface); diff --git a/src/Shared/ApiExplorerTypes/ProducesResponseTypeMetadata.cs b/src/Shared/ApiExplorerTypes/ProducesResponseTypeMetadata.cs index 11b2de096f35..ae24fd551d29 100644 --- a/src/Shared/ApiExplorerTypes/ProducesResponseTypeMetadata.cs +++ b/src/Shared/ApiExplorerTypes/ProducesResponseTypeMetadata.cs @@ -30,14 +30,29 @@ public ProducesResponseTypeMetadata(int statusCode) /// /// The of object that is going to be written in the response. /// The HTTP response status code. - public ProducesResponseTypeMetadata(Type type, int statusCode) + /// A description of the response. + public ProducesResponseTypeMetadata(Type type, int statusCode, string? description = null) { Type = type ?? throw new ArgumentNullException(nameof(type)); StatusCode = statusCode; IsResponseTypeSetByDefault = false; + Description = description; _contentTypes = Enumerable.Empty(); } + /// + /// Initializes an instance of . + /// + /// The of object that is going to be written in the response. + /// A description of the response. + /// The HTTP response status code. + /// The content type associated with the response. + /// Additional content types supported by the response. + public ProducesResponseTypeMetadata(Type type, string description, int statusCode, string contentType, params string[] additionalContentTypes) : this(type, statusCode, contentType, additionalContentTypes) + { + Description = description; + } + /// /// Initializes an instance of . /// @@ -87,6 +102,9 @@ public ProducesResponseTypeMetadata(Type type, int statusCode, string contentTyp /// internal bool IsResponseTypeSetByDefault { get; } + /// + public string? Description { get; } + public IEnumerable ContentTypes => _contentTypes; private static List GetContentTypes(string contentType, string[] additionalContentTypes)