-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #12 from gregsdennis/aspnet
Aspnet support (prerelease)
- Loading branch information
Showing
70 changed files
with
2,916 additions
and
2,492 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
[*.{cs,vb}] | ||
|
||
# IDE0037: Use inferred member name | ||
dotnet_diagnostic.IDE0037.severity = silent |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -397,4 +397,5 @@ FodyWeavers.xsd | |
|
||
# JetBrains Rider | ||
*.sln.iml | ||
/Graeae.Models/OpenApi.Models.xml | ||
*.xml | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
using System; | ||
using System.Collections.Immutable; | ||
using System.IO; | ||
using System.Linq; | ||
using System.Threading; | ||
using Graeae.Models; | ||
using Microsoft.CodeAnalysis; | ||
using Microsoft.CodeAnalysis.CSharp.Syntax; | ||
using Yaml2JsonNode; | ||
|
||
namespace Graeae.AspNet.Analyzer; | ||
|
||
/// <summary> | ||
/// Outputs diagnostics for handlers that handle routes or operations that are not listed in the OAI description. | ||
/// </summary> | ||
[Generator(LanguageNames.CSharp)] | ||
internal class AdditionalOperationsAnalyzer : IIncrementalGenerator | ||
{ | ||
private static OpenApiDocument[]? OpenApiDocs { get; set; } | ||
|
||
public void Initialize(IncrementalGeneratorInitializationContext context) | ||
{ | ||
OpenApiDocs = null; | ||
|
||
var handlerClasses = context.SyntaxProvider.CreateSyntaxProvider(HandlerClassPredicate, HandlerClassTransform) | ||
.Where(x => x is not null); | ||
|
||
var files = context.AdditionalTextsProvider.Where(static file => file.Path.EndsWith("openapi.yaml")); | ||
var namesAndContents = files.Select((f, ct) => (Name: Path.GetFileNameWithoutExtension(f.Path), Content: f.GetText(ct)?.ToString(), Path: f.Path)); | ||
|
||
context.RegisterSourceOutput(handlerClasses.Combine(namesAndContents.Collect()), AddDiagnostics); | ||
} | ||
|
||
private static bool HandlerClassPredicate(SyntaxNode node, CancellationToken token) | ||
{ | ||
return node is ClassDeclarationSyntax { AttributeLists.Count: > 0 }; | ||
} | ||
|
||
private static (string, ClassDeclarationSyntax)? HandlerClassTransform(GeneratorSyntaxContext context, CancellationToken token) | ||
{ | ||
var classDeclaration = (ClassDeclarationSyntax)context.Node; | ||
var symbol = context.SemanticModel.GetDeclaredSymbol(context.Node); | ||
|
||
if (symbol is INamedTypeSymbol && | ||
classDeclaration.TryGetAttribute("Graeae.AspNet.RequestHandlerAttribute", context.SemanticModel, token, out var attribute) && | ||
attribute!.TryGetStringParameter(out var route)) | ||
{ | ||
return (route!, classDeclaration); | ||
} | ||
|
||
return null; | ||
} | ||
|
||
private static void AddDiagnostics(SourceProductionContext context, ((string Route, ClassDeclarationSyntax Type)? Handler, ImmutableArray<(string Name, string? Content, string Path)> Files) source) | ||
{ | ||
try | ||
{ | ||
OpenApiDocs ??= source.Files.Select(file => | ||
{ | ||
if (file.Content == null) | ||
throw new Exception("Failed to read file \"" + file.Path + "\""); | ||
var doc = YamlSerializer.Deserialize<OpenApiDocument>(file.Content); | ||
doc!.Initialize().Wait(); | ||
|
||
return doc; | ||
}).ToArray(); | ||
|
||
var handler = source.Handler!.Value; | ||
|
||
var allPaths = OpenApiDocs.SelectMany(x => x.Paths).ToList(); | ||
var path = allPaths.FirstOrDefault(x => x.Key.ToString() == handler.Route); | ||
if (path.Key is null) | ||
{ | ||
context.ReportDiagnostic(Diagnostics.AdditionalRouteHandler(handler.Route)); | ||
return; | ||
} | ||
|
||
var route = path.Key; | ||
var pathItem = path.Value; | ||
|
||
var methods = handler.Type.Members.OfType<MethodDeclarationSyntax>().ToArray(); | ||
|
||
foreach (var method in methods) | ||
{ | ||
var (op, name) = GetMatchingOperation(method, pathItem); | ||
if (!OperationExists(route, op, method)) | ||
context.ReportDiagnostic(Diagnostics.AdditionalRouteOperationHandler(route.ToString(), name!)); | ||
} | ||
} | ||
catch (Exception e) | ||
{ | ||
//Debug.Break(); | ||
var errorMessage = $"Error: {e.Message}\n\nStack trace: {e.StackTrace}\n\nStack trace: {e.InnerException?.StackTrace}"; | ||
context.ReportDiagnostic(Diagnostics.OperationalError(errorMessage)); | ||
} | ||
} | ||
|
||
private static (Operation? Op, string? Name) GetMatchingOperation(MethodDeclarationSyntax method, PathItem pathItem) => | ||
method.Identifier.ValueText.ToUpperInvariant() switch | ||
{ | ||
"GET" => (pathItem.Get, nameof(pathItem.Get)), | ||
"POST" => (pathItem.Post, nameof(pathItem.Post)), | ||
"PUT" => (pathItem.Put, nameof(pathItem.Put)), | ||
"DELETE" => (pathItem.Delete, nameof(pathItem.Delete)), | ||
"TRACE" => (pathItem.Trace, nameof(pathItem.Trace)), | ||
"OPTIONS" => (pathItem.Options, nameof(pathItem.Options)), | ||
"HEAD" => (pathItem.Head, nameof(pathItem.Head)), | ||
_ => (null, null) | ||
}; | ||
|
||
private static bool OperationExists(PathTemplate route, Operation? op, MethodDeclarationSyntax method) | ||
{ | ||
if (op is null) return false; | ||
|
||
// parameters can be implicitly or explicitly bound | ||
// | ||
// - path | ||
// - implicitly bound by name | ||
// - explicitly bound with [FromRoute(Name = "name")] | ||
// - query | ||
// - implicitly bound by name | ||
// - explicitly bound with [FromQuery(Name = "name")] | ||
// - header | ||
// - explicitly bound with [FromHeader(Name = "name")] | ||
// - body | ||
// - implicitly bound by model | ||
|
||
var implicitOpenApiParameters = route.Segments.Select(x => | ||
{ | ||
var match = PathHelpers.TemplatedSegmentPattern.Match(x); | ||
return match.Success | ||
? new Parameter(match.Groups["param"].Value, ParameterLocation.Path) | ||
: null; | ||
}).Where(x => x is not null); | ||
if (op.RequestBody is not null) | ||
implicitOpenApiParameters = implicitOpenApiParameters.Append(Parameter.Body); | ||
var explicitOpenapiParameters = op.Parameters?.Select(x => new Parameter(x.Name, x.In)) ?? []; | ||
var openApiParameters = implicitOpenApiParameters.Union(explicitOpenapiParameters).ToArray(); | ||
var methodParameterList = method.ParameterList.Parameters.SelectMany(AnalysisExtensions.GetParameters); | ||
|
||
return openApiParameters.All(x => methodParameterList.Contains(x)); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Threading; | ||
using Graeae.Models; | ||
using Microsoft.CodeAnalysis; | ||
using Microsoft.CodeAnalysis.CSharp; | ||
using Microsoft.CodeAnalysis.CSharp.Syntax; | ||
|
||
namespace Graeae.AspNet.Analyzer; | ||
|
||
internal static class AnalysisExtensions | ||
{ | ||
public static bool TryGetAttribute(this ClassDeclarationSyntax candidate, string attributeName, SemanticModel semanticModel, CancellationToken cancellationToken, out AttributeSyntax? value) | ||
{ | ||
foreach (var attributeList in candidate.AttributeLists) | ||
{ | ||
foreach (var attribute in attributeList.Attributes) | ||
{ | ||
var info = semanticModel.GetSymbolInfo(attribute, cancellationToken); | ||
var symbol = info.Symbol; | ||
|
||
if (symbol is IMethodSymbol method | ||
&& method.ContainingType.ToDisplayString().Equals(attributeName, StringComparison.Ordinal)) | ||
{ | ||
value = attribute; | ||
return true; | ||
} | ||
} | ||
} | ||
|
||
value = null; | ||
return false; | ||
} | ||
|
||
public static bool TryGetStringParameter(this AttributeSyntax attribute, out string? value) | ||
{ | ||
if (attribute.ArgumentList is | ||
{ | ||
Arguments.Count: 1, | ||
} argumentList) | ||
{ | ||
var argument = argumentList.Arguments[0]; | ||
|
||
if (argument.Expression is LiteralExpressionSyntax literal) | ||
{ | ||
value = literal.Token.Value?.ToString(); | ||
return true; | ||
} | ||
} | ||
|
||
value = null; | ||
return false; | ||
} | ||
|
||
public static IEnumerable<Parameter> GetParameters(this ParameterSyntax parameter) | ||
{ | ||
if (TryGetAttribute(parameter.AttributeLists, "FromRoute", out var attribute) && | ||
TryGetStringParameter(attribute!, out var name)) | ||
yield return new Parameter(name!, ParameterLocation.Path); | ||
else if (TryGetAttribute(parameter.AttributeLists, "FromQuery", out attribute) && | ||
TryGetStringParameter(attribute!, out name)) | ||
yield return new Parameter(name!, ParameterLocation.Query); | ||
else if (TryGetAttribute(parameter.AttributeLists, "FromHeader", out attribute) && | ||
TryGetStringParameter(attribute!, out name)) | ||
yield return new Parameter(name!, ParameterLocation.Header); | ||
else if (TryGetAttribute(parameter.AttributeLists, "FromBody", out _)) | ||
yield return Parameter.Body; | ||
else if (TryGetAttribute(parameter.AttributeLists, "FromServices", out _)) | ||
{ | ||
} | ||
else | ||
{ | ||
// if no attributes are found then consider all implicit options | ||
yield return new Parameter(parameter.Identifier.ValueText, ParameterLocation.Path); | ||
yield return new Parameter(parameter.Identifier.ValueText, ParameterLocation.Query); | ||
// TODO: this is catching services and the http context | ||
//yield return Parameter.Body; | ||
} | ||
} | ||
|
||
private static bool TryGetAttribute(SyntaxList<AttributeListSyntax> attributeLists, string attributeName, out AttributeSyntax? attribute) | ||
{ | ||
foreach (var attributeList in attributeLists) | ||
{ | ||
foreach (var att in attributeList.Attributes) | ||
{ | ||
if (att.Name.ToString() == attributeName) | ||
{ | ||
attribute = att; | ||
return true; | ||
} | ||
} | ||
} | ||
|
||
attribute = null; | ||
return false; | ||
} | ||
|
||
/// <summary> | ||
/// determine the namespace the class/enum/struct is declared in, if any | ||
/// </summary> | ||
public static string GetNamespace(this BaseTypeDeclarationSyntax syntax) | ||
{ | ||
// If we don't have a namespace at all we'll return an empty string | ||
// This accounts for the "default namespace" case | ||
string nameSpace = string.Empty; | ||
|
||
// Get the containing syntax node for the type declaration | ||
// (could be a nested type, for example) | ||
SyntaxNode? potentialNamespaceParent = syntax.Parent; | ||
|
||
// Keep moving "out" of nested classes etc until we get to a namespace | ||
// or until we run out of parents | ||
while (potentialNamespaceParent != null && | ||
potentialNamespaceParent is not NamespaceDeclarationSyntax | ||
&& potentialNamespaceParent is not FileScopedNamespaceDeclarationSyntax) | ||
{ | ||
potentialNamespaceParent = potentialNamespaceParent.Parent; | ||
} | ||
|
||
// Build up the final namespace by looping until we no longer have a namespace declaration | ||
if (potentialNamespaceParent is BaseNamespaceDeclarationSyntax namespaceParent) | ||
{ | ||
// We have a namespace. Use that as the type | ||
nameSpace = namespaceParent.Name.ToString(); | ||
|
||
// Keep moving "out" of the namespace declarations until we | ||
// run out of nested namespace declarations | ||
while (true) | ||
{ | ||
if (namespaceParent.Parent is not NamespaceDeclarationSyntax parent) | ||
{ | ||
break; | ||
} | ||
|
||
// Add the outer namespace as a prefix to the final namespace | ||
nameSpace = $"{namespaceParent.Name}.{nameSpace}"; | ||
namespaceParent = parent; | ||
} | ||
} | ||
|
||
// return the final namespace | ||
return nameSpace; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
using System.Diagnostics; | ||
|
||
namespace Graeae.AspNet.Analyzer; | ||
|
||
internal static class Debug | ||
{ | ||
[Conditional("DEBUG")] | ||
public static void Inject() | ||
{ | ||
if (!Debugger.IsAttached) Debugger.Launch(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
using Microsoft.CodeAnalysis; | ||
|
||
namespace Graeae.AspNet.Analyzer; | ||
|
||
internal static class Diagnostics | ||
{ | ||
public static Diagnostic OperationalError(string message) => | ||
Diagnostic.Create(new("GR0001", "Operational error", message, "Operation", DiagnosticSeverity.Error, true), Location.None, DiagnosticSeverity.Error); | ||
|
||
public static Diagnostic NoPaths(string openApiFilePath) => | ||
Diagnostic.Create(new("GR0002", "No paths", $"No paths are defined in '{openApiFilePath}'", "Path coverage", DiagnosticSeverity.Info, true), Location.None, DiagnosticSeverity.Info); | ||
|
||
public static Diagnostic MissingRouteHandler(string route) => | ||
Diagnostic.Create(new("GR0003", "Route not handled", $"Found no handler type for route '{route}'", "Path coverage", DiagnosticSeverity.Warning, true), Location.None, DiagnosticSeverity.Warning); | ||
|
||
public static Diagnostic MissingRouteOperationHandler(string route, string op) => | ||
Diagnostic.Create(new("GR0004", "Route not handled", $"Found no handler for '{op.ToUpperInvariant()} {route}'", "Path coverage", DiagnosticSeverity.Warning, true), Location.None, DiagnosticSeverity.Warning); | ||
|
||
public static Diagnostic AdditionalRouteHandler(string route) => | ||
Diagnostic.Create(new("GR0005", "Route not published", $"Found handler type for route '{route}' but it does not appear in the OpenAPI definition", "Path coverage", DiagnosticSeverity.Warning, true), Location.None, DiagnosticSeverity.Warning); | ||
|
||
public static Diagnostic AdditionalRouteOperationHandler(string route, string op) => | ||
Diagnostic.Create(new("GR0006", "Route not published", $"Found handler for '{op.ToUpperInvariant()} {route}' but it does not appear in the OpenAPI definition", "Path coverage", DiagnosticSeverity.Warning, true), Location.None, DiagnosticSeverity.Warning); | ||
|
||
public static Diagnostic ExternalFileAdded(string filePath) => | ||
Diagnostic.Create(new("GR0007", "Document load success", $"File {filePath} added to document resolver", "OpenAPI docs", DiagnosticSeverity.Info, true), null, filePath); | ||
|
||
public static Diagnostic ExternalFileNotAdded(string filePath) => | ||
Diagnostic.Create(new DiagnosticDescriptor("GR0008", "Document load failure", $"File {filePath} could not be added to document resolver", "OpenAPI docs", DiagnosticSeverity.Warning, true), null, filePath); | ||
} |
Oops, something went wrong.