Skip to content

Add initial support for parameters with AsParameters attribute #47914

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
May 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions docs/list-of-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@
| __`RDG002`__ | Unable to resolve endpoint handler |
| __`RDG003`__ | Unable to resolve parameter |
| __`RDG004`__ | Unable to resolve anonymous type |
| __`RDG005`__ | Invalid abstract type |
| __`RDG006`__ | Invalid constructor parameters |
| __`RDG007`__ | No valid constructor found |
| __`RDG008`__ | Multiple public constructors found |
| __`RDG009`__ | Invalid nested AsParameters |
| __`RDG010`__ | Unexpected nullable type |


### SignalR Source Generator (`SSG0000-SSG0110`)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ static ImmutableArray<ParameterSymbol> ResolvedParametersCore(ISymbol symbol, IS
static string ResolveRouteParameterName(ISymbol parameterSymbol, WellKnownTypes wellKnownTypes)
{
var fromRouteMetadata = wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Http_Metadata_IFromRouteMetadata);
if (!parameterSymbol.HasAttributeImplementingInterface(fromRouteMetadata, out var attributeData))
if (!parameterSymbol.TryGetAttributeImplementingInterface(fromRouteMetadata, out var attributeData))
{
return parameterSymbol.Name; // No route metadata attribute!
}
Expand Down
48 changes: 48 additions & 0 deletions src/Http/Http.Extensions/gen/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,52 @@ internal static class DiagnosticDescriptors
"Usage",
DiagnosticSeverity.Warning,
isEnabledByDefault: true);

public static DiagnosticDescriptor InvalidAsParametersAbstractType { get; } = new(
"RDG005",
new LocalizableResourceString(nameof(Resources.InvalidAsParametersAbstractType_Title), Resources.ResourceManager, typeof(Resources)),
new LocalizableResourceString(nameof(Resources.InvalidAsParametersAbstractType_Message), Resources.ResourceManager, typeof(Resources)),
"Usage",
DiagnosticSeverity.Error,
isEnabledByDefault: true);

public static DiagnosticDescriptor InvalidAsParametersSignature { get; } = new(
"RDG006",
new LocalizableResourceString(nameof(Resources.InvalidAsParametersSignature_Title), Resources.ResourceManager, typeof(Resources)),
new LocalizableResourceString(nameof(Resources.InvalidAsParametersSignature_Message), Resources.ResourceManager, typeof(Resources)),
"Usage",
DiagnosticSeverity.Error,
isEnabledByDefault: true);

public static DiagnosticDescriptor InvalidAsParametersNoConstructorFound { get; } = new(
"RDG007",
new LocalizableResourceString(nameof(Resources.InvalidAsParametersNoConstructorFound_Title), Resources.ResourceManager, typeof(Resources)),
new LocalizableResourceString(nameof(Resources.InvalidAsParametersNoConstructorFound_Message), Resources.ResourceManager, typeof(Resources)),
"Usage",
DiagnosticSeverity.Error,
isEnabledByDefault: true);

public static DiagnosticDescriptor InvalidAsParametersSingleConstructorOnly { get; } = new(
"RDG008",
new LocalizableResourceString(nameof(Resources.InvalidAsParametersSingleConstructorOnly_Title), Resources.ResourceManager, typeof(Resources)),
new LocalizableResourceString(nameof(Resources.InvalidAsParametersSingleConstructorOnly_Message), Resources.ResourceManager, typeof(Resources)),
"Usage",
DiagnosticSeverity.Error,
isEnabledByDefault: true);

public static DiagnosticDescriptor InvalidAsParametersNested { get; } = new(
"RDG009",
new LocalizableResourceString(nameof(Resources.InvalidAsParametersNested_Title), Resources.ResourceManager, typeof(Resources)),
new LocalizableResourceString(nameof(Resources.InvalidAsParametersNested_Message), Resources.ResourceManager, typeof(Resources)),
"Usage",
DiagnosticSeverity.Error,
isEnabledByDefault: true);

public static DiagnosticDescriptor InvalidAsParametersNullable { get; } = new(
"RDG010",
new LocalizableResourceString(nameof(Resources.InvalidAsParametersNullable_Title), Resources.ResourceManager, typeof(Resources)),
new LocalizableResourceString(nameof(Resources.InvalidAsParametersNullable_Message), Resources.ResourceManager, typeof(Resources)),
"Usage",
DiagnosticSeverity.Error,
isEnabledByDefault: true);
}
10 changes: 9 additions & 1 deletion src/Http/Http.Extensions/gen/RequestDelegateGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
codeWriter.EndBlockWithComma();
codeWriter.WriteLine("(del, options, inferredMetadataResult) =>");
codeWriter.StartBlock();
codeWriter.WriteLine(@"Debug.Assert(options != null, ""RequestDelegateFactoryOptions not found."");");
Copy link
Member

Choose a reason for hiding this comment

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

I'm not super excited about this. Do we Assert in other generated code already?

It feels like we are doing something wrong. Can the user actually get into this situation? And if they can, do they know what to do to get out of it?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ha! @mitchdenny and I had a similar conversation about this yesterday.

Do we Assert in other generated code already?

We introduced this assertion here in b138bde. I'm not aware of the use Debug.Assert in other generated code (like the runtime's generators) but I'll attempt to justify why I think this is appropriate here.

Can the user actually get into this situation?

Nope, not if they are using RDG.

The nullable parameters in the API signatures of MetadataPopulator and RequestDelegateFactoryFunc exists primarily because the RequestDelegateFactory has public APIs that allow uses to construct delegates and populate metadata standalone without going through our endpoint resolution logic (see this and this. Internally, these APIs use fallbacks if null values are provided for the nullable parameters, as seen here.

With the generated code, the more specific Map overloads can only be called during endpoint resolution when RequestDelegateFactoryOptions are guaranteed to be set for route handlers (ref, ref).

If there's every a scenario where null values are passed to the generated code, that means we did something wrong in our framework. There's not really anything the user can do to address that issue. IMO, the Debug.Assert here reflects that this is bookkeeping we're responsible for as framework authors, not a nullability requirement that the end-user violated.

Copy link
Member

Choose a reason for hiding this comment

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

Would it make sense to just disable nullability checking in these files?

Copy link
Member Author

Choose a reason for hiding this comment

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

Would it make sense to just disable nullability checking in these files?

The nullability checks provide value when executing the requiredness checks that we have built in as part of parameter binding. Minimal APIs has a feature where in if you don't annotate a type as nullable, code-gen some logic that validates that it is provided (kind of like the Required attribute in System.ComponentModel.DataAnnotations). For example, the code below will bind the age value from the route parameter but check that the route parameter has been provided before invoking the users handler.

app.MapGet("/birthday/{age}", (int age) => ...)

Being able to leverage nullable flow analysis in generated code to ensure that we've done the appropriate requiredness checks is more valuable than not having it for the sake of avoiding two assertions, IMO.

Copy link
Member

Choose a reason for hiding this comment

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

I would just ! away the nullability checks on this one line:

-var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices;
+var serviceProvider = options!.ServiceProvider ?? options!.EndpointBuilder!.ApplicationServices;

If the user built for Release and somehow got into a null situation here, the behavior is the same. If they are in Debug and get asserts in here, they have no idea what to do either. Either way, it being a bug in our code.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep. My preference towards using Debug.Assert vs. the null forgiving operator is that Debug.Assert is that we fail upfront in our debug builds if we're in an invalid state instead of waiting until the options are used. It makes more sense to be to alert about this inconsistency earlier.

Also, for users reading the generated code when in debug mode, I think the Debug.Assert communicates more clearly what is going on here than the null-forgiving operator (which personally gives me the ick when I see it in code that I don't own and don't understand why the developer made that guarantee).

codeWriter.WriteLine(@"Debug.Assert(options.EndpointBuilder != null, ""EndpointBuilder not found."");");
codeWriter.WriteLine($"var handler = ({endpoint!.EmitHandlerDelegateType(considerOptionality: true)})del;");
codeWriter.WriteLine("EndpointFilterDelegate? filteredInvocation = null;");
if (endpoint!.EmitterContext.RequiresLoggingHelper || endpoint!.EmitterContext.HasJsonBodyOrService || endpoint!.Response?.IsSerializableJsonResponse(out var _) is true)
{
codeWriter.WriteLine("var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices;");
codeWriter.WriteLine("var serviceProvider = options.ServiceProvider ?? options.EndpointBuilder.ApplicationServices;");
}
endpoint!.EmitLoggingPreamble(codeWriter);
endpoint!.EmitRouteOrQueryResolver(codeWriter);
Expand Down Expand Up @@ -235,6 +237,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
var hasFormBody = endpoints.Any(endpoint => endpoint!.EmitterContext.HasFormBody);
var hasJsonBody = endpoints.Any(endpoint => endpoint!.EmitterContext.HasJsonBody || endpoint!.EmitterContext.HasJsonBodyOrService);
var hasResponseMetadata = endpoints.Any(endpoint => endpoint!.EmitterContext.HasResponseMetadata);
var requiresPropertyAsParameterInfo = endpoints.Any(endpoint => endpoint!.EmitterContext.RequiresPropertyAsParameterInfo);

using var stringWriter = new StringWriter(CultureInfo.InvariantCulture);
using var codeWriter = new CodeWriter(stringWriter, baseIndent: 0);
Expand All @@ -254,6 +257,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
codeWriter.WriteLine(RequestDelegateGeneratorSources.ContentTypeConstantsType);
}

if (requiresPropertyAsParameterInfo)
{
codeWriter.WriteLine(RequestDelegateGeneratorSources.PropertyAsParameterInfoClass);
}

return stringWriter.ToString();
});

Expand Down
98 changes: 96 additions & 2 deletions src/Http/Http.Extensions/gen/RequestDelegateGeneratorSources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ private static Task WriteToResponseAsync<T>(T? value, HttpContext httpContext, J
""";

public static string LogOrThrowExceptionHelperClass => $$"""
file class LogOrThrowExceptionHelper
file sealed class LogOrThrowExceptionHelper
{
private readonly ILogger? _rdgLogger;
private readonly bool _shouldThrow;
Expand Down Expand Up @@ -397,13 +397,107 @@ public void InvalidFormRequestBody(string parameterTypeName, string parameterNam
}
""";

public static string PropertyAsParameterInfoClass = """
file sealed class PropertyAsParameterInfo : ParameterInfo
{
private readonly PropertyInfo _underlyingProperty;
private readonly ParameterInfo? _constructionParameterInfo;

public PropertyAsParameterInfo(bool isOptional, PropertyInfo propertyInfo)
{
Debug.Assert(propertyInfo != null, "PropertyInfo must be provided.");

AttrsImpl = (ParameterAttributes)propertyInfo.Attributes;
NameImpl = propertyInfo.Name;
MemberImpl = propertyInfo;
ClassImpl = propertyInfo.PropertyType;

// It is not a real parameter in the delegate, so,
// not defining a real position.
PositionImpl = -1;

_underlyingProperty = propertyInfo;
IsOptional = isOptional;
}

public PropertyAsParameterInfo(bool isOptional, PropertyInfo property, ParameterInfo? parameterInfo)
: this(isOptional, property)
{
_constructionParameterInfo = parameterInfo;
}

public override bool HasDefaultValue
=> _constructionParameterInfo is not null && _constructionParameterInfo.HasDefaultValue;
public override object? DefaultValue
=> _constructionParameterInfo?.DefaultValue;
public override int MetadataToken => _underlyingProperty.MetadataToken;
public override object? RawDefaultValue
=> _constructionParameterInfo?.RawDefaultValue;

public override object[] GetCustomAttributes(Type attributeType, bool inherit)
{
var attributes = _constructionParameterInfo?.GetCustomAttributes(attributeType, inherit);

if (attributes == null || attributes is { Length: 0 })
{
attributes = _underlyingProperty.GetCustomAttributes(attributeType, inherit);
}

return attributes;
}

public override object[] GetCustomAttributes(bool inherit)
{
var constructorAttributes = _constructionParameterInfo?.GetCustomAttributes(inherit);

if (constructorAttributes == null || constructorAttributes is { Length: 0 })
{
return _underlyingProperty.GetCustomAttributes(inherit);
}

var propertyAttributes = _underlyingProperty.GetCustomAttributes(inherit);

// Since the constructors attributes should take priority we will add them first,
// as we usually call it as First() or FirstOrDefault() in the argument creation
var mergedAttributes = new object[constructorAttributes.Length + propertyAttributes.Length];
Array.Copy(constructorAttributes, mergedAttributes, constructorAttributes.Length);
Array.Copy(propertyAttributes, 0, mergedAttributes, constructorAttributes.Length, propertyAttributes.Length);

return mergedAttributes;
}

public override IList<CustomAttributeData> GetCustomAttributesData()
{
var attributes = new List<CustomAttributeData>(
_constructionParameterInfo?.GetCustomAttributesData() ?? Array.Empty<CustomAttributeData>());
attributes.AddRange(_underlyingProperty.GetCustomAttributesData());

return attributes.AsReadOnly();
}

public override Type[] GetOptionalCustomModifiers()
=> _underlyingProperty.GetOptionalCustomModifiers();

public override Type[] GetRequiredCustomModifiers()
=> _underlyingProperty.GetRequiredCustomModifiers();

public override bool IsDefined(Type attributeType, bool inherit)
{
return (_constructionParameterInfo is not null && _constructionParameterInfo.IsDefined(attributeType, inherit)) ||
_underlyingProperty.IsDefined(attributeType, inherit);
}

public new bool IsOptional { get; }
}
""";

public static string GetGeneratedRouteBuilderExtensionsSource(string genericThunks, string thunks, string endpoints, string helperMethods, string helperTypes) => $$"""
{{SourceHeader}}

namespace Microsoft.AspNetCore.Builder
{
{{GeneratedCodeAttribute}}
internal class SourceKey
internal sealed class SourceKey
{
public string Path { get; init; }
public int Line { get; init; }
Expand Down
90 changes: 63 additions & 27 deletions src/Http/Http.Extensions/gen/Resources.resx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema

Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes

The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.

Example:

... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
Expand All @@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple

There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the

Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not

The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can

Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.

mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.

mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.

mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
Expand Down Expand Up @@ -141,4 +141,40 @@
<data name="UnableToResolveAnonymousReturnType_Title" xml:space="preserve">
<value>Unable to resolve anonymous type</value>
</data>
<data name="InvalidAsParametersAbstractType_Title" xml:space="preserve">
<value>Invalid abstract type</value>
</data>
<data name="InvalidAsParametersAbstractType_Message" xml:space="preserve">
<value>The abstract type '{0}' is not supported.</value>
</data>
<data name="InvalidAsParametersSignature_Title" xml:space="preserve">
<value>Invalid constructor parameters</value>
</data>
<data name="InvalidAsParametersSignature_Message" xml:space="preserve">
<value>The public parameterized constructor must contain only parameters that match the declared public properties for type '{0}'.</value>
</data>
<data name="InvalidAsParametersNoConstructorFound_Title" xml:space="preserve">
<value>No valid constructor found</value>
</data>
<data name="InvalidAsParametersNoConstructorFound_Message" xml:space="preserve">
<value>No public parameterless constructor found for type '{0}'.</value>
</data>
<data name="InvalidAsParametersSingleConstructorOnly_Title" xml:space="preserve">
<value>Multiple public constructors found</value>
</data>
<data name="InvalidAsParametersSingleConstructorOnly_Message" xml:space="preserve">
<value>Only a single public parameterized constructor is allowed for type '{0}'.</value>
</data>
<data name="InvalidAsParametersNested_Title" xml:space="preserve">
<value>Invalid nested AsParameters</value>
</data>
<data name="InvalidAsParametersNested_Message" xml:space="preserve">
<value>Nested AsParametersAttribute is not supported and should be used only for handler parameters.</value>
</data>
<data name="InvalidAsParametersNullable_Title" xml:space="preserve">
<value>Unexpected nullable type</value>
</data>
<data name="InvalidAsParametersNullable_Message" xml:space="preserve">
<value>The nullable type '{0}' is not supported. Mark the parameter as non-nullable.</value>
</data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal sealed class EmitterContext
public bool HasBindAsync { get; set; }
public bool HasParsable { get; set; }
public bool HasJsonResponse { get; set; }
public bool RequiresPropertyAsParameterInfo { get; set; }
public bool RequiresLoggingHelper { get; set; }
public bool HasEndpointMetadataProvider { get; set; }
public bool HasEndpointParameterMetadataProvider { get; set; }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +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;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Analyzers.Infrastructure;
using Microsoft.CodeAnalysis;
Expand All @@ -12,6 +13,7 @@ internal static class EmitterExtensions
{
EndpointParameterSource.Header => "header",
EndpointParameterSource.Query => "query string",
EndpointParameterSource.Route => "route",
EndpointParameterSource.RouteOrQuery => "route or query string",
EndpointParameterSource.FormBody => "form",
EndpointParameterSource.BindAsync => endpointParameter.BindMethod == BindabilityMethod.BindAsync
Expand All @@ -30,4 +32,13 @@ public static bool IsSerializableJsonResponse(this EndpointResponse endpointResp
}
return false;
}

public static string EmitHandlerArgument(this EndpointParameter endpointParameter) => $"{endpointParameter.SymbolName}_local";

public static string EmitArgument(this EndpointParameter endpointParameter) => endpointParameter.Source switch
{
EndpointParameterSource.JsonBody or EndpointParameterSource.Route or EndpointParameterSource.RouteOrQuery or EndpointParameterSource.JsonBodyOrService or EndpointParameterSource.FormBody => endpointParameter.IsOptional ? endpointParameter.EmitHandlerArgument() : $"{endpointParameter.EmitHandlerArgument()}!",
EndpointParameterSource.Unknown => throw new NotImplementedException("Unreachable!"),
_ => endpointParameter.EmitHandlerArgument()
};
}
Loading