Skip to content

Commit

Permalink
Allow operations to be intercepted using attributes (#573)
Browse files Browse the repository at this point in the history
  • Loading branch information
Kaliumhexacyanoferrat authored Dec 5, 2024
1 parent 5365be3 commit 041949a
Show file tree
Hide file tree
Showing 7 changed files with 249 additions and 7 deletions.
39 changes: 39 additions & 0 deletions Modules/Reflection/IOperationInterceptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using GenHTTP.Api.Protocol;

using GenHTTP.Modules.Reflection.Operations;

namespace GenHTTP.Modules.Reflection;

/// <summary>
/// A result returned by an interceptor.
/// </summary>
/// <param name="payload">The payload of the response</param>
public sealed class InterceptionResult(object? payload = null) : Result<object?>(payload);

/// <summary>
/// A piece of logic to be executed before the
/// actual method invocation. Triggered by methods
/// annotated with the <see cref="InterceptWithAttribute{T}" />
/// attribute.
/// </summary>
public interface IOperationInterceptor
{

/// <summary>
/// Invoked after the instance has been created to configure
/// the interceptor with the originally used attribute. Allows
/// the interceptor to read configuration data as needed.
/// </summary>
/// <param name="attribute">The original attribute instance on the method definition</param>
void Configure(object attribute);

/// <summary>
/// Invoked on every operation call by the client.
/// </summary>
/// <param name="request">The request which caused this invocation</param>
/// <param name="operation">The currently executed operation</param>
/// <param name="arguments">The operation arguments as derived by the framework</param>
/// <returns>If a result is returned, it will be converted into a response and the method is not invoked</returns>
ValueTask<InterceptionResult?> InterceptAsync(IRequest request, Operation operation, IReadOnlyDictionary<string, object?> arguments);

}
16 changes: 16 additions & 0 deletions Modules/Reflection/InterceptWithAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace GenHTTP.Modules.Reflection;

/// <summary>
/// When annotated on a service method, the method handler
/// will create an instance of T and invoke it before
/// the actual method invocation.
/// </summary>
/// <typeparam name="T">The type of interceptor to be used</typeparam>
/// <remarks>
/// Allows to implement concerns on operation level such as authorization.
/// </remarks>
[AttributeUsage(AttributeTargets.Method)]
public class InterceptWithAttribute<T> : Attribute where T : IOperationInterceptor, new()
{

}
35 changes: 30 additions & 5 deletions Modules/Reflection/MethodHandler.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Text.RegularExpressions;

using GenHTTP.Api.Content;
using GenHTTP.Api.Protocol;
using GenHTTP.Api.Routing;

using GenHTTP.Modules.Conversion.Serializers.Forms;
using GenHTTP.Modules.Reflection.Operations;

Expand All @@ -19,7 +21,7 @@ namespace GenHTTP.Modules.Reflection;
/// </remarks>
public sealed class MethodHandler : IHandler
{
private static readonly object?[] NoArguments = [];
private static readonly Dictionary<string, object?> NoArguments = [];

#region Get-/Setters

Expand Down Expand Up @@ -63,12 +65,19 @@ public MethodHandler(Operation operation, object instance, IMethodConfiguration
{
var arguments = await GetArguments(request);

var result = Invoke(arguments);
var interception = await InterceptAsync(request, arguments);

if (interception is not null)
{
return interception;
}

var result = Invoke(arguments.Values.ToArray());

return await ResponseProvider.GetResponseAsync(request, this, Operation, await UnwrapAsync(result), null);
}

private async ValueTask<object?[]> GetArguments(IRequest request)
private async ValueTask<IReadOnlyDictionary<string, object?>> GetArguments(IRequest request)
{
var targetParameters = Operation.Method.GetParameters();

Expand All @@ -85,7 +94,7 @@ public MethodHandler(Operation operation, object instance, IMethodConfiguration

if (targetParameters.Length > 0)
{
var targetArguments = new object?[targetParameters.Length];
var targetArguments = new Dictionary<string, object?>(targetParameters.Length);

var bodyArguments = FormFormat.GetContent(request);

Expand All @@ -97,7 +106,7 @@ public MethodHandler(Operation operation, object instance, IMethodConfiguration
{
if (Operation.Arguments.TryGetValue(par.Name, out var arg))
{
targetArguments[i] = arg.Source switch
targetArguments[arg.Name] = arg.Source switch
{
OperationArgumentSource.Injected => ArgumentProvider.GetInjectedArgument(request, this, arg, Registry),
OperationArgumentSource.Path => ArgumentProvider.GetPathArgument(arg, sourceParameters, Registry),
Expand All @@ -119,6 +128,22 @@ public MethodHandler(Operation operation, object instance, IMethodConfiguration

public ValueTask PrepareAsync() => ValueTask.CompletedTask;

private async ValueTask<IResponse?> InterceptAsync(IRequest request, IReadOnlyDictionary<string, object?> arguments)
{
if (Operation.Interceptors.Count > 0)
{
foreach (var interceptor in Operation.Interceptors)
{
if (await interceptor.InterceptAsync(request, Operation, arguments) is IResultWrapper result)
{
return await ResponseProvider.GetResponseAsync(request, this, Operation, result.Payload, (r) => result.Apply(r));
}
}
}

return null;
}

private object? Invoke(object?[] arguments)
{
try
Expand Down
46 changes: 46 additions & 0 deletions Modules/Reflection/Operations/InterceptorAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.Reflection;

namespace GenHTTP.Modules.Reflection.Operations;

public static class InterceptorAnalyzer
{

public static IReadOnlyList<IOperationInterceptor> GetInterceptors(MethodInfo method)
{
var interceptors = new List<IOperationInterceptor>();

foreach (var attribute in method.GetCustomAttributes(typeof(InterceptWithAttribute<>), true))
{
var interceptorType = FindInterceptorType(attribute.GetType());

if (interceptorType != null)
{
if (Activator.CreateInstance(interceptorType) is IOperationInterceptor interceptor)
{
interceptor.Configure(attribute);
interceptors.Add(interceptor);
}
}
}

return interceptors;
}

private static Type? FindInterceptorType(Type attributeType)
{
var current = attributeType;

while (current != null)
{
if (current.IsGenericType && current.GetGenericTypeDefinition() == typeof(InterceptWithAttribute<>))
{
return current.GetGenericArguments()[0];
}

current = current.BaseType;
}

return null;
}

}
8 changes: 7 additions & 1 deletion Modules/Reflection/Operations/Operation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public sealed class Operation
/// </summary>
public IReadOnlyDictionary<string, OperationArgument> Arguments { get; }

/// <summary>
/// The interceptors to be executed for this operation.
/// </summary>
public IReadOnlyList<IOperationInterceptor> Interceptors { get; }

/// <summary>
/// The result generated by this operation.
/// </summary>
Expand All @@ -31,12 +36,13 @@ public sealed class Operation

#region Initialization

public Operation(MethodInfo method, OperationPath path, OperationResult result, IReadOnlyDictionary<string, OperationArgument> arguments)
public Operation(MethodInfo method, OperationPath path, OperationResult result, IReadOnlyDictionary<string, OperationArgument> arguments, IReadOnlyList<IOperationInterceptor> interceptors)
{
Method = method;
Path = path;
Result = result;
Arguments = arguments;
Interceptors = interceptors;
}

#endregion
Expand Down
4 changes: 3 additions & 1 deletion Modules/Reflection/Operations/OperationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ public static Operation Create(string? definition, MethodInfo method, MethodRegi

var result = SignatureAnalyzer.GetResult(method, registry);

return new Operation(method, path, result, arguments);
var interceptors = InterceptorAnalyzer.GetInterceptors(method);

return new Operation(method, path, result, arguments, interceptors);
}

private static bool CheckWildcardRoute(Type returnType)
Expand Down
108 changes: 108 additions & 0 deletions Testing/Acceptance/Modules/Reflection/InterceptionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Net;
using GenHTTP.Api.Content;
using GenHTTP.Api.Protocol;

using GenHTTP.Modules.Functional;
using GenHTTP.Modules.Reflection;
using GenHTTP.Modules.Reflection.Operations;

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace GenHTTP.Testing.Acceptance.Modules.Reflection;

[TestClass]
public class InterceptionTests
{

#region Supporting data structures

[AttributeUsage(AttributeTargets.Method)]
public class MyAttribute(string command) : InterceptWithAttribute<MyInterceptor>
{

public string Command => command;

}

public class MyInterceptor : IOperationInterceptor
{

public string? Command { get; private set; }

public void Configure(object attribute)
{
if (attribute is MyAttribute my)
{
Command = my.Command;
}
}

public ValueTask<InterceptionResult?> InterceptAsync(IRequest request, Operation operation, IReadOnlyDictionary<string, object?> arguments)
{
if (Command == "intercept")
{
var result = new InterceptionResult("Nah");
result.Status(ResponseStatus.Forbidden);

return new(result);
}

if (Command == "throw")
{
throw new ProviderException(ResponseStatus.Forbidden, "Nah");
}

return default;
}

}

#endregion

#region Tests

[TestMethod]
public async Task TestInterception()
{
var app = Inline.Create().Get([My("intercept")] () => 42);

await using var host = await TestHost.RunAsync(app);

using var response = await host.GetResponseAsync();

await response.AssertStatusAsync(HttpStatusCode.Forbidden);

Assert.AreEqual("Nah", await response.GetContentAsync());
}

[TestMethod]
public async Task TestPassThrough()
{
var app = Inline.Create().Get([My("pass")] () => 42);

await using var host = await TestHost.RunAsync(app);

using var response = await host.GetResponseAsync();

await response.AssertStatusAsync(HttpStatusCode.OK);

Assert.AreEqual("42", await response.GetContentAsync());
}

[TestMethod]
public async Task TestException()
{
var app = Inline.Create().Get([My("throw")] () => 42);

await using var host = await TestHost.RunAsync(app);

using var response = await host.GetResponseAsync();

await response.AssertStatusAsync(HttpStatusCode.Forbidden);

AssertX.Contains("Nah", await response.GetContentAsync());
}

#endregion

}

0 comments on commit 041949a

Please sign in to comment.