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

Aspnet support (prerelease) #12

Merged
merged 30 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
fe2768e
add runtime expression evaluation
gregsdennis Oct 15, 2023
19f9135
map from openapi desc
gregsdennis Oct 16, 2023
3356a3a
use 'openapi.yaml'; watch additional files
gregsdennis Oct 18, 2023
246e4d5
deserialize api doc; downgrade models to net standard
gregsdennis Oct 19, 2023
7335365
diagnostic created to warn about missing route handlers
gregsdennis Oct 19, 2023
0483aa9
create scaffold for model generation
gregsdennis Oct 19, 2023
088a4df
implemented method checking (again, after deleting it the first time …
gregsdennis Oct 19, 2023
60ea6a6
more tests
gregsdennis Oct 20, 2023
c2502be
header tests
gregsdennis Oct 20, 2023
e2a973b
more tests; implicit body is difficult
gregsdennis Oct 25, 2023
ae2698a
getting references right
gregsdennis Oct 27, 2023
9545644
more work on analyzer
gregsdennis Dec 2, 2023
7286e62
update nuget packages
gregsdennis Jul 22, 2024
16ad753
analyzer now reads code and extra files; generates warnings for misse…
gregsdennis Sep 10, 2024
74d9934
add detection of additional routes/ops
gregsdennis Sep 13, 2024
2770a3a
refactor common functionality out
gregsdennis Sep 13, 2024
395d137
classes moving
gregsdennis Sep 13, 2024
71e8f6a
initial steps toward model generation
gregsdennis Sep 14, 2024
3e81e7f
more progress; blocked by async issue
gregsdennis Sep 15, 2024
c7b3370
add found schemas to doc resolver; still doesn't work
gregsdennis Sep 15, 2024
13fa2cd
use the corvus normalizer
gregsdennis Sep 16, 2024
2d45498
it's working! kinda
gregsdennis Sep 16, 2024
42198fb
all the nuget references
gregsdennis Sep 18, 2024
d287d17
move dependencies to .props file
gregsdennis Sep 18, 2024
675f495
remove extendedtypes lib; sort lib references
gregsdennis Sep 19, 2024
80fe0c4
a working prototype
gregsdennis Sep 19, 2024
4ea1e9e
a working prototype
gregsdennis Sep 19, 2024
c6e5931
delete the test analyzer
gregsdennis Sep 19, 2024
7e49150
works on the command line
gregsdennis Sep 23, 2024
61978fa
ignore tests for now. Update for better packaging
gregsdennis Sep 23, 2024
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
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
Loading