Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,31 @@ public static TBuilder WithMetadata<TBuilder>(this TBuilder builder, params obje

return builder;
}

/// <summary>
/// Sets the <see cref="EndpointNameAttribute"/> for all endpoints produced
/// on the target <see cref="IEndpointConventionBuilder"/>.
/// </summary>
/// <param name="builder">The <see cref="IEndpointConventionBuilder"/>.</param>
/// <param name="endpointName">The endpoint name.</param>
/// <returns>The <see cref="IEndpointConventionBuilder"/>.</returns>
public static TBuilder WithName<TBuilder>(this TBuilder builder, string endpointName) where TBuilder : IEndpointConventionBuilder
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

excited about this

{
builder.WithMetadata(new EndpointNameAttribute(endpointName));
return builder;
}

/// <summary>
/// Sets the <see cref="EndpointGroupNameAttribute"/> for all endpoints produced
/// on the target <see cref="IEndpointConventionBuilder"/>.
/// </summary>
/// <param name="builder">The <see cref="IEndpointConventionBuilder"/>.</param>
/// <param name="endpointGroupName">The endpoint group name.</param>
/// <returns>The <see cref="IEndpointConventionBuilder"/>.</returns>
public static TBuilder WithGroupName<TBuilder>(this TBuilder builder, string endpointGroupName) where TBuilder : IEndpointConventionBuilder
{
builder.WithMetadata(new EndpointGroupNameAttribute(endpointGroupName));
return builder;
}
}
}
32 changes: 32 additions & 0 deletions src/Http/Routing/src/EndpointGroupNameAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Routing
{
/// <summary>
/// Specifies the endpoint group name in <see cref="Microsoft.AspNetCore.Http.Endpoint.Metadata"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Delegate | AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class EndpointGroupNameAttribute : Attribute, IEndpointGroupNameMetadata
{
/// <summary>
/// Initializes an instance of the EndpointGroupNameAttribute.
/// </summary>
/// <param name="endpointGroupName">The endpoint group name.</param>
public EndpointGroupNameAttribute(string endpointGroupName)
{
if (endpointGroupName == null)
{
throw new ArgumentNullException(nameof(endpointGroupName));
}

EndpointGroupName = endpointGroupName;
}

/// <inheritdoc />
public string EndpointGroupName { get; }
}
}
36 changes: 36 additions & 0 deletions src/Http/Routing/src/EndpointNameAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Routing
{
/// <summary>
/// Specifies the endpoint name in <see cref="Endpoint.Metadata"/>.
/// </summary>
/// <remarks>
/// Endpoint names must be unique within an application, and can be used to unambiguously
/// identify a desired endpoint for URI generation using <see cref="Microsoft.AspNetCore.Routing.LinkGenerator"/>
/// </remarks>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Delegate, Inherited = false, AllowMultiple = false)]
public sealed class EndpointNameAttribute : Attribute, IEndpointNameMetadata
{
/// <summary>
/// Initializes an instance of the EndpointNameAttribute.
/// </summary>
/// <param name="endpointName">The endpoint name.</param>
public EndpointNameAttribute(string endpointName)
{
if (endpointName == null)
{
throw new ArgumentNullException(nameof(endpointName));
}

EndpointName = endpointName;
}

/// <inheritdoc />
public string EndpointName { get; }
}
}
18 changes: 18 additions & 0 deletions src/Http/Routing/src/ExcludeFromDescriptionAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Routing
{
/// <summary>
/// Indicates that this <see cref="Endpoint"/> should not be included in the generated API metadata.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Delegate, AllowMultiple = false, Inherited = true)]
public sealed class ExcludeFromDescriptionAttribute : Attribute, IExcludeFromDescriptionMetadata
{
/// <inheritdoc />
public bool ExcludeFromDescription => true;
}
}
18 changes: 18 additions & 0 deletions src/Http/Routing/src/IEndpointGroupNameMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// 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;

namespace Microsoft.AspNetCore.Routing
{
/// <summary>
/// Defines a contract used to specify an endpoint group name in <see cref="Endpoint.Metadata"/>.
/// </summary>
public interface IEndpointGroupNameMetadata
{
/// <summary>
/// Gets the endpoint group name.
/// </summary>
string EndpointGroupName { get; }
}
}
21 changes: 21 additions & 0 deletions src/Http/Routing/src/IExcludeFromDescriptionMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Routing
{
/// <summary>
/// Indicates whether or not that API explorer data should be emitted for this endpoint.
/// </summary>
public interface IExcludeFromDescriptionMetadata
{
/// <summary>
/// Gets a value indicating whether OpenAPI
/// data should be excluded for this endpoint. If <see langword="true"/>,
/// API metadata is not emitted.
/// </summary>
bool ExcludeFromDescription { get; }
}
}
15 changes: 15 additions & 0 deletions src/Http/Routing/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,18 @@ static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.
static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.MapMethods(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Collections.Generic.IEnumerable<string!>! httpMethods, System.Delegate! action) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder!
static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.MapPost(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! action) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder!
static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.MapPut(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! action) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder!
Microsoft.AspNetCore.Routing.IEndpointGroupNameMetadata
Microsoft.AspNetCore.Routing.IEndpointGroupNameMetadata.EndpointGroupName.get -> string!
Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute
Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute.EndpointGroupNameAttribute(string! endpointGroupName) -> void
Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute.EndpointGroupName.get -> string!
Microsoft.AspNetCore.Routing.EndpointNameAttribute
Microsoft.AspNetCore.Routing.EndpointNameAttribute.EndpointNameAttribute(string! endpointName) -> void
Microsoft.AspNetCore.Routing.EndpointNameAttribute.EndpointName.get -> string!
static Microsoft.AspNetCore.Builder.RoutingEndpointConventionBuilderExtensions.WithName<TBuilder>(this TBuilder builder, string! endpointName) -> TBuilder
static Microsoft.AspNetCore.Builder.RoutingEndpointConventionBuilderExtensions.WithGroupName<TBuilder>(this TBuilder builder, string! endpointGroupName) -> TBuilder
Microsoft.AspNetCore.Routing.IExcludeFromDescriptionMetadata
Microsoft.AspNetCore.Routing.IExcludeFromDescriptionMetadata.ExcludeFromDescription.get -> bool
Microsoft.AspNetCore.Routing.ExcludeFromDescriptionAttribute
Microsoft.AspNetCore.Routing.ExcludeFromDescriptionAttribute.ExcludeFromDescriptionAttribute() -> void
Microsoft.AspNetCore.Routing.ExcludeFromDescriptionAttribute.ExcludeFromDescription.get -> bool
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,38 @@ public void WithMetadata_ChainedCall_ReturnedBuilderIsDerivedType()
Assert.True(chainedBuilder.TestProperty);
}

[Fact]
public void WithName_SetsEndpointName()
{
// Arrange
var builder = CreateBuilder();

// Act
builder.WithName("SomeEndpointName");

// Assert
var endpoint = builder.Build();

var endpointName = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>();
Assert.Equal("SomeEndpointName", endpointName.EndpointName);
}

[Fact]
public void WithGroupName_SetsEndpointGroupName()
{
// Arrange
var builder = CreateBuilder();

// Act
builder.WithGroupName("SomeEndpointGroupName");

// Assert
var endpoint = builder.Build();

var endpointGroupName = endpoint.Metadata.GetMetadata<IEndpointGroupNameMetadata>();
Assert.Equal("SomeEndpointGroupName", endpointGroupName.EndpointGroupName);
}

private TestEndpointConventionBuilder CreateBuilder()
{
var conventionBuilder = new DefaultEndpointConventionBuilder(new RouteEndpointBuilder(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ public void OnProvidersExecuting(ApiDescriptionProviderContext context)
{
if (endpoint is RouteEndpoint routeEndpoint &&
routeEndpoint.Metadata.GetMetadata<MethodInfo>() is { } methodInfo &&
routeEndpoint.Metadata.GetMetadata<IHttpMethodMetadata>() is { } httpMethodMetadata)
routeEndpoint.Metadata.GetMetadata<IHttpMethodMetadata>() is { } httpMethodMetadata &&
(routeEndpoint.Metadata.GetMetadata<IExcludeFromDescriptionMetadata>() == null ||
routeEndpoint.Metadata.GetMetadata<IExcludeFromDescriptionMetadata>() is { ExcludeFromDescription: false} ))
{
// REVIEW: Should we add an ApiDescription for endpoints without IHttpMethodMetadata? Swagger doesn't handle
// a null HttpMethod even though it's nullable on ApiDescription, so we'd need to define "default" HTTP methods.
Expand Down Expand Up @@ -89,6 +91,7 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string
var apiDescription = new ApiDescription
{
HttpMethod = httpMethod,
GroupName = routeEndpoint.Metadata.GetMetadata<IEndpointGroupNameMetadata>()?.EndpointGroupName,
RelativePath = routeEndpoint.RoutePattern.RawText?.TrimStart('/'),
ActionDescriptor = new ActionDescriptor
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ public void GetApiDescription_ReturnsActionResultWithProduces_And_ProducesConten
// Arrange
var action = CreateActionDescriptor(methodName, controllerType);
action.FilterDescriptors = filterDescriptors;
var expectedMediaTypes = new[] { "application/json", "text/json" };
var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" };

// Act
var descriptions = GetApiDescriptions(action);
Expand Down Expand Up @@ -677,7 +677,7 @@ public void GetApiDescription_ReturnsVoidWithProducesContentType(
// Arrange
var action = CreateActionDescriptor(methodName, controllerType);
action.FilterDescriptors = filterDescriptors;
var expectedMediaTypes = new[] { "application/json", "text/json" };
var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" };

// Act
var descriptions = GetApiDescriptions(action);
Expand Down Expand Up @@ -740,7 +740,7 @@ public void GetApiDescription_ReturnsActionResultOfTWithProducesContentType(
new ProducesResponseTypeAttribute(typeof(ErrorDetails), 500),
FilterScope.Action)
};
var expectedMediaTypes = new[] { "application/json", "text/json" };
var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" };

// Act
var descriptions = GetApiDescriptions(action);
Expand Down Expand Up @@ -810,7 +810,7 @@ public void GetApiDescription_ReturnsActionResultOfTWithProducesContentType_ForS
new ProducesResponseTypeAttribute(typeof(ErrorDetails), 500),
FilterScope.Action)
};
var expectedMediaTypes = new[] { "application/json", "text/json" };
var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" };

// Act
var descriptions = GetApiDescriptions(action);
Expand Down Expand Up @@ -880,7 +880,7 @@ public void GetApiDescription_ReturnsActionResultOfSequenceOfTWithProducesConten
new ProducesResponseTypeAttribute(typeof(ErrorDetails), 500),
FilterScope.Action)
};
var expectedMediaTypes = new[] { "application/json", "text/json" };
var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" };

// Act
var descriptions = GetApiDescriptions(action);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
Expand Down Expand Up @@ -358,6 +359,78 @@ public void AddsMetadataFromRouteEndpoint()
Assert.True(apiExplorerSettings.IgnoreApi);
}

[Fact]
public void RespectsProducesProblemExtensionMethod()
{
// Arrange
var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(null));
builder.MapGet("/api/todos", () => "").ProducesProblem(StatusCodes.Status400BadRequest);
var context = new ApiDescriptionProviderContext(Array.Empty<ActionDescriptor>());

var endpointDataSource = builder.DataSources.OfType<EndpointDataSource>().Single();
var hostEnvironment = new HostEnvironment
{
ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest)
};
var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService());

// Act
provider.OnProvidersExecuting(context);

// Assert
var apiDescription = Assert.Single(context.Results);
var responseTypes = Assert.Single(apiDescription.SupportedResponseTypes);
Assert.Equal(typeof(ProblemDetails), responseTypes.Type);
}

[Fact]
public void RespectsProducesWithGroupNameExtensionMethod()
{
// Arrange
var endpointGroupName = "SomeEndpointGroupName";
var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(null));
builder.MapGet("/api/todos", () => "").Produces<InferredJsonClass>().WithGroupName(endpointGroupName);
var context = new ApiDescriptionProviderContext(Array.Empty<ActionDescriptor>());

var endpointDataSource = builder.DataSources.OfType<EndpointDataSource>().Single();
var hostEnvironment = new HostEnvironment
{
ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest)
};
var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService());

// Act
provider.OnProvidersExecuting(context);

// Assert
var apiDescription = Assert.Single(context.Results);
var responseTypes = Assert.Single(apiDescription.SupportedResponseTypes);
Assert.Equal(typeof(InferredJsonClass), responseTypes.Type);
Assert.Equal(endpointGroupName, apiDescription.GroupName);
}

[Fact]
public void RespectsExcludeFromDescription()
{
// Arrange
var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(null));
builder.MapGet("/api/todos", () => "").Produces<InferredJsonClass>().ExcludeFromDescription();
var context = new ApiDescriptionProviderContext(Array.Empty<ActionDescriptor>());

var endpointDataSource = builder.DataSources.OfType<EndpointDataSource>().Single();
var hostEnvironment = new HostEnvironment
{
ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest)
};
var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService());

// Act
provider.OnProvidersExecuting(context);

// Assert
Assert.Empty(context.Results);
}

private IList<ApiDescription> GetApiDescriptions(
Delegate action,
string pattern = null,
Expand Down Expand Up @@ -423,5 +496,22 @@ private class HostEnvironment : IHostEnvironment
public string ContentRootPath { get; set; }
public IFileProvider ContentRootFileProvider { get; set; }
}

private class TestEndpointRouteBuilder : IEndpointRouteBuilder
{
public TestEndpointRouteBuilder(IApplicationBuilder applicationBuilder)
{
ApplicationBuilder = applicationBuilder ?? throw new ArgumentNullException(nameof(applicationBuilder));
DataSources = new List<EndpointDataSource>();
}

public IApplicationBuilder ApplicationBuilder { get; }

public IApplicationBuilder CreateApplicationBuilder() => ApplicationBuilder.New();

public ICollection<EndpointDataSource> DataSources { get; }

public IServiceProvider ServiceProvider => ApplicationBuilder.ApplicationServices;
}
}
}
Loading