From 2d909a7ae5019ae2794defe3761187abde86d103 Mon Sep 17 00:00:00 2001 From: Martin Strecker <103252490+martin-strecker-sonarsource@users.noreply.github.com> Date: Fri, 22 Mar 2024 14:49:41 +0100 Subject: [PATCH] New rule S6934: You should specify the RouteAttribute when an HttpMethodAttribute is specified at an action level (#8954) Co-authored-by: Costin Zaharia --- analyzers/rspec/cs/S6934.html | 108 ++++++++++ analyzers/rspec/cs/S6934.json | 23 +++ analyzers/rspec/cs/Sonar_way_profile.json | 3 +- .../Rules/SpecifyRouteAttribute.cs | 90 ++++++++ .../Extensions/AttributeDataExtensions.cs | 6 + .../SonarAnalyzer.Common/Helpers/KnownType.cs | 7 + ...outeTemplateShouldNotStartWithSlashBase.cs | 13 +- .../PackagingTests/RuleTypeMappingCS.cs | 2 +- .../Rules/SpecifyRouteAttributeTest.cs | 86 ++++++++ .../SpecifyRouteAttribute.CSharp12.cs | 192 ++++++++++++++++++ 10 files changed, 523 insertions(+), 7 deletions(-) create mode 100644 analyzers/rspec/cs/S6934.html create mode 100644 analyzers/rspec/cs/S6934.json create mode 100644 analyzers/src/SonarAnalyzer.CSharp/Rules/SpecifyRouteAttribute.cs create mode 100644 analyzers/tests/SonarAnalyzer.Test/Rules/SpecifyRouteAttributeTest.cs create mode 100644 analyzers/tests/SonarAnalyzer.Test/TestCases/SpecifyRouteAttribute.CSharp12.cs diff --git a/analyzers/rspec/cs/S6934.html b/analyzers/rspec/cs/S6934.html new file mode 100644 index 00000000000..6df28d9a091 --- /dev/null +++ b/analyzers/rspec/cs/S6934.html @@ -0,0 +1,108 @@ +

When a route template is defined through an attribute on an action method, conventional routing for that action is disabled. To maintain good +practice, it’s recommended not to combine conventional and attribute-based routing within a single controller to avoid unpredicted behavior. As such, +the controller should exclude itself from conventional routing by applying a [Route] attribute.

+

Why is this an issue?

+

In ASP.NET Core MVC, the routing middleware utilizes a series of rules and conventions to +identify the appropriate controller and action method to handle a specific HTTP request. This process, known as conventional routing, is +generally established using the MapControllerRoute +method. This method is typically configured in one central location for all controllers during the application setup.

+

Conversely, attribute routing allows routes to be defined at the controller or action method level. It is possible to mix both +mechanisms. Although it’s permissible to employ diverse routing strategies across multiple controllers, combining both mechanisms within one +controller can result in confusion and increased complexity, as illustrated below.

+
+// Conventional mapping definition
+app.MapControllerRoute(
+    name: "default",
+    pattern: "{controller=Home}/{action=Index}/{id?}");
+
+public class PersonController
+{
+        // Conventional routing:
+        // Matches e.g. /Person/Index/123
+        public IActionResult Index(int? id) => View();
+
+        // Attribute routing:
+        // Matches e.g. /Age/Ascending (and model binds "Age" to sortBy and "Ascending" to direction)
+        // but does not match /Person/List/Age/Ascending
+        [HttpGet(template: "{sortBy}/{direction}")]
+        public IActionResult List(string sortBy, SortOrder direction) => View();
+}
+
+

How to fix it in ASP.NET Core

+

When any of the controller actions are annotated with a HttpMethodAttribute with a +route template defined, you should specify a route template on the controller with the RouteAttribute as well.

+

Code examples

+

Noncompliant code example

+
+public class PersonController: Controller
+{
+    // Matches /Person/Index/123
+    public IActionResult Index(int? id) => View();
+
+    // Matches /Age/Ascending
+    [HttpGet(template: "{sortBy}/{direction}")] // Noncompliant: The "Index" and the "List" actions are
+                                                // reachable via different routing mechanisms and routes
+    public IActionResult List(string sortBy, SortOrder direction) => View();
+}
+
+

Compliant solution

+
+[Route("[controller]/{action=Index}")]
+public class PersonController: Controller
+{
+    // Matches /Person/Index/123
+    [Route("{id?}")]
+    public IActionResult Index(int? id) => View();
+
+    // Matches Person/List/Age/Ascending
+    [HttpGet("{sortBy}/{direction}")] // Compliant: The route is relative to the controller
+    public IActionResult List(string sortBy, SortOrder direction) => View();
+}
+
+

There are also alternative options to prevent the mixing of conventional and attribute-based routing:

+
+// Option 1. Replace the attribute-based routing with a conventional route
+app.MapControllerRoute(
+    name: "Lists",
+    pattern: "{controller}/List/{sortBy}/{direction}",
+    defaults: new { action = "List" } ); // Matches Person/List/Age/Ascending
+
+// Option 2. Use a binding, that does not depend on route templates
+public class PersonController: Controller
+{
+    // Matches Person/List?sortBy=Age&direction=Ascending
+    [HttpGet] // Compliant: Parameters are bound from the query string
+    public IActionResult List(string sortBy, SortOrder direction) => View();
+}
+
+// Option 3. Use an absolute route
+public class PersonController: Controller
+{
+    // Matches Person/List/Age/Ascending
+    [HttpGet("/[controller]/[action]/{sortBy}/{direction}")] // Illustrate the expected route by starting with "/"
+    public IActionResult List(string sortBy, SortOrder direction) => View();
+}
+
+

Resources

+

Documentation

+ +

Articles & blog posts

+ + diff --git a/analyzers/rspec/cs/S6934.json b/analyzers/rspec/cs/S6934.json new file mode 100644 index 00000000000..420fb001764 --- /dev/null +++ b/analyzers/rspec/cs/S6934.json @@ -0,0 +1,23 @@ +{ + "title": "A Route attribute should be added to the controller when a route template is specified at the action level", + "type": "CODE_SMELL", + "status": "ready", + "remediation": { + "func": "Constant\/Issue", + "constantCost": "5min" + }, + "tags": [ + "asp.net" + ], + "defaultSeverity": "Major", + "ruleSpecification": "RSPEC-6934", + "sqKey": "S6934", + "scope": "Main", + "quickfix": "partial", + "code": { + "impacts": { + "MAINTAINABILITY": "HIGH" + }, + "attribute": "CLEAR" + } +} diff --git a/analyzers/rspec/cs/Sonar_way_profile.json b/analyzers/rspec/cs/Sonar_way_profile.json index ba82ac1eced..3b3bc4d2da9 100644 --- a/analyzers/rspec/cs/Sonar_way_profile.json +++ b/analyzers/rspec/cs/Sonar_way_profile.json @@ -315,6 +315,7 @@ "S6800", "S6803", "S6930", - "S6931" + "S6931", + "S6934" ] } diff --git a/analyzers/src/SonarAnalyzer.CSharp/Rules/SpecifyRouteAttribute.cs b/analyzers/src/SonarAnalyzer.CSharp/Rules/SpecifyRouteAttribute.cs new file mode 100644 index 00000000000..869095b1e3d --- /dev/null +++ b/analyzers/src/SonarAnalyzer.CSharp/Rules/SpecifyRouteAttribute.cs @@ -0,0 +1,90 @@ +/* + * SonarAnalyzer for .NET + * Copyright (C) 2015-2024 SonarSource SA + * mailto: contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +using System.Collections.Concurrent; + +namespace SonarAnalyzer.Rules.CSharp; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class SpecifyRouteAttribute() : SonarDiagnosticAnalyzer(DiagnosticId) +{ + private const string DiagnosticId = "S6934"; + + private static readonly ImmutableArray RouteTemplateAttributes = ImmutableArray.Create( + KnownType.Microsoft_AspNetCore_Mvc_Routing_HttpMethodAttribute, + KnownType.Microsoft_AspNetCore_Mvc_RouteAttribute); + + protected override string MessageFormat => "Specify the RouteAttribute when an HttpMethodAttribute or RouteAttribute is specified at an action level."; + protected override ILanguageFacade Language => CSharpFacade.Instance; + + protected override void Initialize(SonarAnalysisContext context) => + context.RegisterCompilationStartAction(compilationStart => + { + if (!UsesAttributeRouting(compilationStart.Compilation)) + { + return; + } + compilationStart.RegisterSymbolStartAction(symbolStart => + { + if (symbolStart.Symbol.GetAttributes().Any(x => x.AttributeClass.Is(KnownType.Microsoft_AspNetCore_Mvc_RouteAttribute))) + { + return; + } + var secondaryLocations = new ConcurrentStack(); + symbolStart.RegisterSyntaxNodeAction(nodeContext => + { + var methodDeclaration = (MethodDeclarationSyntax)nodeContext.Node; + if (nodeContext.SemanticModel.GetDeclaredSymbol(methodDeclaration, nodeContext.Cancel) is { } method + && method.IsControllerMethod() + && method.GetAttributes().Any(x => !CanBeIgnored(x.GetAttributeRouteTemplate(RouteTemplateAttributes)))) + { + secondaryLocations.Push(methodDeclaration.Identifier.GetLocation()); + } + }, SyntaxKind.MethodDeclaration); + symbolStart.RegisterSymbolEndAction(symbolEnd => ReportIssues(symbolEnd, symbolStart.Symbol, secondaryLocations)); + }, SymbolKind.NamedType); + }); + + private void ReportIssues(SonarSymbolReportingContext context, ISymbol symbol, ConcurrentStack secondaryLocations) + { + if (secondaryLocations.IsEmpty) + { + return; + } + + foreach (var declaration in symbol.DeclaringSyntaxReferences.Select(x => x.GetSyntax())) + { + if (declaration.GetIdentifier() is { } identifier) + { + context.ReportIssue(CSharpGeneratedCodeRecognizer.Instance, Diagnostic.Create(Rule, identifier.GetLocation(), secondaryLocations)); + } + } + } + + private static bool UsesAttributeRouting(Compilation compilation) => + compilation.GetTypeByMetadataName(KnownType.Microsoft_AspNetCore_Mvc_Routing_HttpMethodAttribute) is not null; + + private static bool CanBeIgnored(string template) => + string.IsNullOrEmpty(template) + // See: https://learn.microsoft.com/en-us/aspnet/core/mvc/controllers/routing#combining-attribute-routes + // Route templates applied to an action that begin with / or ~/ don't get combined with route templates applied to the controller. + || template.StartsWith("/") + || template.StartsWith("~/"); +} diff --git a/analyzers/src/SonarAnalyzer.Common/Extensions/AttributeDataExtensions.cs b/analyzers/src/SonarAnalyzer.Common/Extensions/AttributeDataExtensions.cs index 3ed347b455d..eaea75c7f1f 100644 --- a/analyzers/src/SonarAnalyzer.Common/Extensions/AttributeDataExtensions.cs +++ b/analyzers/src/SonarAnalyzer.Common/Extensions/AttributeDataExtensions.cs @@ -28,6 +28,12 @@ public static bool HasName(this AttributeData attribute, string name) => public static bool HasAnyName(this AttributeData attribute, params string[] names) => names.Any(x => attribute.HasName(x)); + public static string GetAttributeRouteTemplate(this AttributeData attribute, ImmutableArray attributeTypes) => + attribute.AttributeClass.DerivesFromAny(attributeTypes) + && attribute.TryGetAttributeValue("template", out var template) + ? template + : null; + public static bool TryGetAttributeValue(this AttributeData attribute, string valueName, out T value) { // named arguments take precedence over constructor arguments of the same name. For [Attr(valueName: false, valueName = true)] "true" is returned. diff --git a/analyzers/src/SonarAnalyzer.Common/Helpers/KnownType.cs b/analyzers/src/SonarAnalyzer.Common/Helpers/KnownType.cs index 7b85e0fd5b9..abe3cd52f53 100644 --- a/analyzers/src/SonarAnalyzer.Common/Helpers/KnownType.cs +++ b/analyzers/src/SonarAnalyzer.Common/Helpers/KnownType.cs @@ -72,6 +72,13 @@ public sealed partial class KnownType public static readonly KnownType Microsoft_AspNetCore_Mvc_ControllerAttribute = new("Microsoft.AspNetCore.Mvc.ControllerAttribute"); public static readonly KnownType Microsoft_AspNetCore_Mvc_DisableRequestSizeLimitAttribute = new("Microsoft.AspNetCore.Mvc.DisableRequestSizeLimitAttribute"); public static readonly KnownType Microsoft_AspNetCore_Mvc_FromServicesAttribute = new("Microsoft.AspNetCore.Mvc.FromServicesAttribute"); + public static readonly KnownType Microsoft_AspNetCore_Mvc_HttpDeleteAttribute = new("Microsoft.AspNetCore.Mvc.HttpDeleteAttribute"); + public static readonly KnownType Microsoft_AspNetCore_Mvc_HttpGetAttribute = new("Microsoft.AspNetCore.Mvc.HttpGetAttribute"); + public static readonly KnownType Microsoft_AspNetCore_Mvc_HttpHeadAttribute = new("Microsoft.AspNetCore.Mvc.HttpHeadAttribute"); + public static readonly KnownType Microsoft_AspNetCore_Mvc_HttpOptionsAttribute = new("Microsoft.AspNetCore.Mvc.HttpOptionsAttribute"); + public static readonly KnownType Microsoft_AspNetCore_Mvc_HttpPatchAttribute = new("Microsoft.AspNetCore.Mvc.HttpPatchAttribute"); + public static readonly KnownType Microsoft_AspNetCore_Mvc_HttpPostAttribute = new("Microsoft.AspNetCore.Mvc.HttpPostAttribute"); + public static readonly KnownType Microsoft_AspNetCore_Mvc_HttpPutAttribute = new("Microsoft.AspNetCore.Mvc.HttpPutAttribute"); public static readonly KnownType Microsoft_AspNetCore_Mvc_IActionResult = new("Microsoft.AspNetCore.Mvc.IActionResult"); public static readonly KnownType Microsoft_AspNetCore_Mvc_IgnoreAntiforgeryTokenAttribute = new("Microsoft.AspNetCore.Mvc.IgnoreAntiforgeryTokenAttribute"); public static readonly KnownType Microsoft_AspNetCore_Mvc_NonActionAttribute = new("Microsoft.AspNetCore.Mvc.NonActionAttribute"); diff --git a/analyzers/src/SonarAnalyzer.Common/Rules/RouteTemplateShouldNotStartWithSlashBase.cs b/analyzers/src/SonarAnalyzer.Common/Rules/RouteTemplateShouldNotStartWithSlashBase.cs index 1a46a6f66f3..431bbfef745 100644 --- a/analyzers/src/SonarAnalyzer.Common/Rules/RouteTemplateShouldNotStartWithSlashBase.cs +++ b/analyzers/src/SonarAnalyzer.Common/Rules/RouteTemplateShouldNotStartWithSlashBase.cs @@ -22,15 +22,19 @@ namespace SonarAnalyzer.Rules; -public abstract class RouteTemplateShouldNotStartWithSlashBase : SonarDiagnosticAnalyzer +public abstract class RouteTemplateShouldNotStartWithSlashBase() : SonarDiagnosticAnalyzer(DiagnosticId) where TSyntaxKind : struct { private const string DiagnosticId = "S6931"; private const string MessageOnlyActions = "Change the paths of the actions of this controller to be relative and adapt the controller route accordingly."; private const string MessageActionsAndController = "Change the paths of the actions of this controller to be relative and add a controller route with the common prefix."; + private static readonly ImmutableArray RouteTemplateAttributes = ImmutableArray.Create( + KnownType.Microsoft_AspNetCore_Mvc_Routing_HttpMethodAttribute, + KnownType.Microsoft_AspNetCore_Mvc_RouteAttribute, + KnownType.System_Web_Mvc_RouteAttribute); + protected override string MessageFormat => "{0}"; - protected RouteTemplateShouldNotStartWithSlashBase() : base(DiagnosticId) { } protected override void Initialize(SonarAnalysisContext context) => context.RegisterCompilationStartAction(compilationStartContext => @@ -91,10 +95,9 @@ SyntaxNode ControllerDeclarationSyntax(INamedTypeSymbol symbol) => private static Dictionary RouteAttributeTemplateArguments(ImmutableArray attributes) { var templates = new Dictionary(); - var routeAttributes = attributes.Where(x => x.AttributeClass.IsAny(KnownType.RouteAttributes) || x.AttributeClass.DerivesFrom(KnownType.Microsoft_AspNetCore_Mvc_Routing_HttpMethodAttribute)); - foreach (var attribute in routeAttributes) + foreach (var attribute in attributes) { - if (attribute.TryGetAttributeValue("template", out var templateParameter)) + if (attribute.GetAttributeRouteTemplate(RouteTemplateAttributes) is { } templateParameter) { templates.Add(attribute.ApplicationSyntaxReference.GetSyntax().GetLocation(), templateParameter); } diff --git a/analyzers/tests/SonarAnalyzer.Test/PackagingTests/RuleTypeMappingCS.cs b/analyzers/tests/SonarAnalyzer.Test/PackagingTests/RuleTypeMappingCS.cs index 2b5b2e3ec5a..0238592b135 100644 --- a/analyzers/tests/SonarAnalyzer.Test/PackagingTests/RuleTypeMappingCS.cs +++ b/analyzers/tests/SonarAnalyzer.Test/PackagingTests/RuleTypeMappingCS.cs @@ -6858,7 +6858,7 @@ internal static class RuleTypeMappingCS ["S6931"] = "CODE_SMELL", // ["S6932"], // ["S6933"], - // ["S6934"], + ["S6934"] = "CODE_SMELL", // ["S6935"], // ["S6936"], // ["S6937"], diff --git a/analyzers/tests/SonarAnalyzer.Test/Rules/SpecifyRouteAttributeTest.cs b/analyzers/tests/SonarAnalyzer.Test/Rules/SpecifyRouteAttributeTest.cs new file mode 100644 index 00000000000..546051f66c7 --- /dev/null +++ b/analyzers/tests/SonarAnalyzer.Test/Rules/SpecifyRouteAttributeTest.cs @@ -0,0 +1,86 @@ +/* + * SonarAnalyzer for .NET + * Copyright (C) 2015-2024 SonarSource SA + * mailto: contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +#if NET + +using SonarAnalyzer.Rules.CSharp; + +namespace SonarAnalyzer.Test.Rules; + +[TestClass] +public class SpecifyRouteAttributeTest +{ + private readonly VerifierBuilder builder = new VerifierBuilder() + .WithOptions(ParseOptionsHelper.FromCSharp12) + .AddReferences([ + AspNetCoreMetadataReference.MicrosoftAspNetCoreMvcCore, + AspNetCoreMetadataReference.MicrosoftAspNetCoreMvcViewFeatures, + AspNetCoreMetadataReference.MicrosoftAspNetCoreMvcAbstractions + ]); + + [TestMethod] + public void SpecifyRouteAttribute_CSharp12() => + builder.AddPaths("SpecifyRouteAttribute.CSharp12.cs").Verify(); + + [TestMethod] + public void SpecifyRouteAttribute_PartialClasses_CSharp12() => + builder + .AddSnippet(""" + using Microsoft.AspNetCore.Mvc; + + public partial class HomeController : Controller // Noncompliant [first] + { + [HttpGet("Test")] + public IActionResult Index() => View(); // Secondary [first, second] + } + """) + .AddSnippet(""" + using Microsoft.AspNetCore.Mvc; + + public partial class HomeController : Controller { } // Noncompliant [second] + """) + .Verify(); + + [TestMethod] + public void SpecifyRouteAttribute_PartialClasses_OneGenerated_CSharp12() => + builder + .AddSnippet(""" + // + using Microsoft.AspNetCore.Mvc; + + public partial class HomeController : Controller + { + [HttpGet("Test")] + public IActionResult ActionInGeneratedCode() => View(); // Secondary + } + """) + .AddSnippet(""" + using Microsoft.AspNetCore.Mvc; + + public partial class HomeController : Controller // Noncompliant + { + [HttpGet("Test")] + public IActionResult Index() => View(); // Secondary + } + """) + .Verify(); +} + +#endif diff --git a/analyzers/tests/SonarAnalyzer.Test/TestCases/SpecifyRouteAttribute.CSharp12.cs b/analyzers/tests/SonarAnalyzer.Test/TestCases/SpecifyRouteAttribute.CSharp12.cs new file mode 100644 index 00000000000..a1cbb206f65 --- /dev/null +++ b/analyzers/tests/SonarAnalyzer.Test/TestCases/SpecifyRouteAttribute.CSharp12.cs @@ -0,0 +1,192 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; + +using MA = Microsoft.AspNetCore; + +public class RouteTemplateIsNotSpecifiedController : Controller +{ + public IActionResult NoAttribute() => View(); // Compliant + + [HttpGet] + public IActionResult WithHttpGetAttribute() => View(); // Compliant + + [HttpGet()] + public IActionResult WithHttpGetAttributeWithParanthesis() => View(); // Compliant + + [HttpGetAttribute] + public IActionResult WithFullAttributeName() => View(); // Compliant + + [Microsoft.AspNetCore.Mvc.HttpGet] + public IActionResult WithNamespaceAttribute() => View(); // Compliant + + [MA.Mvc.HttpGet] + public IActionResult WithAliasedNamespaceAttribute() => View(); // Compliant + + [method: HttpGet] + public IActionResult WithScopedAttribute() => View(); // Compliant + + [HttpGet("/[controller]/[action]/{sortBy}")] + public IActionResult AbsoluteUri1(string sortBy) => View(); // Compliant, absolute uri + + [HttpGet("~/[controller]/[action]/{sortBy}")] + public IActionResult AbsoluteUri2(string sortBy) => View(); // Compliant, absolute uri + + public IActionResult Error() => View(); // Compliant +} + +public class RouteTemplatesAreSpecifiedController : Controller // Noncompliant [controller] {{Specify the RouteAttribute when an HttpMethodAttribute or RouteAttribute is specified at an action level.}} +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +{ + private const string ConstantRoute = "ConstantRoute"; + + [HttpGet("GetObject")] + public IActionResult Get() => View(); +// ^^^ Secondary [controller] + + [HttpGet("GetFirst")] + [HttpGet("GetSecond")] + public IActionResult GetMultipleTemplates() => View(); // Secondary [controller] + + [HttpGet("GetFirst")] + [HttpPut("PutFirst")] + public IActionResult MixGetAndPut() => View(); // Secondary [controller] + + [HttpGet("GetFirst")] + [HttpPut()] + public IActionResult MixWithTemplateAndWithout() => View(); // Secondary [controller] + + [HttpGet()] + [HttpPut()] + public IActionResult MixWithoutTemplate() => View(); + + [HttpPost("CreateObject")] + public IActionResult Post() => View(); // Secondary [controller] + + [HttpPut("UpdateObject")] + public IActionResult Put() => View(); // Secondary [controller] + + [HttpDelete("DeleteObject")] + public IActionResult Delete() => View(); // Secondary [controller] + + [HttpPatch("PatchObject")] + public IActionResult Patch() => View(); // Secondary [controller] + + [HttpHead("Head")] + public IActionResult HttpHead() => View(); // Secondary [controller] + + [HttpOptions("Options")] + public IActionResult HttpOptions() => View(); // Secondary [controller] + + [Route("details")] + public IActionResult WithRoute() => View(); // Secondary [controller] + + [Route("details", Order = 1)] + public IActionResult WithRouteAndProperties1() => View(); // Secondary [controller] + + [Route("details", Order = 1, Name = "Details")] + public IActionResult WithRouteAndProperties2() => View(); // Secondary [controller] + + [Route("details", Name = "Details", Order = 1)] + public IActionResult WithRouteAndProperties3() => View(); // Secondary [controller] + + [Route("[controller]/List/{sortBy}/{direction}")] + [HttpGet("[controller]/Search/{sortBy}/{direction}")] + public IActionResult RouteAndMethodMix(string sortBy) => View(); // Secondary [controller] + + [HttpGet("details", Order = 1)] + public IActionResult MultipleProperties1() => View(); // Secondary [controller] + + [HttpGet("details", Order = 1, Name = "Details")] + public IActionResult MultipleProperties2() => View(); // Secondary [controller] + + [HttpGet("details", Name = "Details", Order = 1)] + public IActionResult MultipleProperties3() => View(); // Secondary [controller] + + [HttpGet(ConstantRoute)] + public IActionResult Constant() => View(); // Secondary [controller] + + [HttpGet(""" + ConstantRoute + """)] + public IActionResult Constant2() => View(); // Secondary [controller] + + [HttpGet($"Route {ConstantRoute}")] + public IActionResult Interpolation1() => View(); // Secondary [controller] + + [HttpGet($""" + {ConstantRoute} + """)] + public IActionResult Interpolation2() => View(); // Secondary [controller] + + [HttpGet("GetObject")] + public ActionResult WithActionResult() => View(); // Secondary [controller] + + [Route(" ")] + public IActionResult WithSpace() => View(); // Secondary [controller] + + [Route("\t")] + public IActionResult WithTab() => View(); // Secondary [controller] + + // [HttpPost("Comment")] + public IActionResult Comment() => View(); +} + +[Route("api/[controller]")] +public class WithRouteAttributeIsCompliantController : Controller +{ + [HttpGet("Test")] + public IActionResult Index() => View(); +} + +public class WithUserDefinedAttributeController : Controller // Noncompliant [customAttribute] +{ + [MyHttpMethod("Test")] + public IActionResult Index() => View(); // Secondary [customAttribute] + + private sealed class MyHttpMethodAttribute(string template) : HttpMethodAttribute([template]) { } +} + +public class WithCustomGetAttributeController : Controller // Noncompliant [custom-get-attribute] +{ + [HttpGet("Test")] + public IActionResult Index() => View(); // Secondary [custom-get-attribute] + + private sealed class HttpGetAttribute(string template) : HttpMethodAttribute([template]) { } +} + +public class WithCustomController : DerivedController // Noncompliant [derivedController] +{ + [HttpGet("Test")] + public IActionResult Index() => View(); // Secondary [derivedController] +} + +[Controller] +public class WithAttributeController // Noncompliant [attribute-controller] +{ + [HttpGet("Test")] + public string Index() => "Hi!"; // Secondary [attribute-controller] +} + +public class WithAttributeControllerUsingInheritanceController : Endpoint // FN +{ + [HttpGet("Test")] + public string Index() => "Hi!"; // FN +} + +public class NamedController // FN +{ + [HttpGet("Test")] + public string Index() => "Hi!"; // FN +} + +[NonController] +public class NonController +{ + [HttpGet("Test")] + public string Index() => "Hi!"; +} +public class DerivedController : Controller { } + +[Controller] +public class Endpoint { }