Skip to content
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

Update RDG to use interceptors feature #48817

Merged
merged 7 commits into from Jul 11, 2023
Merged

Conversation

captainsafia
Copy link
Member

@captainsafia captainsafia commented Jun 14, 2023

Closes #48383
Closes #48289

@ghost ghost added the area-infrastructure Includes: MSBuild projects/targets, build scripts, CI, Installers and shared framework label Jun 14, 2023
@captainsafia captainsafia force-pushed the safia/interceptors-rdg branch 6 times, most recently from 2860bdc to d1412b1 Compare June 20, 2023 23:42
@captainsafia captainsafia force-pushed the safia/interceptors-rdg branch 8 times, most recently from c06f2e0 to 2adb5f6 Compare June 30, 2023 22:08
@captainsafia captainsafia added feature-rdg area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc and removed area-infrastructure Includes: MSBuild projects/targets, build scripts, CI, Installers and shared framework labels Jul 5, 2023
@@ -8,6 +8,7 @@
<Nullable>Enable</Nullable>
<RootNamespace>Microsoft.AspNetCore.Analyzers</RootNamespace>
<SuppressNullableAttributesImport>true</SuppressNullableAttributesImport>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
Copy link
Member Author

Choose a reason for hiding this comment

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

This change, and stylistic modifications in the analyzers, are a result of new diagnostics emitted from the updated compiler version.

using Microsoft.CodeAnalysis.Completion;

namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage;

[QuarantinedTest("https://github.com/dotnet/aspnetcore/issues/49126")]
Copy link
Member Author

Choose a reason for hiding this comment

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

A downstream change in Roslyn makes these tests unreliable. A PR with a fix is out but I am quarantining for now so that we can move forward with this change.

return RouteHandlerServices.Map(routes, pattern, handler, httpMethods, populateMetadata, createRequestDelegate);
}

private static T Cast<T>(Delegate d, T _) where T : Delegate
Copy link
Member Author

Choose a reason for hiding this comment

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

Now that we are no longer using strongly-typed delegate types in the overload we need to get a concrete type from the Delegate parameter so that we can use it in the generated code. This Cast method allows us to capture the concrete type and to capture default parameters passed to the lambda/method group (see here).

Copy link
Member

@mitchdenny mitchdenny Jul 7, 2023

Choose a reason for hiding this comment

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

I think it would be good to add a comment here as well to fully explain this since it's kind of obscure (just to help future maintainers).

{
{{GeneratedCodeAttribute}}
internal sealed class SourceKey
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
file sealed class InterceptsLocationAttribute : Attribute
Copy link
Member Author

Choose a reason for hiding this comment

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

No public API is included as part of the interceptors feature in preview so we have to define this type ourselves. We use AllowMultiple = true here so we can use the same interceptor for Map action calls that implement the same signature.

Copy link
Member Author

Choose a reason for hiding this comment

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

This DiagnosticSuppressor is the recommended workaround for dotnet/roslyn#68669. It specifically suppresses the false-positive diagnostics that are emitted by the linker analyzer when the RDG wille emit an interceptor (we produce an endpoint model with no diagnostics).

@@ -23,6 +23,7 @@ public EndpointParameter(Endpoint endpoint, IParameterSymbol parameter, WellKnow
{
Ordinal = parameter.Ordinal;
IsOptional = parameter.IsOptional();
HasDefaultValue = parameter.HasExplicitDefaultValue;
Copy link
Member Author

Choose a reason for hiding this comment

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

We add tracking for whether or not a method has a default parameter so that we can pass it in the concrete delegate type to support default parameter values in lambdas.

@@ -610,17 +613,17 @@ private static string ConvertEndOfLineAndQuotationCharactersToEscapeForm(string
other.SymbolName == SymbolName &&
other.Ordinal == Ordinal &&
other.IsOptional == IsOptional &&
SymbolEqualityComparer.Default.Equals(other.Type, Type);
SymbolEqualityComparer.IncludeNullability.Equals(other.Type, Type);
Copy link
Member Author

Choose a reason for hiding this comment

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

Prior to the interceptors feature, we couldn't generate separate overloads for endpoints that had the same parameter type but differing nullability (see #46622). Interceptors don't have the same problem so we can generate code for endpoints that use the same type but differing nullability.

@@ -167,11 +169,14 @@ internal Endpoint[] GetEndpointsFromCompilation(Compilation compilation, bool? e

foreach (var endpoint in endpoints)
{
var sourceKeyMetadata = endpoint.Metadata.FirstOrDefault(metadata => metadata.GetType() == sourceKeyType);
var sourceKeyMetadata = endpoint.Metadata.OfType<GeneratedCodeAttribute>().SingleOrDefault();
Copy link
Member Author

Choose a reason for hiding this comment

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

We emitted the SourceKey originally as a proactive measure to allow other possibly generated code (like the validation generator) to do look-ups on generated endpoints by the source location. Since we don't really need this for .NET 8 (we're not shipping other minimal API-related generators), I'm removing this type from the generated code and reusing the GeneratedCode attribute to serve as a marker for when the endpoint is statically generated.

Copy link
Member

Choose a reason for hiding this comment

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

I asked this in my review below, but I don't fully understand why we are doing this. Why do we need this marker?

Copy link
Member Author

Choose a reason for hiding this comment

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

It helps us determine at runtime if an endpoint was generated at compile-time or runtime. For example, for determining what generator version an endpoint in a library was generated with compared to the app code consuming it.

We typically populate all attributes that are on an endpoint into its metadata so this makes sense to me assuming something like this:

app.MapGet("/", [GeneratedCode] (string name) => ...);

At runtime, we can get resolve if the endpoint was generated:

endpoint.Metadata.OfType<GeneratedCodeAttribute>();

@captainsafia captainsafia marked this pull request as ready for review July 5, 2023 17:37
Copy link
Member Author

Choose a reason for hiding this comment

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

I was able to integrate validations for the DiagnosticSuppressor in our existing test infrastructure in https://github.com/dotnet/aspnetcore/tree/safia/interceptors-rdg-save. The changes work locally but it is challenging to get them to work in a Helix environment since we have to upload a version of the linker analyzer assembly onto the Helix machine along with the tests.

To spare us the MSBuild-foo, it should be sufficient to add E2E validation for the suppressor in the SDK repo. The setup in the branch above can also be used to validate the changeset locally.

eng/SourceBuildPrebuiltBaseline.xml Outdated Show resolved Hide resolved
{
Debug.Assert(options != null, "RequestDelegateFactoryOptions not found.");
Debug.Assert(options.EndpointBuilder != null, "EndpointBuilder not found.");
options.EndpointBuilder.Metadata.Add(new System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.RequestDelegateGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42"));
Copy link
Member

Choose a reason for hiding this comment

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

Is this really necessary to add to the Metadata? What's its purpose?

{
httpContext.Response.StatusCode = 400;
}

Copy link
Member

Choose a reason for hiding this comment

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

Why doesn't this just happen inline? It may make sense to throw a comment in the generated 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.

The parameter binding logic that sets the wasParamCheckFailureis shared between the non-filter and filter-handler. However, the way we react to it is different depending on whether we are executing an endpoint with filters or not.

When no filters are registered, we return from the RequestDelegate immediately. When filters are registered, we don't return because we want the filters to run, but not the request delegate. This is easier to do if the check doesn't happen inline.

Copy link
Member

@eerhardt eerhardt Jul 7, 2023

Choose a reason for hiding this comment

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

I think GitHub is messing up showing where I was referring to. When you switch to the "Files changed" tab, you can see where I was referring to:

image

Why is the Cast helper method necessary? Why can't we just cast inline without using this method?

Copy link
Member Author

Choose a reason for hiding this comment

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

Why is the Cast helper method necessary? Why can't we just cast inline without using this method?

Ah, that part is in reaction to a breaking change in the compiler. Method group references are now inferred as anonymous delegate types instead of Action/Func to match the conversion for lambdas. This is the workaround @jjonescz and I came up with given the new constraint.

Copy link
Member

Choose a reason for hiding this comment

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

Cast is more like Infer. Using generics to reference and unspeakable type (like an anonymous class).

{
%GENERATEDCODEATTRIBUTE%
internal sealed class SourceKey
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
file sealed class InterceptsLocationAttribute : Attribute
Copy link
Member

Choose a reason for hiding this comment

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

What happens if another source generator adds this same file sealed class InterceptsLocationAttribute to the project? For example - if I enable both RDG and the ConfigurationBinder source generator.

Copy link
Member Author

Choose a reason for hiding this comment

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

Since the type is file scoped, it'll be fine to have multiple definitions in each generator as each will produce a unique type definition (e.g. System.Runtime.CompilerServices.<GeneratedFileName_g>FBAC_InterceptsLocationAttribute).

Things will get interesting if generators aren't cautious about making them file private (as opposed to say internal) but we should fine here.

cc: @RikkiGibson At minimum, we probably want to document the requirement for InterceptsLocationAttribute definitions to be file private. Not sure if we can add a compiler check/analyzer as well.

namespace Microsoft.AspNetCore.Http.RequestDelegateGenerator;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class RequestDelegateGeneratorSuppressor : DiagnosticSuppressor
Copy link
Member

@mitchdenny mitchdenny Jul 7, 2023

Choose a reason for hiding this comment

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

More of a question because it's the first time I've seen these in action. Does a DiagnosticSuppressor need to be registered explicitly anywhere, or does its mere reference in the dependency graph kick it into action?

Copy link
Member

Choose a reason for hiding this comment

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

Another thought do we need to write a test here to verify that this suppressor doesn't supress things that it shouldn't?

Copy link
Member Author

Choose a reason for hiding this comment

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

More of a question because it's the first time I've seen these in action. Does a DiagnosticSuppressor need to be registered explicitly anywhere, or does its mere reference in the dependency graph kick it into action?

It doesn't need to be registered. Since it is annotated with the DiagnosticAnalyzer attribute, the compiler infrastructure will pick it up automatically.

Another thought do we need to write a test here to verify that this suppressor doesn't supress things that it shouldn't?

Copying my response from another thread:

So, I was able to get this working with the automated tests in https://github.com/dotnet/aspnetcore/blob/safia/interceptors-rdg-save/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs#L96-L100.

One of the reasons I didn't pursue this testing strategy in the actual PR is because it's a bit of a pain to do a lookup for the linker analyzer assembly and then upload it to Helix, especially because the test projects themselves don't take a dependency on the linker analyzer.

The current proposal is to add tests for this E2E behavior in the SDK, where all the components already exist, so I'll make a point of adding a test to validate the true negative scenario there.

But TL;DR, the results I saw with the test infra locally gives me confidence about diagnostics being emitted for true negatives like

public async Task BuildRequestDelegateEmitsDiagnosticForInvalidParameterListConstructor(
string parameterType,

Copy link
Member

@mitchdenny mitchdenny left a comment

Choose a reason for hiding this comment

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

This is really nice! The size of the PR doesn't do justice to the level of effort that went into this (I guess that is a sign that the interceptors stuff really simplifies things!).

Comment on lines 63 to 65
[InterceptsLocation(@"TestMapActions.cs", 25, 13)]
[InterceptsLocation(@"OtherTestMapActions.cs", 25, 13)]
internal static RouteHandlerBuilder MapGet_2513(
Copy link
Member

Choose a reason for hiding this comment

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

What happens if the calls come from different line and column numbers? Do we still merge them into 1 method?

Copy link
Member Author

Choose a reason for hiding this comment

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

If the calls have the same exact signature, then yes. I can add this to the test case.

using Microsoft.CodeAnalysis.Operations;

/*
* This class contains the logic for suppressing diagnostics that are
Copy link
Member

Choose a reason for hiding this comment

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

(nit) why isn't this comment in a /// <summary> on the class?

Copy link
Member Author

Choose a reason for hiding this comment

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

I believe that generally in the repo we use code comments for dev-focused comments and /// <summary> tags for things that should surface in user facing docs.

Even though it doesn't matter for this class since it doesn't ship in a library (and docs won't surface on the docs.microsoft.com site anyways), I kept the pattern.

Copy link
Member

@eerhardt eerhardt left a comment

Choose a reason for hiding this comment

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

Looks good. Nice work, @captainsafia!

@@ -245,7 +245,7 @@
<Analyzer_MicrosoftCodeAnalysisCSharpWorkspacesVersion>3.3.1</Analyzer_MicrosoftCodeAnalysisCSharpWorkspacesVersion>
<!-- Pin the version of the M.CA dependencies that we utilize with a cutom version property $(MicrosoftCodeAnalysisVersion_LatestVS) to avoid automatically
consuming the newest version of the packages when using the $(MicrosoftCodeAnalysisCSharpVersion) properties in source-build. -->
<MicrosoftCodeAnalysisVersion_LatestVS>4.5.0</MicrosoftCodeAnalysisVersion_LatestVS>
<MicrosoftCodeAnalysisVersion_LatestVS>4.7.0-3.23314.3</MicrosoftCodeAnalysisVersion_LatestVS>
Copy link
Member

Choose a reason for hiding this comment

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

Was this just missed in #49245?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes 😅

The other PR has already flown into the installer without any major issues so this hopefully doesn't cause any headaches....

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc feature-rdg
Projects
None yet
5 participants