Skip to content
20 changes: 20 additions & 0 deletions src/Http/Http.Abstractions/src/Metadata/ApiEndpointMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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;

/// <summary>
/// Metadata that indicates the endpoint is intended for API clients.
/// When present, authentication handlers should prefer returning status codes over browser redirects.
/// </summary>
internal sealed class ApiEndpointMetadata : IApiEndpointMetadata
{
/// <summary>
/// Singleton instance of <see cref="ApiEndpointMetadata"/>.
/// </summary>
public static readonly ApiEndpointMetadata Instance = new();

private ApiEndpointMetadata()
{
}
}
12 changes: 12 additions & 0 deletions src/Http/Http.Abstractions/src/Metadata/IApiEndpointMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// 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;

/// <summary>
/// Metadata that indicates the endpoint is an API intended for programmatic access rather than direct browser navigation.
/// When present, authentication handlers should prefer returning status codes over browser redirects.
/// </summary>
public interface IApiEndpointMetadata
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ Microsoft.AspNetCore.Http.HttpResponse</Description>
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Abstractions.Microbenchmarks" />
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Extensions" />
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Results" />
</ItemGroup>
Expand All @@ -61,5 +60,6 @@ Microsoft.AspNetCore.Http.HttpResponse</Description>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Abstractions.Tests" />
<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Abstractions.Microbenchmarks" />
</ItemGroup>
</Project>
1 change: 1 addition & 0 deletions src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#nullable enable
Microsoft.AspNetCore.Http.Metadata.IApiEndpointMetadata
Microsoft.AspNetCore.Http.Metadata.IDisableValidationMetadata
Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.Description.get -> string?
Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.Description.set -> void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context)

if (hasFormBody)
{
codeWriter.WriteLine(RequestDelegateGeneratorSources.AntiforgeryMetadataType);
codeWriter.WriteLine(RequestDelegateGeneratorSources.AntiforgeryMetadataClass);
}

if (hasJsonBody || hasResponseMetadata)
{
codeWriter.WriteLine(RequestDelegateGeneratorSources.ApiEndpointMetadataClass);
}

if (hasFormBody || hasJsonBody || hasResponseMetadata)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -479,19 +479,39 @@ internal ParameterBindingMetadata(
}
""";

public static string AntiforgeryMetadataType = """
file sealed class AntiforgeryMetadata : IAntiforgeryMetadata
{
public static readonly IAntiforgeryMetadata ValidationRequired = new AntiforgeryMetadata(true);

public AntiforgeryMetadata(bool requiresValidation)
public static string AntiforgeryMetadataClass = """
file sealed class AntiforgeryMetadata : IAntiforgeryMetadata
{
RequiresValidation = requiresValidation;
public static readonly IAntiforgeryMetadata ValidationRequired = new AntiforgeryMetadata(true);

public AntiforgeryMetadata(bool requiresValidation)
{
RequiresValidation = requiresValidation;
}

public bool RequiresValidation { get; }
}
""";

public bool RequiresValidation { get; }
}
public static string ApiEndpointMetadataClass = """
file sealed class ApiEndpointMetadata : IApiEndpointMetadata
{
public static readonly ApiEndpointMetadata Instance = new();

private ApiEndpointMetadata()
{
}

public static void AddApiEndpointMetadataIfMissing(EndpointBuilder builder)
{
if (!builder.Metadata.Any(m => m is IApiEndpointMetadata))
{
builder.Metadata.Add(Instance);
}
}
}
""";

public static string GetGeneratedRouteBuilderExtensionsSource(string endpoints, string helperMethods, string helperTypes, ImmutableHashSet<string> verbs) => $$"""
{{SourceHeader}}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ private static void EmitBuiltinResponseTypeMetadata(this Endpoint endpoint, Code
return;
}

if (!endpoint.Response.IsAwaitable && (response.HasNoResponse || response.IsIResult))
if (response.HasNoResponse || response.IsIResult)
{
return;
}
Expand All @@ -215,13 +215,10 @@ private static void EmitBuiltinResponseTypeMetadata(this Endpoint endpoint, Code
{
codeWriter.WriteLine($"options.EndpointBuilder.Metadata.Add(new ProducesResponseTypeMetadata(statusCode: StatusCodes.Status200OK, type: typeof(string), contentTypes: GeneratedMetadataConstants.PlaintextContentType));");
}
else if (response.IsAwaitable && response.ResponseType == null)
{
codeWriter.WriteLine($"options.EndpointBuilder.Metadata.Add(new ProducesResponseTypeMetadata(statusCode: StatusCodes.Status200OK, type: typeof(void), contentTypes: GeneratedMetadataConstants.PlaintextContentType));");
}
else if (response.ResponseType is { } responseType)
{
codeWriter.WriteLine($$"""options.EndpointBuilder.Metadata.Add(new ProducesResponseTypeMetadata(statusCode: StatusCodes.Status200OK, type: typeof({{responseType.ToDisplayString(EmitterConstants.DisplayFormatWithoutNullability)}}), contentTypes: GeneratedMetadataConstants.JsonContentType));""");
codeWriter.WriteLine("ApiEndpointMetadata.AddApiEndpointMetadataIfMissing(options.EndpointBuilder);");
}
}

Expand Down Expand Up @@ -339,13 +336,15 @@ public static void EmitJsonAcceptsMetadata(this Endpoint endpoint, CodeWriter co
codeWriter.WriteLine("if (!serviceProviderIsService.IsService(type))");
codeWriter.StartBlock();
codeWriter.WriteLine("options.EndpointBuilder.Metadata.Add(new AcceptsMetadata(type: type, isOptional: isOptional, contentTypes: GeneratedMetadataConstants.JsonContentType));");
codeWriter.WriteLine("options.EndpointBuilder.Metadata.Add(ApiEndpointMetadata.Instance);");
codeWriter.WriteLine("break;");
codeWriter.EndBlock();
codeWriter.EndBlock();
}
else
{
codeWriter.WriteLine($"options.EndpointBuilder.Metadata.Add(new AcceptsMetadata(contentTypes: GeneratedMetadataConstants.JsonContentType));");
codeWriter.WriteLine("options.EndpointBuilder.Metadata.Add(new AcceptsMetadata(contentTypes: GeneratedMetadataConstants.JsonContentType));");
codeWriter.WriteLine("options.EndpointBuilder.Metadata.Add(ApiEndpointMetadata.Instance);");
}
}

Expand Down
28 changes: 19 additions & 9 deletions src/Http/Http.Extensions/src/RequestDelegateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,14 @@ private static Expression[] CreateArgumentsAndInferMetadata(MethodInfo methodInf
InferAntiforgeryMetadata(factoryContext);
}

PopulateBuiltInResponseTypeMetadata(methodInfo.ReturnType, factoryContext.EndpointBuilder);
// If this endpoint expects a JSON request body, we assume its an API endpoint not intended for browser navigation.
// When present, authentication handlers should prefer returning status codes over browser redirects.
if (factoryContext.JsonRequestBodyParameter is not null)
{
factoryContext.EndpointBuilder.Metadata.Add(ApiEndpointMetadata.Instance);
}

PopulateBuiltInResponseTypeMetadata(methodInfo.ReturnType, factoryContext);

// Add metadata provided by the delegate return type and parameter types next, this will be more specific than inferred metadata from above
EndpointMetadataPopulator.PopulateMetadata(methodInfo, factoryContext.EndpointBuilder, factoryContext.Parameters);
Expand Down Expand Up @@ -1023,37 +1030,40 @@ private static Expression CreateParamCheckingResponseWritingMethodCall(Type retu
return Expression.Block(localVariables, checkParamAndCallMethod);
}

private static void PopulateBuiltInResponseTypeMetadata(Type returnType, EndpointBuilder builder)
private static void PopulateBuiltInResponseTypeMetadata(Type returnType, RequestDelegateFactoryContext factoryContext)
{
if (returnType.IsByRefLike)
{
throw GetUnsupportedReturnTypeException(returnType);
}

var isAwaitable = false;
if (CoercedAwaitableInfo.IsTypeAwaitable(returnType, out var coercedAwaitableInfo))
{
returnType = coercedAwaitableInfo.AwaitableInfo.ResultType;
isAwaitable = true;
}

// Skip void returns and IResults. IResults might implement IEndpointMetadataProvider but otherwise we don't know what it might do.
if (!isAwaitable && (returnType == typeof(void) || typeof(IResult).IsAssignableFrom(returnType)))
if (returnType == typeof(void) || typeof(IResult).IsAssignableFrom(returnType))
{
return;
}

var builder = factoryContext.EndpointBuilder;

if (returnType == typeof(string))
{
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(type: typeof(string), statusCode: 200, PlaintextContentType));
}
else if (returnType == typeof(void))
{
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(returnType, statusCode: 200, PlaintextContentType));
}
else
{
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(returnType, statusCode: 200, DefaultAcceptsAndProducesContentType));

if (factoryContext.JsonRequestBodyParameter is null)
{
// Since this endpoint responds with JSON, we assume its an API endpoint not intended for browser navigation,
// but we don't want to bother adding this metadata twice if we've already inferred it based on the expected JSON request body.
builder.Metadata.Add(ApiEndpointMetadata.Instance);
}
}
}

Expand Down
27 changes: 15 additions & 12 deletions src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2533,7 +2533,7 @@ public void Create_AddJsonResponseType_AsMetadata()
var @delegate = () => new object();
var result = RequestDelegateFactory.Create(@delegate);

var responseMetadata = Assert.IsAssignableFrom<IProducesResponseTypeMetadata>(Assert.Single(result.EndpointMetadata));
var responseMetadata = Assert.Single(result.EndpointMetadata.OfType<IProducesResponseTypeMetadata>());

Assert.Equal("application/json", Assert.Single(responseMetadata.ContentTypes));
Assert.Equal(typeof(object), responseMetadata.Type);
Expand All @@ -2545,7 +2545,7 @@ public void Create_AddPlaintextResponseType_AsMetadata()
var @delegate = () => "Hello";
var result = RequestDelegateFactory.Create(@delegate);

var responseMetadata = Assert.IsAssignableFrom<IProducesResponseTypeMetadata>(Assert.Single(result.EndpointMetadata));
var responseMetadata = Assert.Single(result.EndpointMetadata.OfType<IProducesResponseTypeMetadata>());

Assert.Equal("text/plain", Assert.Single(responseMetadata.ContentTypes));
Assert.Equal(typeof(string), responseMetadata.Type);
Expand Down Expand Up @@ -2683,6 +2683,7 @@ public void Create_CombinesDefaultMetadata_AndMetadataFromReturnTypesImplementin

// Assert
Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Caller });
Assert.DoesNotContain(result.EndpointMetadata, m => m is IProducesResponseTypeMetadata);
// Expecting '1' because only initial metadata will be in the metadata list when this metadata item is added
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 1 });
}
Expand All @@ -2705,9 +2706,9 @@ public void Create_CombinesDefaultMetadata_AndMetadataFromTaskWrappedReturnTypes

// Assert
Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Caller });
Assert.Contains(result.EndpointMetadata, m => m is ProducesResponseTypeMetadata { Type: { } type } && type == typeof(CountsDefaultEndpointMetadataResult));
// Expecting the custom metadata and the implicit metadata associated with a Task-based return type to be inserted
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 2 });
Assert.DoesNotContain(result.EndpointMetadata, m => m is IProducesResponseTypeMetadata);
// Expecting '1' because only initial metadata will be in the metadata list when this metadata item is added
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 1 });
}

[Fact]
Expand All @@ -2728,9 +2729,9 @@ public void Create_CombinesDefaultMetadata_AndMetadataFromValueTaskWrappedReturn

// Assert
Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Caller });
Assert.Contains(result.EndpointMetadata, m => m is ProducesResponseTypeMetadata { Type: { } type } && type == typeof(CountsDefaultEndpointMetadataResult));
// Expecting the custom metadata nad hte implicit metadata associated with a Task-based return type to be inserted
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 2 });
Assert.DoesNotContain(result.EndpointMetadata, m => m is IProducesResponseTypeMetadata);
// Expecting '1' because only initial metadata will be in the metadata list when this metadata item is added
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 1 });
}

[Fact]
Expand All @@ -2751,9 +2752,9 @@ public void Create_CombinesDefaultMetadata_AndMetadataFromFSharpAsyncWrappedRetu

// Assert
Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Caller });
Assert.Contains(result.EndpointMetadata, m => m is IProducesResponseTypeMetadata { Type: { } type } && type == typeof(CountsDefaultEndpointMetadataResult));
Assert.DoesNotContain(result.EndpointMetadata, m => m is IProducesResponseTypeMetadata);
// Expecting '1' because only initial metadata will be in the metadata list when this metadata item is added
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 2 });
Assert.Contains(result.EndpointMetadata, m => m is MetadataCountMetadata { Count: 1 });
}

[Fact]
Expand Down Expand Up @@ -2824,14 +2825,16 @@ public void Create_CombinesAllMetadata_InCorrectOrder()
m => Assert.True(m is AcceptsMetadata am && am.RequestType == typeof(AddsCustomParameterMetadata)),
// Inferred ParameterBinding metadata
m => Assert.True(m is IParameterBindingMetadata { Name: "param1" }),
// Inferred ProducesResopnseTypeMetadata from RDF for complex type
// Inferred IApiEndpointMetadata from RDF for complex request and response type
m => Assert.True(m is IApiEndpointMetadata),
// Inferred ProducesResponseTypeMetadata from RDF for complex type
m => Assert.Equal(typeof(CountsDefaultEndpointMetadataPoco), ((IProducesResponseTypeMetadata)m).Type),
// Metadata provided by parameters implementing IEndpointParameterMetadataProvider
m => Assert.True(m is ParameterNameMetadata { Name: "param1" }),
// Metadata provided by parameters implementing IEndpointMetadataProvider
m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Parameter }),
// Metadata provided by return type implementing IEndpointMetadataProvider
m => Assert.True(m is MetadataCountMetadata { Count: 6 }));
m => Assert.True(m is MetadataCountMetadata { Count: 7 }));
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,22 @@ namespace Microsoft.AspNetCore.Http.Generated

}

file sealed class ApiEndpointMetadata : IApiEndpointMetadata
{
public static readonly ApiEndpointMetadata Instance = new();

private ApiEndpointMetadata()
{
}

public static void AddApiEndpointMetadataIfMissing(EndpointBuilder builder)
{
if (!builder.Metadata.Any(m => m is IApiEndpointMetadata))
{
builder.Metadata.Add(Instance);
}
}
}
%GENERATEDCODEATTRIBUTE%
file static class GeneratedMetadataConstants
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,22 @@ namespace Microsoft.AspNetCore.Http.Generated

}

file sealed class ApiEndpointMetadata : IApiEndpointMetadata
{
public static readonly ApiEndpointMetadata Instance = new();

private ApiEndpointMetadata()
{
}

public static void AddApiEndpointMetadataIfMissing(EndpointBuilder builder)
{
if (!builder.Metadata.Any(m => m is IApiEndpointMetadata))
{
builder.Metadata.Add(Instance);
}
}
}
%GENERATEDCODEATTRIBUTE%
file static class GeneratedMetadataConstants
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2225,6 +2225,22 @@ namespace Microsoft.AspNetCore.Http.Generated

}

file sealed class ApiEndpointMetadata : IApiEndpointMetadata
{
public static readonly ApiEndpointMetadata Instance = new();

private ApiEndpointMetadata()
{
}

public static void AddApiEndpointMetadataIfMissing(EndpointBuilder builder)
{
if (!builder.Metadata.Any(m => m is IApiEndpointMetadata))
{
builder.Metadata.Add(Instance);
}
}
}
%GENERATEDCODEATTRIBUTE%
file static class GeneratedMetadataConstants
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,22 @@ namespace Microsoft.AspNetCore.Http.Generated

}

file sealed class ApiEndpointMetadata : IApiEndpointMetadata
{
public static readonly ApiEndpointMetadata Instance = new();

private ApiEndpointMetadata()
{
}

public static void AddApiEndpointMetadataIfMissing(EndpointBuilder builder)
{
if (!builder.Metadata.Any(m => m is IApiEndpointMetadata))
{
builder.Metadata.Add(Instance);
}
}
}
%GENERATEDCODEATTRIBUTE%
file static class GeneratedMetadataConstants
{
Expand Down
Loading
Loading