feat: add Roslyn-based compile-time request handler registration
Introduce compile-time registration capabilities with source generation, enabling automatic handler registration.
This commit is contained in:
parent
148a878951
commit
f77c1ff29b
20 changed files with 918 additions and 133 deletions
24
README.md
24
README.md
|
|
@ -66,7 +66,29 @@ public class ScalarBehavior : IScalarRequestBehavior<ScalarRequest, string>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Compile-time registration
|
||||||
|
|
||||||
|
Projects that directly reference `Geekeey.Request` also get generated registration methods in the
|
||||||
|
`Geekeey.Request` namespace:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
collection.AddRequestDispatcher(builder => builder
|
||||||
|
.AddExampleProject()
|
||||||
|
.Add(typeof(ScalarBehavior)));
|
||||||
|
|
||||||
|
collection.AddRequestDispatcher(builder => builder
|
||||||
|
.AddExampleProject(ServiceLifetime.Scoped)
|
||||||
|
.Add(typeof(ScalarBehavior)));
|
||||||
|
```
|
||||||
|
|
||||||
|
- generation is enabled by default
|
||||||
|
- disable it with `<CompiletimeRequestDispatchHandlerRegistration>false</CompiletimeRequestDispatchHandlerRegistration>`
|
||||||
|
- rename the generated `Add<Name>(...)` methods with `CompiletimeRequestDispatchHandlerName`
|
||||||
|
- only request handlers are generated; behaviors still need normal registration
|
||||||
|
- nested request handlers are rejected during `Add(...)` registration, including `SearchHandlerInAssembly(...)` and by the source generator
|
||||||
|
- use one registration style per assembly: generated methods or `SearchHandlerInAssembly(...)`
|
||||||
|
|
||||||
## Behaviour of the Handlers
|
## Behaviour of the Handlers
|
||||||
|
|
||||||
Handlers are resolved from either the DI conatiner or are created on the fly but can receive arguments from the DI
|
Handlers are resolved from either the DI container or are created on the fly but can receive arguments from the DI
|
||||||
container when being constructed. The same also applied for the request pipeline behaviours.
|
container when being constructed. The same also applied for the request pipeline behaviours.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<Solution>
|
<Solution>
|
||||||
|
<Project Path="src/request.generator/Geekeey.Request.Generator.csproj" />
|
||||||
<Project Path="src/request.validation/Geekeey.Request.Validation.csproj" />
|
<Project Path="src/request.validation/Geekeey.Request.Validation.csproj" />
|
||||||
<Project Path="src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj" />
|
<Project Path="src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj" />
|
||||||
<Project Path="src/request/Geekeey.Request.csproj" />
|
<Project Path="src/request/Geekeey.Request.csproj" />
|
||||||
|
|
|
||||||
293
src/request.generator/CompiletimeRequestDispatchGenerator.cs
Normal file
293
src/request.generator/CompiletimeRequestDispatchGenerator.cs
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Diagnostics;
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Generator;
|
||||||
|
|
||||||
|
[Generator(LanguageNames.CSharp)]
|
||||||
|
public sealed class CompileTimeRequestDispatchGenerator : IIncrementalGenerator
|
||||||
|
{
|
||||||
|
private const string DefaultNamespace = "Geekeey.Request";
|
||||||
|
private const string ScalarHandlerMetadataName = "Geekeey.Request.IScalarRequestHandler`2";
|
||||||
|
private const string StreamHandlerMetadataName = "Geekeey.Request.IStreamRequestHandler`2";
|
||||||
|
|
||||||
|
internal static readonly DiagnosticDescriptor InvalidExplicitName = new(
|
||||||
|
id: "GKRQ001",
|
||||||
|
title: "Invalid compile-time registration name",
|
||||||
|
messageFormat: "CompileTimeRequestDispatchHandlerName value '{0}' is not a valid C# identifier",
|
||||||
|
category: "Geekeey.Request",
|
||||||
|
defaultSeverity: DiagnosticSeverity.Error,
|
||||||
|
isEnabledByDefault: true);
|
||||||
|
|
||||||
|
internal static readonly DiagnosticDescriptor NestedHandlerIgnored = new(
|
||||||
|
id: "GKRQ002",
|
||||||
|
title: "Invalid nested request handler",
|
||||||
|
messageFormat: "Nested request handler '{0}' is not supported by compile-time registration",
|
||||||
|
category: "Geekeey.Request",
|
||||||
|
defaultSeverity: DiagnosticSeverity.Error,
|
||||||
|
isEnabledByDefault: true);
|
||||||
|
|
||||||
|
internal static readonly DiagnosticDescriptor InvalidDerivedName = new(
|
||||||
|
id: "GKRQ003",
|
||||||
|
title: "Invalid derived compile-time registration name",
|
||||||
|
messageFormat: "AssemblyName '{0}' does not produce a valid compile-time registration name. Set CompileTimeRequestDispatchHandlerName to a valid identifier.",
|
||||||
|
category: "Geekeey.Request",
|
||||||
|
defaultSeverity: DiagnosticSeverity.Error,
|
||||||
|
isEnabledByDefault: true);
|
||||||
|
|
||||||
|
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||||
|
{
|
||||||
|
var options = context.AnalyzerConfigOptionsProvider
|
||||||
|
.Select(static (provider, _) => GeneratorOptions.Create(provider.GlobalOptions));
|
||||||
|
|
||||||
|
var candidates = context.SyntaxProvider
|
||||||
|
.CreateSyntaxProvider(
|
||||||
|
static (node, _) => node is ClassDeclarationSyntax,
|
||||||
|
static (syntaxContext, cancellationToken) => GetCandidate(syntaxContext, cancellationToken))
|
||||||
|
.Where(static candidate => candidate is not null)
|
||||||
|
.Select(static (candidate, _) => candidate!.Value)
|
||||||
|
.Collect();
|
||||||
|
|
||||||
|
context.RegisterSourceOutput(options.Combine(candidates),
|
||||||
|
static (spc, source) => Execute(spc, source.Left, source.Right));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HandlerCandidate? GetCandidate(GeneratorSyntaxContext context, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var declaration = (ClassDeclarationSyntax)context.Node;
|
||||||
|
|
||||||
|
if (context.SemanticModel.GetDeclaredSymbol(declaration, cancellationToken) is not { TypeKind: TypeKind.Class } symbol)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (symbol.IsAbstract || !symbol.AllInterfaces.Any(IsHandlerType))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HandlerCandidate(symbol, symbol.ContainingType is not null, declaration.Identifier.GetLocation());
|
||||||
|
|
||||||
|
static bool IsHandlerType(INamedTypeSymbol @interface)
|
||||||
|
{
|
||||||
|
if (!@interface.IsGenericType)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol = @interface.OriginalDefinition;
|
||||||
|
var name = $"{symbol.ContainingNamespace.ToDisplayString()}.{symbol.MetadataName}";
|
||||||
|
return name is ScalarHandlerMetadataName or StreamHandlerMetadataName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Execute(SourceProductionContext context, GeneratorOptions options, ImmutableArray<HandlerCandidate> candidates)
|
||||||
|
{
|
||||||
|
if (!options.Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryGetMethodName(options, out var methodName, out var diagnostic))
|
||||||
|
{
|
||||||
|
if (diagnostic is not null)
|
||||||
|
{
|
||||||
|
context.ReportDiagnostic(diagnostic);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nestedHandlers = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
|
||||||
|
var topLevelHandlers = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
|
||||||
|
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
if (candidate.IsNested)
|
||||||
|
{
|
||||||
|
if (nestedHandlers.Add(candidate.Symbol))
|
||||||
|
{
|
||||||
|
context.ReportDiagnostic(Diagnostic.Create(
|
||||||
|
NestedHandlerIgnored,
|
||||||
|
candidate.Location ?? candidate.Symbol.Locations.FirstOrDefault(),
|
||||||
|
candidate.Symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)));
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
topLevelHandlers.Add(candidate.Symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
var handlers = topLevelHandlers
|
||||||
|
.Select(static symbol => symbol.Arity is 0
|
||||||
|
? symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
|
||||||
|
: symbol.ConstructUnboundGenericType().ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))
|
||||||
|
.OrderBy(static typeName => typeName, StringComparer.Ordinal)
|
||||||
|
.ToImmutableArray();
|
||||||
|
|
||||||
|
context.AddSource(
|
||||||
|
hintName: $"Geekeey.Request.CompileTimeRegistration.{methodName}.g.cs",
|
||||||
|
source: RenderSource(methodName, handlers));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetMethodName(GeneratorOptions options, out string name, out Diagnostic? diagnostic)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.ExplicitName))
|
||||||
|
{
|
||||||
|
name = options.ExplicitName ?? string.Empty;
|
||||||
|
|
||||||
|
if (!SyntaxFacts.IsValidIdentifier(name))
|
||||||
|
{
|
||||||
|
name = string.Empty;
|
||||||
|
diagnostic = Diagnostic.Create(InvalidExplicitName, Location.None, options.ExplicitName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
name = SanitizeAssemblyName(options.AssemblyName);
|
||||||
|
|
||||||
|
if (!SyntaxFacts.IsValidIdentifier(name))
|
||||||
|
{
|
||||||
|
name = string.Empty;
|
||||||
|
diagnostic = Diagnostic.Create(InvalidDerivedName, Location.None, options.AssemblyName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostic = null;
|
||||||
|
return true;
|
||||||
|
|
||||||
|
static string SanitizeAssemblyName(string assemblyName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(assemblyName))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder(assemblyName.Length + 1);
|
||||||
|
foreach (var character in assemblyName)
|
||||||
|
{
|
||||||
|
if (builder.Length is 0)
|
||||||
|
{
|
||||||
|
if (SyntaxFacts.IsIdentifierStartCharacter(character))
|
||||||
|
{
|
||||||
|
builder.Append(character);
|
||||||
|
}
|
||||||
|
else if (char.IsDigit(character))
|
||||||
|
{
|
||||||
|
builder.Append('_');
|
||||||
|
builder.Append(character);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SyntaxFacts.IsIdentifierPartCharacter(character))
|
||||||
|
{
|
||||||
|
builder.Append(character);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RenderSource(string methodName, ImmutableArray<string> handlers)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder()
|
||||||
|
.AppendLine("// <auto-generated/>")
|
||||||
|
.AppendLine("#nullable enable")
|
||||||
|
.AppendLine()
|
||||||
|
.AppendLine($"namespace {DefaultNamespace};")
|
||||||
|
.AppendLine()
|
||||||
|
.AppendLine($"public static class CompileTimeRequestDispatcherBuilderExtensions_{methodName}")
|
||||||
|
.AppendLine("{")
|
||||||
|
.AppendLine($"\tpublic static global::Geekeey.Request.IRequestDispatcherBuilder Add{methodName}(this global::Geekeey.Request.IRequestDispatcherBuilder builder)")
|
||||||
|
.AppendLine("\t{")
|
||||||
|
.AppendLine("\t\tglobal::System.ArgumentNullException.ThrowIfNull(builder);");
|
||||||
|
|
||||||
|
foreach (var handlerTypeName in handlers)
|
||||||
|
{
|
||||||
|
builder.AppendLine($"\t\tbuilder.Add(typeof({handlerTypeName}));");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
|
.AppendLine()
|
||||||
|
.AppendLine("\t\treturn builder;")
|
||||||
|
.AppendLine("\t}")
|
||||||
|
.AppendLine()
|
||||||
|
.AppendLine($"\tpublic static global::Geekeey.Request.IRequestDispatcherBuilder Add{methodName}(this global::Geekeey.Request.IRequestDispatcherBuilder builder, global::Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime)")
|
||||||
|
.AppendLine("\t{")
|
||||||
|
.AppendLine("\t\tglobal::System.ArgumentNullException.ThrowIfNull(builder);");
|
||||||
|
|
||||||
|
foreach (var handlerTypeName in handlers)
|
||||||
|
{
|
||||||
|
builder.AppendLine($"\t\tbuilder.Add(typeof({handlerTypeName}), lifetime);");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
|
.AppendLine()
|
||||||
|
.AppendLine("\t\treturn builder;")
|
||||||
|
.AppendLine("\t}")
|
||||||
|
.AppendLine("}");
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct HandlerCandidate
|
||||||
|
{
|
||||||
|
public HandlerCandidate(INamedTypeSymbol symbol, bool isNested, Location? location)
|
||||||
|
{
|
||||||
|
Symbol = symbol;
|
||||||
|
IsNested = isNested;
|
||||||
|
Location = location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public INamedTypeSymbol Symbol { get; }
|
||||||
|
|
||||||
|
public bool IsNested { get; }
|
||||||
|
|
||||||
|
public Location? Location { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record GeneratorOptions
|
||||||
|
{
|
||||||
|
private const string AssemblyNameProperty = "build_property.AssemblyName";
|
||||||
|
private const string EnabledProperty = "build_property.CompileTimeRequestDispatchHandlerRegistration";
|
||||||
|
private const string NameProperty = "build_property.CompileTimeRequestDispatchHandlerName";
|
||||||
|
|
||||||
|
public GeneratorOptions(bool enabled, string assemblyName, string? explicitName)
|
||||||
|
{
|
||||||
|
Enabled = enabled;
|
||||||
|
AssemblyName = assemblyName;
|
||||||
|
ExplicitName = explicitName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Enabled { get; }
|
||||||
|
|
||||||
|
public string AssemblyName { get; }
|
||||||
|
|
||||||
|
public string? ExplicitName { get; }
|
||||||
|
|
||||||
|
public static GeneratorOptions Create(AnalyzerConfigOptions options)
|
||||||
|
{
|
||||||
|
var enabled = !options.TryGetValue(EnabledProperty, out var enabledValue) ||
|
||||||
|
!bool.TryParse(enabledValue, out var parsedValue) ||
|
||||||
|
parsedValue;
|
||||||
|
|
||||||
|
_ = options.TryGetValue(AssemblyNameProperty, out var assemblyName);
|
||||||
|
_ = options.TryGetValue(NameProperty, out var explicitName);
|
||||||
|
|
||||||
|
return new GeneratorOptions(enabled, assemblyName ?? string.Empty, explicitName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/request.generator/Geekeey.Request.Generator.csproj
Normal file
24
src/request.generator/Geekeey.Request.Generator.csproj
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Library</OutputType>
|
||||||
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Label="Roslyn Analyzer">
|
||||||
|
<NoWarn>$(NoWarn);RS1036;RS2008</NoWarn>
|
||||||
|
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||||
|
<IsRoslynComponent>true</IsRoslynComponent>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
|
||||||
|
<InternalsVisibleTo Include="Geekeey.Request.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
11
src/request.generator/Geekeey.Request.Generator.props
Normal file
11
src/request.generator/Geekeey.Request.Generator.props
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<CompiletimeRequestDispatchHandlerRegistration Condition="'$(CompiletimeRequestDispatchHandlerRegistration)' == ''">true</CompiletimeRequestDispatchHandlerRegistration>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<CompilerVisibleProperty Include="AssemblyName" />
|
||||||
|
<CompilerVisibleProperty Include="CompiletimeRequestDispatchHandlerRegistration" />
|
||||||
|
<CompilerVisibleProperty Include="CompiletimeRequestDispatchHandlerName" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
147
src/request.tests/CompiletimeRequestDispatchGeneratorTests.cs
Normal file
147
src/request.tests/CompiletimeRequestDispatchGeneratorTests.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
using Geekeey.Request.Generator;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Tests;
|
||||||
|
|
||||||
|
internal sealed class CompileTimeRequestDispatchGeneratorTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_generate_extensions_for_top_level_request_handlers()
|
||||||
|
{
|
||||||
|
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
|
||||||
|
{
|
||||||
|
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
|
||||||
|
options.AssemblyName = "Example.Project";
|
||||||
|
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
|
||||||
|
options.References.Add(typeof(ServiceLifetime).Assembly);
|
||||||
|
});
|
||||||
|
|
||||||
|
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
|
||||||
|
|
||||||
|
using var scope = Assert.Multiple();
|
||||||
|
await Assert.That(result.Diagnostics).IsEmpty();
|
||||||
|
await Assert.That(content).Contains("AddExampleProject");
|
||||||
|
await Assert.That(content).Contains("typeof(global::Sample.PingHandler)");
|
||||||
|
await Assert.That(content).Contains("typeof(global::Sample.OpenPingHandler<>)");
|
||||||
|
await Assert.That(content).DoesNotContain("PingBehavior");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_override_the_generated_extension_name()
|
||||||
|
{
|
||||||
|
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
|
||||||
|
{
|
||||||
|
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
|
||||||
|
options.AssemblyName = "Example.Project";
|
||||||
|
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
|
||||||
|
options.References.Add(typeof(ServiceLifetime).Assembly);
|
||||||
|
options.Properties["CompileTimeRequestDispatchHandlerName"] = "CustomRegistration";
|
||||||
|
});
|
||||||
|
|
||||||
|
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
|
||||||
|
|
||||||
|
await Assert.That(content).Contains("AddCustomRegistration");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_get_an_error_for_an_invalid_explicit_name()
|
||||||
|
{
|
||||||
|
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
|
||||||
|
{
|
||||||
|
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
|
||||||
|
options.AssemblyName = "Example.Project";
|
||||||
|
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
|
||||||
|
options.References.Add(typeof(ServiceLifetime).Assembly);
|
||||||
|
options.Properties["CompileTimeRequestDispatchHandlerName"] = "not.valid";
|
||||||
|
});
|
||||||
|
|
||||||
|
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
|
||||||
|
|
||||||
|
using var scope = Assert.Multiple();
|
||||||
|
await Assert.That(result.Diagnostics).Count().IsEqualTo(1);
|
||||||
|
await Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Descriptor))
|
||||||
|
.IsEquivalentTo([CompileTimeRequestDispatchGenerator.InvalidExplicitName]);
|
||||||
|
await Assert.That(result.GeneratedTrees).IsEmpty();
|
||||||
|
await Assert.That(content).IsNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_get_an_error_when_the_assembly_name_cannot_be_sanitized_to_a_valid_identifier()
|
||||||
|
{
|
||||||
|
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
|
||||||
|
{
|
||||||
|
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
|
||||||
|
options.AssemblyName = "...";
|
||||||
|
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
|
||||||
|
options.References.Add(typeof(ServiceLifetime).Assembly);
|
||||||
|
});
|
||||||
|
|
||||||
|
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
|
||||||
|
|
||||||
|
using var scope = Assert.Multiple();
|
||||||
|
await Assert.That(result.Diagnostics).Count().IsEqualTo(1);
|
||||||
|
await Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Descriptor))
|
||||||
|
.IsEquivalentTo([CompileTimeRequestDispatchGenerator.InvalidDerivedName]);
|
||||||
|
await Assert.That(result.GeneratedTrees).IsEmpty();
|
||||||
|
await Assert.That(content).IsNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_get_an_error_for_nested_handlers_and_they_are_ignored()
|
||||||
|
{
|
||||||
|
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
|
||||||
|
{
|
||||||
|
options.AddFromEmbeddedSource("GeneratorNestedHandlers.cs");
|
||||||
|
options.AssemblyName = "Example.Project";
|
||||||
|
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
|
||||||
|
options.References.Add(typeof(ServiceLifetime).Assembly);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
|
||||||
|
|
||||||
|
using var scope = Assert.Multiple();
|
||||||
|
await Assert.That(result.Diagnostics).Count().IsEqualTo(1);
|
||||||
|
await Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Descriptor))
|
||||||
|
.IsEquivalentTo([CompileTimeRequestDispatchGenerator.NestedHandlerIgnored]);
|
||||||
|
await Assert.That(content).DoesNotContain("Container.PingHandler");
|
||||||
|
await Assert.That(content).DoesNotContain("builder.Add(typeof(");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_still_generate_a_no_op_extension_when_no_handlers_exist()
|
||||||
|
{
|
||||||
|
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
|
||||||
|
{
|
||||||
|
options.AddFromEmbeddedSource("GeneratorNoHandlers.cs");
|
||||||
|
options.AssemblyName = "Example.Project";
|
||||||
|
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
|
||||||
|
options.References.Add(typeof(ServiceLifetime).Assembly);
|
||||||
|
});
|
||||||
|
|
||||||
|
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
|
||||||
|
|
||||||
|
using var scope = Assert.Multiple();
|
||||||
|
await Assert.That(content).Contains("AddExampleProject");
|
||||||
|
await Assert.That(content).DoesNotContain("builder.Add(typeof(");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_disable_CompileTime_registration_generation()
|
||||||
|
{
|
||||||
|
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
|
||||||
|
{
|
||||||
|
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
|
||||||
|
options.AssemblyName = "Example.Project";
|
||||||
|
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
|
||||||
|
options.References.Add(typeof(ServiceLifetime).Assembly);
|
||||||
|
options.Properties["CompileTimeRequestDispatchHandlerRegistration"] = "false";
|
||||||
|
});
|
||||||
|
|
||||||
|
await Assert.That(result.GeneratedTrees).IsEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,12 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Compile Remove="_fixtures/roslyn/**/*.cs" />
|
||||||
|
<EmbeddedResource Include="_fixtures/roslyn/**/*.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\request.generator\Geekeey.Request.Generator.csproj" />
|
||||||
<ProjectReference Include="..\request\Geekeey.Request.csproj" />
|
<ProjectReference Include="..\request\Geekeey.Request.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
51
src/request.tests/PackageContentsTests.cs
Normal file
51
src/request.tests/PackageContentsTests.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Tests;
|
||||||
|
|
||||||
|
internal sealed class PackageContentsTests
|
||||||
|
{
|
||||||
|
private static string RepoRoot => Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..");
|
||||||
|
private static string ArtifactsDir => Path.Combine(RepoRoot, "artifacts");
|
||||||
|
private static string ProjectPath => Path.Combine(RepoRoot, "src", "request", "Geekeey.Request.csproj");
|
||||||
|
|
||||||
|
private readonly string _outputDir;
|
||||||
|
|
||||||
|
public PackageContentsTests()
|
||||||
|
{
|
||||||
|
_outputDir = Path.Combine(ArtifactsDir, Path.GetRandomFileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Before(Test)]
|
||||||
|
public async Task SetUpAsync()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_outputDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
[After(Test)]
|
||||||
|
public async Task CleanUpAsync()
|
||||||
|
{
|
||||||
|
Directory.Delete(_outputDir, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_verify_the_package_contains_the_expected_contents()
|
||||||
|
{
|
||||||
|
using var process = Process.Start("dotnet", $"pack \"{ProjectPath}\" -o \"{_outputDir}\" -c Release");
|
||||||
|
await process.WaitForExitAsync();
|
||||||
|
|
||||||
|
var pkgs = Directory.GetFiles(_outputDir, "*.nupkg");
|
||||||
|
await Assert.That(pkgs.Length).IsGreaterThanOrEqualTo(1);
|
||||||
|
|
||||||
|
await using var archive = await ZipFile.OpenReadAsync(pkgs[0]);
|
||||||
|
var entries = archive.Entries.Select(entry => entry.FullName.Replace('\\', '/')).ToHashSet();
|
||||||
|
|
||||||
|
await Assert.That(entries).Contains("lib/net10.0/Geekeey.Request.dll");
|
||||||
|
await Assert.That(entries).Contains("lib/net10.0/Geekeey.Request.xml");
|
||||||
|
await Assert.That(entries).Contains("build/Geekeey.Request.props");
|
||||||
|
await Assert.That(entries).Contains("analyzers/dotnet/cs/Geekeey.Request.Generator.dll");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -100,9 +100,43 @@ internal sealed class RequestDispatcherBuilderExtensionsTests
|
||||||
using (Assert.Multiple())
|
using (Assert.Multiple())
|
||||||
{
|
{
|
||||||
await Assert.That(() => builder.Add(typeof(TestHandler))).Throws<ArgumentNullException>();
|
await Assert.That(() => builder.Add(typeof(TestHandler))).Throws<ArgumentNullException>();
|
||||||
await Assert.That(() => builder.Add(typeof(TestHandler), ServiceLifetime.Transient)).Throws<ArgumentNullException>();
|
await Assert.That(() => builder.Add(typeof(TestHandler), ServiceLifetime.Transient))
|
||||||
|
.Throws<ArgumentNullException>();
|
||||||
await Assert.That(() => builder.Add([typeof(TestHandler)])).Throws<ArgumentNullException>();
|
await Assert.That(() => builder.Add([typeof(TestHandler)])).Throws<ArgumentNullException>();
|
||||||
await Assert.That(() => builder.Add([typeof(TestHandler)], ServiceLifetime.Transient)).Throws<ArgumentNullException>();
|
await Assert.That(() => builder.Add([typeof(TestHandler)], ServiceLifetime.Transient))
|
||||||
|
.Throws<ArgumentNullException>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_get_an_exception_when_adding_a_nested_request_handler()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var builder = services.AddRequestDispatcher();
|
||||||
|
var assembly = Roslyn.Compile(options =>
|
||||||
|
{
|
||||||
|
options.AddFromEmbeddedSource("BuilderNestedHandler.cs");
|
||||||
|
});
|
||||||
|
var nestedHandlerType = assembly.GetType("Sample.Container+PingHandler", throwOnError: true)!;
|
||||||
|
|
||||||
|
var exception = await Assert.That(() => builder.Add(nestedHandlerType)).Throws<InvalidOperationException>();
|
||||||
|
await Assert.That(exception?.Message).Contains(nestedHandlerType.FullName!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_get_an_exception_when_adding_nested_request_handlers_with_a_lifetime()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var builder = services.AddRequestDispatcher();
|
||||||
|
var assembly = Roslyn.Compile(options =>
|
||||||
|
{
|
||||||
|
options.AddFromEmbeddedSource("BuilderNestedHandler.cs");
|
||||||
|
});
|
||||||
|
var nestedHandlerType = assembly.GetType("Sample.Container+PingHandler", throwOnError: true)!;
|
||||||
|
|
||||||
|
var exception = await Assert
|
||||||
|
.That(() => builder.Add([typeof(TestHandler), nestedHandlerType], ServiceLifetime.Transient))
|
||||||
|
.Throws<InvalidOperationException>();
|
||||||
|
await Assert.That(exception?.Message).Contains(nestedHandlerType.FullName!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,32 +13,15 @@ internal sealed class SearchHandlerInAssemblyTests
|
||||||
[Test]
|
[Test]
|
||||||
public async Task I_can_search_handlers_in_an_assembly()
|
public async Task I_can_search_handlers_in_an_assembly()
|
||||||
{
|
{
|
||||||
const string source =
|
var assembly = Roslyn.Compile(options => options.AddFromEmbeddedSource("SearchTopLevelHandler.cs"));
|
||||||
"""
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Geekeey.Request;
|
|
||||||
|
|
||||||
namespace Sample;
|
|
||||||
|
|
||||||
public sealed class Ping : IScalarRequest<string>
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
|
|
||||||
{
|
|
||||||
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
using var assembly = DynamicTestAssembly.Compile(source);
|
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddRequestDispatcher(builder => builder
|
services.AddRequestDispatcher(builder => builder
|
||||||
.SearchHandlerInAssembly(assembly.Assembly));
|
.SearchHandlerInAssembly(assembly));
|
||||||
await using var provider = services.BuildServiceProvider();
|
await using var provider = services.BuildServiceProvider();
|
||||||
var options = provider.GetRequiredService<IOptions<RequestDispatcherOptions>>().Value;
|
var options = provider.GetRequiredService<IOptions<RequestDispatcherOptions>>().Value;
|
||||||
var requestType = assembly.Assembly.GetType("Sample.Ping", throwOnError: true)!;
|
var requestType = assembly.GetType("Sample.Ping", throwOnError: true)!;
|
||||||
var handlerType = assembly.Assembly.GetType("Sample.PingHandler", throwOnError: true)!;
|
var handlerType = assembly.GetType("Sample.PingHandler", throwOnError: true)!;
|
||||||
var handlerInterface = typeof(IScalarRequestHandler<,>).MakeGenericType(requestType, typeof(string));
|
var handlerInterface = typeof(IScalarRequestHandler<,>).MakeGenericType(requestType, typeof(string));
|
||||||
var handlers = GetRequestHandlers(options, handlerInterface, provider).ToArray();
|
var handlers = GetRequestHandlers(options, handlerInterface, provider).ToArray();
|
||||||
|
|
||||||
|
|
@ -50,29 +33,12 @@ internal sealed class SearchHandlerInAssemblyTests
|
||||||
[Test]
|
[Test]
|
||||||
public async Task I_can_search_handlers_in_an_assembly_with_a_lifetime()
|
public async Task I_can_search_handlers_in_an_assembly_with_a_lifetime()
|
||||||
{
|
{
|
||||||
const string source =
|
var assembly = Roslyn.Compile(options => options.AddFromEmbeddedSource("SearchTopLevelHandler.cs"));
|
||||||
"""
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Geekeey.Request;
|
|
||||||
|
|
||||||
namespace Sample;
|
|
||||||
|
|
||||||
public sealed class Ping : IScalarRequest<string>
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
|
|
||||||
{
|
|
||||||
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
using var assembly = DynamicTestAssembly.Compile(source);
|
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddRequestDispatcher(builder => builder
|
services.AddRequestDispatcher(builder => builder
|
||||||
.SearchHandlerInAssembly(assembly.Assembly, ServiceLifetime.Singleton));
|
.SearchHandlerInAssembly(assembly, ServiceLifetime.Singleton));
|
||||||
var handlerType = assembly.Assembly.GetType("Sample.PingHandler", throwOnError: true)!;
|
var handlerType = assembly.GetType("Sample.PingHandler", throwOnError: true)!;
|
||||||
var descriptor = services.SingleOrDefault(service => service.ServiceType == handlerType);
|
var descriptor = services.SingleOrDefault(service => service.ServiceType == handlerType);
|
||||||
|
|
||||||
using var scope = Assert.Multiple();
|
using var scope = Assert.Multiple();
|
||||||
|
|
@ -83,32 +49,12 @@ internal sealed class SearchHandlerInAssemblyTests
|
||||||
[Test]
|
[Test]
|
||||||
public async Task I_get_an_exception_when_nested_handlers_are_present()
|
public async Task I_get_an_exception_when_nested_handlers_are_present()
|
||||||
{
|
{
|
||||||
const string source =
|
var assembly = Roslyn.Compile(options => options.AddFromEmbeddedSource("SearchNestedHandler.cs"));
|
||||||
"""
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Geekeey.Request;
|
|
||||||
|
|
||||||
namespace Sample;
|
|
||||||
|
|
||||||
public sealed class Ping : IScalarRequest<string>
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class Container
|
|
||||||
{
|
|
||||||
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
|
|
||||||
{
|
|
||||||
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
using var assembly = DynamicTestAssembly.Compile(source);
|
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
var builder = services.AddRequestDispatcher();
|
var builder = services.AddRequestDispatcher();
|
||||||
|
|
||||||
await Assert.That(() => builder.SearchHandlerInAssembly(assembly.Assembly))
|
await Assert.That(() => builder.SearchHandlerInAssembly(assembly))
|
||||||
.Throws<InvalidOperationException>().And.HasMessageContaining("Sample.Container+PingHandler");
|
.Throws<InvalidOperationException>().And.HasMessageContaining("Sample.Container+PingHandler");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
18
src/request.tests/_fixtures/roslyn/BuilderNestedHandler.cs
Normal file
18
src/request.tests/_fixtures/roslyn/BuilderNestedHandler.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
using Geekeey.Request;
|
||||||
|
|
||||||
|
namespace Sample;
|
||||||
|
|
||||||
|
public sealed class Ping : IScalarRequest<string>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Container
|
||||||
|
{
|
||||||
|
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
|
||||||
|
{
|
||||||
|
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Geekeey.Request;
|
||||||
|
|
||||||
|
namespace Sample;
|
||||||
|
|
||||||
|
public sealed class Ping : IScalarRequest<string>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Container
|
||||||
|
{
|
||||||
|
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
|
||||||
|
{
|
||||||
|
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
namespace Sample;
|
||||||
|
|
||||||
|
public sealed class Marker
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Geekeey.Request;
|
||||||
|
|
||||||
|
namespace Sample;
|
||||||
|
|
||||||
|
public sealed class Ping : IScalarRequest<string>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
|
||||||
|
{
|
||||||
|
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GenericPing<T> : IScalarRequest<string>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OpenPingHandler<T> : IScalarRequestHandler<GenericPing<T>, string>
|
||||||
|
{
|
||||||
|
public Task<string> HandleAsync(GenericPing<T> request, CancellationToken cancellationToken) => Task.FromResult("pong");
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PingBehavior : IScalarRequestBehavior<Ping, string>
|
||||||
|
{
|
||||||
|
public Task<string> HandleAsync(Ping request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken) => next(request, cancellationToken);
|
||||||
|
}
|
||||||
18
src/request.tests/_fixtures/roslyn/SearchNestedHandler.cs
Normal file
18
src/request.tests/_fixtures/roslyn/SearchNestedHandler.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
using Geekeey.Request;
|
||||||
|
|
||||||
|
namespace Sample;
|
||||||
|
|
||||||
|
public sealed class Ping : IScalarRequest<string>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Container
|
||||||
|
{
|
||||||
|
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
|
||||||
|
{
|
||||||
|
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/request.tests/_fixtures/roslyn/SearchTopLevelHandler.cs
Normal file
15
src/request.tests/_fixtures/roslyn/SearchTopLevelHandler.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
using Geekeey.Request;
|
||||||
|
|
||||||
|
namespace Sample;
|
||||||
|
|
||||||
|
public sealed class Ping : IScalarRequest<string>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
|
||||||
|
{
|
||||||
|
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
|
||||||
|
}
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
// Copyright (c) The Geekeey Authors
|
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Runtime.Loader;
|
|
||||||
|
|
||||||
using Microsoft.CodeAnalysis;
|
|
||||||
using Microsoft.CodeAnalysis.CSharp;
|
|
||||||
|
|
||||||
namespace Geekeey.Request.Tests;
|
|
||||||
|
|
||||||
internal sealed class DynamicTestAssembly : IDisposable
|
|
||||||
{
|
|
||||||
private readonly WeakReference _context;
|
|
||||||
|
|
||||||
private DynamicTestAssembly(Assembly assembly, CollectibleAssemblyLoadContext context)
|
|
||||||
{
|
|
||||||
Assembly = assembly;
|
|
||||||
_context = new WeakReference(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Assembly Assembly { get; }
|
|
||||||
|
|
||||||
public static DynamicTestAssembly Compile(string source)
|
|
||||||
{
|
|
||||||
var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
|
|
||||||
.Split(Path.PathSeparator)
|
|
||||||
.Concat([typeof(IRequestDispatcherBuilder).Assembly.Location])
|
|
||||||
.Distinct(StringComparer.Ordinal)
|
|
||||||
.Select(static path => MetadataReference.CreateFromFile(path));
|
|
||||||
|
|
||||||
var syntax = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default
|
|
||||||
.WithLanguageVersion(LanguageVersion.Preview));
|
|
||||||
var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary);
|
|
||||||
var compilation = CSharpCompilation.Create($"Dynamic_{Guid.NewGuid():N}", [syntax], references, options);
|
|
||||||
|
|
||||||
using var stream = new MemoryStream();
|
|
||||||
|
|
||||||
if (compilation.Emit(stream) is { Success: false, Diagnostics: var diagnostics })
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(string.Join(Environment.NewLine,
|
|
||||||
diagnostics.Select(static diagnostic => diagnostic.ToString())));
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.Seek(0, SeekOrigin.Begin);
|
|
||||||
|
|
||||||
var context = new CollectibleAssemblyLoadContext();
|
|
||||||
return new DynamicTestAssembly(context.LoadFromStream(stream), context);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_context.Target is AssemblyLoadContext context)
|
|
||||||
{
|
|
||||||
context.Unload();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var iteration = 0; iteration < 10 && _context.IsAlive; iteration++)
|
|
||||||
{
|
|
||||||
GC.Collect();
|
|
||||||
GC.WaitForPendingFinalizers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class CollectibleAssemblyLoadContext()
|
|
||||||
: AssemblyLoadContext(nameof(DynamicTestAssembly), isCollectible: true);
|
|
||||||
}
|
|
||||||
177
src/request.tests/_helpers/Roslyn.cs
Normal file
177
src/request.tests/_helpers/Roslyn.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.Diagnostics;
|
||||||
|
|
||||||
|
using Assembly = System.Reflection.Assembly;
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Tests;
|
||||||
|
|
||||||
|
internal static class Roslyn
|
||||||
|
{
|
||||||
|
private static CSharpCompilation CreateCompilation(CompilerOptions options)
|
||||||
|
{
|
||||||
|
if (options.SourceCode.Count is 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("At least one source file must be configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var syntax = options.SourceCode
|
||||||
|
.Select(static sourceCode => CSharpSyntaxTree.ParseText(sourceCode, CSharpParseOptions.Default
|
||||||
|
.WithLanguageVersion(LanguageVersion.Preview)))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
|
||||||
|
.Split(Path.PathSeparator)
|
||||||
|
.Concat(options.References.Select(static assembly => assembly.Location))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.Select(static path => MetadataReference.CreateFromFile(path));
|
||||||
|
|
||||||
|
var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary);
|
||||||
|
return CSharpCompilation.Create(options.AssemblyName, syntax, references, compilationOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CompilerOptions
|
||||||
|
{
|
||||||
|
public string AssemblyName { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
public ICollection<Assembly> References { get; } = new HashSet<Assembly>();
|
||||||
|
public IDictionary<string, string?> Properties { get; } = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||||
|
public ICollection<string> SourceCode { get; } = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public void AddFromEmbeddedSource(string fileName)
|
||||||
|
{
|
||||||
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
var relativePath = $"_fixtures/roslyn/{fileName}";
|
||||||
|
var suffix = relativePath.Replace(Path.DirectorySeparatorChar, '.')
|
||||||
|
.Replace(Path.AltDirectorySeparatorChar, '.');
|
||||||
|
var resourceName = assembly.GetManifestResourceNames()
|
||||||
|
.SingleOrDefault(name => name.EndsWith(suffix, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
if (resourceName is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Embedded fixture '{relativePath}' was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assembly.GetManifestResourceStream(resourceName) is not { } stream)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Embedded fixture '{resourceName}' could not be opened.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
SourceCode.Add(reader.ReadToEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Assembly Compile(Action<CompilerOptions>? configure = null)
|
||||||
|
{
|
||||||
|
var options = new CompilerOptions();
|
||||||
|
configure?.Invoke(options);
|
||||||
|
var compilation = CreateCompilation(options);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
|
||||||
|
if (compilation.Emit(stream) is { Success: false, Diagnostics: var diagnostics })
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(string.Join(Environment.NewLine,
|
||||||
|
diagnostics.Select(static diagnostic => diagnostic.ToString())));
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.Seek(0, SeekOrigin.Begin);
|
||||||
|
|
||||||
|
return Assembly.Load(stream.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GeneratorDriverRunResult SourceGenerator<TGenerator>(Action<CompilerOptions>? configure = null)
|
||||||
|
where TGenerator : IIncrementalGenerator, new()
|
||||||
|
{
|
||||||
|
var options = new CompilerOptions();
|
||||||
|
configure?.Invoke(options);
|
||||||
|
var compilation = CreateCompilation(options);
|
||||||
|
|
||||||
|
GeneratorDriver driver = CSharpGeneratorDriver.Create([new TGenerator().AsSourceGenerator()],
|
||||||
|
parseOptions: (CSharpParseOptions)compilation.SyntaxTrees.First().Options,
|
||||||
|
optionsProvider: new TestAnalyzerConfigOptionsProvider(
|
||||||
|
new Dictionary<string, string?>(options.Properties, StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["build_property.AssemblyName"] = options.AssemblyName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _);
|
||||||
|
return driver.GetRunResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
extension(GeneratorDriverRunResult result)
|
||||||
|
{
|
||||||
|
public string? File([StringSyntax(StringSyntaxAttribute.Regex)] string name)
|
||||||
|
{
|
||||||
|
foreach (var value in result.Results)
|
||||||
|
{
|
||||||
|
foreach (var source in value.GeneratedSources)
|
||||||
|
{
|
||||||
|
if (Regex.IsMatch(source.HintName, name))
|
||||||
|
{
|
||||||
|
return source.SourceText.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider
|
||||||
|
{
|
||||||
|
public TestAnalyzerConfigOptionsProvider(IDictionary<string, string?> values)
|
||||||
|
{
|
||||||
|
GlobalOptions = new TestAnalyzerConfigOptions(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override AnalyzerConfigOptions GlobalOptions { get; }
|
||||||
|
|
||||||
|
public override AnalyzerConfigOptions GetOptions(SyntaxTree tree)
|
||||||
|
{
|
||||||
|
return GlobalOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override AnalyzerConfigOptions GetOptions(AdditionalText textFile)
|
||||||
|
{
|
||||||
|
return GlobalOptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestAnalyzerConfigOptions : AnalyzerConfigOptions
|
||||||
|
{
|
||||||
|
private readonly ImmutableDictionary<string, string?> _values;
|
||||||
|
|
||||||
|
public TestAnalyzerConfigOptions(IDictionary<string, string?> values)
|
||||||
|
{
|
||||||
|
_values = values.ToImmutableDictionary(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool TryGetValue(string key, out string value)
|
||||||
|
{
|
||||||
|
if (_values.TryGetValue(key, out var existingValue) && existingValue is not null)
|
||||||
|
{
|
||||||
|
value = existingValue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.StartsWith("build_property.", StringComparison.Ordinal) &&
|
||||||
|
_values.TryGetValue(key["build_property.".Length..], out existingValue) &&
|
||||||
|
existingValue is not null)
|
||||||
|
{
|
||||||
|
value = existingValue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = string.Empty;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,4 +33,16 @@
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Reference the source generator just to ensure it's built but don't reference its output -->
|
||||||
|
<ProjectReference Include="..\request.generator\Geekeey.Request.Generator.csproj" OutputItemType="SourceGeneratorAssemblyPath" ReferenceOutputAssembly="false" Private="true" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="IncludeSourceGeneratorInPackage" AfterTargets="ResolveProjectReferences">
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="@(SourceGeneratorAssemblyPath)" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
|
||||||
|
<None Include="..\request.generator\Geekeey.Request.Generator.props" Pack="true" PackagePath="build/$(PackageId).props" Visible="false" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -61,3 +61,30 @@ public class ScalarBehavior : IScalarRequestBehavior<ScalarRequest, string>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Compile-time registration
|
||||||
|
|
||||||
|
Projects that directly reference `Geekeey.Request` also get generated registration methods in the
|
||||||
|
`Geekeey.Request` namespace:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
collection.AddRequestDispatcher(builder => builder
|
||||||
|
.AddExampleProject()
|
||||||
|
.Add(typeof(ScalarBehavior)));
|
||||||
|
|
||||||
|
collection.AddRequestDispatcher(builder => builder
|
||||||
|
.AddExampleProject(ServiceLifetime.Scoped)
|
||||||
|
.Add(typeof(ScalarBehavior)));
|
||||||
|
```
|
||||||
|
|
||||||
|
- generation is enabled by default
|
||||||
|
- disable it with `<CompileTimeRequestDispatchHandlerRegistration>false</CompileTimeRequestDispatchHandlerRegistration>`
|
||||||
|
- rename the generated `Add<Name>(...)` methods with `CompileTimeRequestDispatchHandlerName`
|
||||||
|
- only request handlers are generated; behaviors still need normal registration
|
||||||
|
- nested request handlers are rejected during `Add(...)` registration, including `SearchHandlerInAssembly(...)` and by the source generator
|
||||||
|
- use one registration style per assembly: generated methods or `SearchHandlerInAssembly(...)`
|
||||||
|
|
||||||
|
## Behaviour of the Handlers
|
||||||
|
|
||||||
|
Handlers are resolved from either the DI container or are created on the fly but can receive arguments from the DI
|
||||||
|
container when being constructed. The same also applied for the request pipeline behaviours.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue