Skip to content

Commit

Permalink
Merge pull request #12 from gregsdennis/aspnet
Browse files Browse the repository at this point in the history
Aspnet support (prerelease)
  • Loading branch information
gregsdennis authored Sep 23, 2024
2 parents e8a9e4b + 61978fa commit 33d8041
Show file tree
Hide file tree
Showing 70 changed files with 2,916 additions and 2,492 deletions.
4 changes: 4 additions & 0 deletions .editorconfig
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -397,4 +397,5 @@ FodyWeavers.xsd

# JetBrains Rider
*.sln.iml
/Graeae.Models/OpenApi.Models.xml
*.xml

143 changes: 143 additions & 0 deletions Graeae.AspNet.Analyzer/AdditionalOperationsAnalyzer.cs
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));
}
}
145 changes: 145 additions & 0 deletions Graeae.AspNet.Analyzer/AnalysisExtensions.cs
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;
}
}
12 changes: 12 additions & 0 deletions Graeae.AspNet.Analyzer/Debug.cs
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();
}
}
30 changes: 30 additions & 0 deletions Graeae.AspNet.Analyzer/Diagnostics.cs
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);
}
Loading

0 comments on commit 33d8041

Please sign in to comment.