├── tests ├── Reaper.TestWeb │ ├── .gitignore │ ├── GlobalUsings.cs │ ├── ReaperEndpoint.http │ ├── appsettings.json │ ├── ReaperEndpointRX.http │ ├── Endpoints │ │ ├── ReaperEndpoint │ │ │ ├── NoneEndpoint.cs │ │ │ ├── StringWriteEndpoint.cs │ │ │ ├── ScopedIncrementorEndpoint.cs │ │ │ ├── SingletonIncrementorEndpoint.cs │ │ │ └── StatusCodeEndpoints.cs │ │ ├── ReaperEndpointXR │ │ │ ├── StringEndpoint.cs │ │ │ ├── ServiceScopedStringEndpoint.cs │ │ │ └── JsonEndpoint.cs │ │ ├── ReaperEndpointRX │ │ │ ├── ReflectorWriteEndpoint.cs │ │ │ └── ValidatorWriteEndpoint.cs │ │ └── ReaperEndpoint`ReqResp │ │ │ ├── ReflectorEndpoint.cs │ │ │ ├── ConstrainedRouteEndpoint.cs │ │ │ ├── FromSourcesEndpoint.cs │ │ │ └── OptionalFromSourcesEndpoint.cs │ ├── HelloWorldProvider.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Dockerfile │ ├── Program.cs │ └── Reaper.TestWeb.csproj ├── UnitTests │ ├── GlobalUsings.cs │ ├── Resources │ │ └── EndpointHarness.cs │ ├── StatusCodeHelperTests.cs │ └── UnitTests.csproj └── IntegrationTests │ ├── GlobalUsings.cs │ ├── AotTests │ ├── AotCollection.cs │ ├── ReaperEndpointTests.cs │ ├── ReaperEndpointRRTests.cs │ ├── ReaperEndpointRXTests.cs │ ├── ReaperEndpointXRTests.cs │ └── AotTestFixture.cs │ ├── WafTests │ ├── WafCollection.cs │ ├── ReaperEndpointTests.cs │ ├── ReaperEndpointRRTests.cs │ ├── ReaperEndpointRXTests.cs │ ├── ReaperEndpointXRTests.cs │ └── WafTextFixture.cs │ ├── IntegrationTests.csproj │ └── Tests │ ├── ReaperEndpointXRTests.cs │ ├── ReaperEndpointRXTests.cs │ ├── ReaperEndpointTests.cs │ └── ReaperEndpointRRTests.cs ├── .gitignore ├── src ├── Reaper │ ├── Handler │ │ └── ReaperEndpointHandler.cs │ ├── ReaperOptions.cs │ ├── Handlers │ │ ├── IValidationFailureHandler.cs │ │ └── DefaultValidationFailureHandler.cs │ ├── Validation │ │ ├── IReaperValidationContext.cs │ │ ├── RequestValidationFailureType.cs │ │ └── Context │ │ │ └── ReaperValidationContext.cs │ ├── Meta.cs │ ├── Context │ │ ├── IReaperExecutionContextProvider.cs │ │ ├── IReaperExecutionContext.cs │ │ ├── ReaperExecutionContextProvider.cs │ │ └── ReaperExecutionContext.cs │ ├── Attributes │ │ ├── ReaperScopedAttribute.cs │ │ ├── ReaperRouteAttribute.cs │ │ └── ReaperForceHandlerAttribute.cs │ ├── Middleware │ │ ├── ReaperHandlerExecutorMiddleware.cs │ │ └── ReaperExecutionContextMiddleware.cs │ ├── WebApplicationBuilderExtensions.cs │ ├── WebApplicationExtensions.cs │ ├── Response │ │ ├── ReaperResponse.cs │ │ └── ReaperEndpointExtensions.cs │ ├── EndpointMapper │ │ ├── ReaperEndpointDefinition.cs │ │ └── ReaperMapper.cs │ ├── Reaper.csproj │ ├── RequestDelegateSupport │ │ ├── ResponseHelpers.cs │ │ ├── RequestHelpers.cs │ │ ├── JsonBodyResolver.cs │ │ └── LogOrThrowExceptionHelper.cs │ └── ReaperEndpoint.cs ├── Reaper.Validation │ ├── RequestValidator.cs │ ├── Context │ │ └── FluentValidationContext.cs │ ├── Responses │ │ └── ValidationProblemDetails.cs │ ├── WebApplicationBuilderExtensions.cs │ ├── ReaperValidationJsonContext.cs │ ├── Reaper.Validation.csproj │ └── Handlers │ │ └── FluentValidationFailureHandler.cs └── Reaper.SourceGenerator │ ├── Resources │ ├── System.Text.Json.SourceGeneration.Reaper.dll │ └── System.Text.Json.SourceGeneration.Reaper.txt │ ├── Properties │ └── launchSettings.json │ ├── ReaperEndpoints │ ├── ReaperValidatorDefinition.cs │ ├── ReaperDefinition.cs │ └── ExtensionMethods.cs │ ├── ServicesInterceptor │ ├── ExtensionMethods.cs │ └── ServicesInterceptorGenerator.cs │ ├── MapperInterceptor │ └── ExtensionMethods.cs │ ├── IsExternalInit.cs │ ├── RoslynHelpers │ ├── ExtensionMethods.cs │ └── WellKnownTypes.cs │ ├── JsonContextGenerator │ ├── JsonContextGenerator.cs │ └── JsonSourceGenerationSupport.cs │ ├── Reaper.SourceGenerator.csproj │ ├── Internal │ ├── CodeWriter.cs │ └── GeneratorStatics.cs │ └── EndpointMapper.cs ├── benchmarks ├── Benchmarker │ ├── wrk.dockerfile │ ├── results-first-net9-run.csv │ ├── results-last-net8-run.csv │ ├── Paths.cs │ ├── Benchmarker.csproj │ ├── DockerStats.cs │ ├── README.md │ ├── Program.cs │ └── TestRunner.cs └── BenchmarkWeb │ ├── appsettings.json │ ├── Dtos │ ├── SampleRequest.cs │ └── SampleResponse.cs │ ├── Carter │ ├── CarterModule.cs │ ├── TypicalCarterModule.cs │ └── AnotherTypicalCarterModule.cs │ ├── Controllers │ ├── TestController.cs │ ├── TypicalController.cs │ └── AnotherTypicalController.cs │ ├── Reaper │ ├── TestEndpoint.cs │ ├── TypicalEndpoints.cs │ └── AnotherTypicalEndpoints.cs │ ├── carter.dockerfile │ ├── controllers.dockerfile │ ├── minimal.dockerfile │ ├── fastendpoints.dockerfile │ ├── FastEndpoints │ ├── TestEndpoint.cs │ ├── TypicalEndpoints.cs │ └── AnotherTypicalEndpoints.cs │ ├── minimal-aot.dockerfile │ ├── reaper.dockerfile │ ├── README.md │ ├── Services │ └── GetMeAStringService.cs │ ├── reaper-aot.dockerfile │ ├── Properties │ └── launchSettings.json │ ├── BenchmarkWeb.csproj │ └── Program.cs ├── .idea └── .idea.Reaper │ └── .idea │ ├── vcs.xml │ ├── indexLayout.xml │ └── .gitignore ├── .dockerignore ├── LICENSE ├── .github └── workflows │ └── main.yml ├── Reaper.sln.DotSettings.user ├── Reaper.sln └── README.md /tests/Reaper.TestWeb/.gitignore: -------------------------------------------------------------------------------- 1 | Generated/ -------------------------------------------------------------------------------- /tests/UnitTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /tests/IntegrationTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Reaper.Attributes; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | /packages/ 4 | riderModule.iml 5 | /_ReSharper.Caches/ -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/ReaperEndpoint.http: -------------------------------------------------------------------------------- 1 | @endpoint = http://localhost:5123 2 | 3 | GET {{endpoint}}/re/j200 4 | -------------------------------------------------------------------------------- /src/Reaper/Handler/ReaperEndpointHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.Handler; 2 | 3 | public class ReaperEndpointHandler 4 | { 5 | 6 | } -------------------------------------------------------------------------------- /src/Reaper/ReaperOptions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Reaper.Handlers; 3 | 4 | namespace Reaper; 5 | 6 | public class ReaperOptions 7 | { 8 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Trace" 5 | } 6 | }, 7 | "AllowedHosts": "*" 8 | } 9 | -------------------------------------------------------------------------------- /src/Reaper/Handlers/IValidationFailureHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.Handlers; 2 | 3 | public interface IValidationFailureHandler 4 | { 5 | Task HandleValidationFailure(); 6 | } -------------------------------------------------------------------------------- /benchmarks/Benchmarker/wrk.dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | 3 | ARG DEBIAN_FRONTEND=noninteractive 4 | RUN apt-get -yqq update>/dev/null && \ 5 | apt-get -yqq install >/dev/null curl wrk -------------------------------------------------------------------------------- /src/Reaper.Validation/RequestValidator.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace Reaper.Validation; 4 | 5 | public abstract class RequestValidator : AbstractValidator 6 | { 7 | } -------------------------------------------------------------------------------- /src/Reaper/Validation/IReaperValidationContext.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.Validation; 2 | 3 | public interface IReaperValidationContext 4 | { 5 | RequestValidationFailureType FailureType { get; set; } 6 | } -------------------------------------------------------------------------------- /tests/IntegrationTests/AotTests/AotCollection.cs: -------------------------------------------------------------------------------- 1 | namespace IntegrationTests.AotTests; 2 | 3 | [CollectionDefinition("AOT")] 4 | public class AotCollection : ICollectionFixture 5 | { 6 | 7 | } -------------------------------------------------------------------------------- /tests/IntegrationTests/WafTests/WafCollection.cs: -------------------------------------------------------------------------------- 1 | namespace IntegrationTests.WafTests; 2 | 3 | [CollectionDefinition("WAF")] 4 | public class WafCollection : ICollectionFixture 5 | { 6 | 7 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/Resources/System.Text.Json.SourceGeneration.Reaper.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Reaper-Net/Reaper/HEAD/src/Reaper.SourceGenerator/Resources/System.Text.Json.SourceGeneration.Reaper.dll -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /src/Reaper/Validation/RequestValidationFailureType.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.Validation; 2 | 3 | public enum RequestValidationFailureType 4 | { 5 | None, 6 | BodyRequiredNotProvided, 7 | UserDefinedValidationFailure 8 | } -------------------------------------------------------------------------------- /tests/IntegrationTests/AotTests/ReaperEndpointTests.cs: -------------------------------------------------------------------------------- 1 | namespace IntegrationTests.AotTests; 2 | 3 | [Collection("AOT")] 4 | public class ReaperEndpointTests(AotTestFixture fixture) : Tests.ReaperEndpointTests(fixture.Client) { } -------------------------------------------------------------------------------- /tests/IntegrationTests/WafTests/ReaperEndpointTests.cs: -------------------------------------------------------------------------------- 1 | namespace IntegrationTests.WafTests; 2 | 3 | [Collection("WAF")] 4 | public class ReaperEndpointTests(WafTextFixture fixture) : Tests.ReaperEndpointTests(fixture.Client) { } -------------------------------------------------------------------------------- /.idea/.idea.Reaper/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/Reaper/Validation/Context/ReaperValidationContext.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.Validation.Context; 2 | 3 | public class ReaperValidationContext : IReaperValidationContext 4 | { 5 | public RequestValidationFailureType FailureType { get; set; } 6 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/ReaperEndpointRX.http: -------------------------------------------------------------------------------- 1 | @endpoint = http://localhost:55009 2 | 3 | POST {{endpoint}}/rerx/validator 4 | Content-Type: application/json 5 | 6 | { 7 | "message": null 8 | } 9 | 10 | ### 11 | GET {{endpoint}}/re/string -------------------------------------------------------------------------------- /src/Reaper/Meta.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper; 2 | 3 | public static class HttpVerbs 4 | { 5 | public const string Delete = "DELETE"; 6 | public const string Get = "GET"; 7 | public const string Post = "POST"; 8 | public const string Put = "PUT"; 9 | } -------------------------------------------------------------------------------- /.idea/.idea.Reaper/.idea/indexLayout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Reaper/Context/IReaperExecutionContextProvider.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.Context; 2 | 3 | public interface IReaperExecutionContextProvider 4 | { 5 | public IReaperExecutionContext Context { get; } 6 | 7 | internal void SetContext(IReaperExecutionContext context); 8 | } -------------------------------------------------------------------------------- /tests/IntegrationTests/AotTests/ReaperEndpointRRTests.cs: -------------------------------------------------------------------------------- 1 | namespace IntegrationTests.AotTests; 2 | 3 | [Collection("AOT")] 4 | // ReSharper disable once InconsistentNaming 5 | public class ReaperEndpointRRTests(AotTestFixture fixture) : Tests.ReaperEndpointRRTests(fixture.Client) { } -------------------------------------------------------------------------------- /tests/IntegrationTests/AotTests/ReaperEndpointRXTests.cs: -------------------------------------------------------------------------------- 1 | namespace IntegrationTests.AotTests; 2 | 3 | [Collection("AOT")] 4 | // ReSharper disable once InconsistentNaming 5 | public class ReaperEndpointRXTests(AotTestFixture fixture) : Tests.ReaperEndpointRXTests(fixture.Client) { } -------------------------------------------------------------------------------- /tests/IntegrationTests/AotTests/ReaperEndpointXRTests.cs: -------------------------------------------------------------------------------- 1 | namespace IntegrationTests.AotTests; 2 | 3 | [Collection("AOT")] 4 | // ReSharper disable once InconsistentNaming 5 | public class ReaperEndpointXRTests(AotTestFixture fixture) : Tests.ReaperEndpointXRTests(fixture.Client) { } -------------------------------------------------------------------------------- /tests/IntegrationTests/WafTests/ReaperEndpointRRTests.cs: -------------------------------------------------------------------------------- 1 | namespace IntegrationTests.WafTests; 2 | 3 | [Collection("WAF")] 4 | // ReSharper disable once InconsistentNaming 5 | public class ReaperEndpointRRTests(WafTextFixture fixture) : Tests.ReaperEndpointRRTests(fixture.Client) { } -------------------------------------------------------------------------------- /tests/IntegrationTests/WafTests/ReaperEndpointRXTests.cs: -------------------------------------------------------------------------------- 1 | namespace IntegrationTests.WafTests; 2 | 3 | [Collection("WAF")] 4 | // ReSharper disable once InconsistentNaming 5 | public class ReaperEndpointRXTests(WafTextFixture fixture) : Tests.ReaperEndpointRXTests(fixture.Client) { } -------------------------------------------------------------------------------- /tests/IntegrationTests/WafTests/ReaperEndpointXRTests.cs: -------------------------------------------------------------------------------- 1 | namespace IntegrationTests.WafTests; 2 | 3 | [Collection("WAF")] 4 | // ReSharper disable once InconsistentNaming 5 | public class ReaperEndpointXRTests(WafTextFixture fixture) : Tests.ReaperEndpointXRTests(fixture.Client) { } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Dtos/SampleRequest.cs: -------------------------------------------------------------------------------- 1 | namespace BenchmarkWeb.Dtos; 2 | 3 | #nullable disable 4 | 5 | public class SampleRequest 6 | { 7 | public string Input { get; set; } 8 | 9 | public string SomeOtherInput { get; set; } 10 | 11 | public bool SomeBool { get; set; } 12 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "Generators": { 5 | "commandName": "DebugRoslynComponent", 6 | "targetProject": "../../tests/Reaper.TestWeb/Reaper.TestWeb.csproj" 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/ReaperEndpoints/ReaperValidatorDefinition.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | 3 | namespace Reaper.SourceGenerator.ReaperEndpoints; 4 | 5 | public record ReaperValidatorDefinition 6 | { 7 | public INamedTypeSymbol? Validator { get; init; } 8 | public ITypeSymbol? RequestSymbol { get; init; } 9 | } -------------------------------------------------------------------------------- /src/Reaper/Attributes/ReaperScopedAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.Attributes; 2 | 3 | /// 4 | /// This defines that the endpoint is scoped, enabling scoped ctor dependency injection 5 | /// 6 | [AttributeUsage(AttributeTargets.Class)] 7 | public class ReaperScopedAttribute : Attribute 8 | { 9 | 10 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpoint/NoneEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.TestWeb.Endpoints.ReaperEndpoint; 2 | 3 | [ReaperRoute(HttpVerbs.Get, "/re/none")] 4 | public class NoneEndpoint : Reaper.ReaperEndpoint 5 | { 6 | public override Task ExecuteAsync() 7 | { 8 | return Task.CompletedTask; 9 | } 10 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/HelloWorldProvider.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.TestWeb; 2 | 3 | public class HelloWorldProvider 4 | { 5 | public const string HelloWorld = "Hello, World! Counter: "; 6 | 7 | private int counter = 0; 8 | 9 | public string GetHelloWorld() 10 | { 11 | return HelloWorld + ++counter; 12 | } 13 | } -------------------------------------------------------------------------------- /src/Reaper.Validation/Context/FluentValidationContext.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation.Results; 2 | 3 | namespace Reaper.Validation.Context; 4 | 5 | public class FluentValidationContext : IReaperValidationContext 6 | { 7 | public RequestValidationFailureType FailureType { get; set; } 8 | 9 | public ValidationResult? ValidationResult { get; set; } 10 | } -------------------------------------------------------------------------------- /.idea/.idea.Reaper/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Rider ignored files 5 | /modules.xml 6 | /contentModel.xml 7 | /projectSettingsUpdater.xml 8 | /.idea.Reaper.iml 9 | # Editor-based HTTP Client requests 10 | /httpRequests/ 11 | # Datasource local storage ignored files 12 | /dataSources/ 13 | /dataSources.local.xml 14 | -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Carter/CarterModule.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkWeb.Services; 2 | using Carter; 3 | 4 | namespace BenchmarkWeb.Carter; 5 | 6 | public class CarterModule : ICarterModule 7 | { 8 | public void AddRoutes(IEndpointRouteBuilder app) 9 | { 10 | app.MapGet("/ep", async (GetMeAStringService svc) => await svc.GetMeAString()); 11 | } 12 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Dtos/SampleResponse.cs: -------------------------------------------------------------------------------- 1 | namespace BenchmarkWeb.Dtos; 2 | 3 | #nullable disable 4 | 5 | public class SampleResponse 6 | { 7 | public string Output { get; set; } 8 | 9 | public string SomeOtherOutput { get; set; } 10 | 11 | public bool SomeBool { get; set; } 12 | 13 | public DateTime GeneratedAt { get; set; } 14 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpoint/StringWriteEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.TestWeb.Endpoints.ReaperEndpoint; 2 | 3 | [ReaperRoute(HttpVerbs.Get, "/re/string")] 4 | public class StringWriteEndpoint : Reaper.ReaperEndpoint 5 | { 6 | public override Task ExecuteAsync() 7 | { 8 | return Context.Response.WriteAsync("Hello, World!"); 9 | } 10 | } -------------------------------------------------------------------------------- /src/Reaper/Middleware/ReaperHandlerExecutorMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using Microsoft.AspNetCore.Http; 3 | 4 | namespace Reaper.Middleware; 5 | 6 | public class ReaperHandlerExecutorMiddleware(RequestDelegate next, IServiceProvider serviceProvider) 7 | { 8 | public async Task InvokeAsync(HttpContext context) 9 | { 10 | 11 | } 12 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpointXR/StringEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.TestWeb.Endpoints.ReaperEndpointXR; 2 | 3 | [ReaperRoute(HttpVerbs.Get, "/rexr/string")] 4 | public class StringEndpoint : ReaperEndpointXR 5 | { 6 | public override Task ExecuteAsync() 7 | { 8 | Result = "Hello, World!"; 9 | return Task.CompletedTask; 10 | } 11 | } -------------------------------------------------------------------------------- /src/Reaper.Validation/Responses/ValidationProblemDetails.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace Reaper.Validation.Responses; 5 | 6 | public class ValidationProblemDetails : ProblemDetails 7 | { 8 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 9 | public Dictionary? ValidationFailures { get; set; } 10 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Controllers/TestController.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkWeb.Services; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace BenchmarkWeb.Controllers; 5 | 6 | public class TestController(GetMeAStringService svc) : Controller 7 | { 8 | [HttpGet("/ep")] 9 | public async Task Get() 10 | { 11 | return new OkObjectResult(await svc.GetMeAString()); 12 | } 13 | } -------------------------------------------------------------------------------- /benchmarks/Benchmarker/results-first-net9-run.csv: -------------------------------------------------------------------------------- 1 | Framework,Startup Time,Memory Usage (MiB) - Startup,Memory Usage (MiB) - Load Test,Requests/sec 2 | carter,77,16.1,62.3,357696.74 3 | controllers,102,18.36,73.97,278958.44 4 | fastendpoints,96,18,67.23,327575.66 5 | minimal,82,14.43,61.89,382918.32 6 | minimal-aot,16,10.37,36.88,426205.32 7 | reaper,82,15.27,69.95,390403.12 8 | reaper-aot,13,10.43,51.65,451385.54 9 | -------------------------------------------------------------------------------- /benchmarks/Benchmarker/results-last-net8-run.csv: -------------------------------------------------------------------------------- 1 | Framework,Startup Time,Memory Usage (MiB) - Startup,Memory Usage (MiB) - Load Test,Requests/sec 2 | carter,84,20.7,173.3,350238.73 3 | controllers,112,23.84,176.1,268670.74 4 | fastendpoints,104,23.59,176.2,349013.49 5 | minimal,73,19.6,170.9,409726.69 6 | minimal-aot,12,10.42,49.5,371205.31 7 | reaper,75,20.08,163.5,425412.98 8 | reaper-aot,13,10.44,57.82,413639.61 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.dockerignore 2 | **/.env 3 | **/.git 4 | **/.gitignore 5 | **/.project 6 | **/.settings 7 | **/.toolstarget 8 | **/.vs 9 | **/.vscode 10 | **/.idea 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Reaper/TestEndpoint.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkWeb.Services; 2 | using Reaper; 3 | using Reaper.Attributes; 4 | 5 | namespace BenchmarkWeb.Reaper; 6 | 7 | [ReaperRoute(HttpVerbs.Get, "/ep")] 8 | public class TestEndpoint(GetMeAStringService svc) : ReaperEndpointXR 9 | { 10 | public override async Task ExecuteAsync() 11 | { 12 | Result = await svc.GetMeAString(); 13 | } 14 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/carter.dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build 2 | WORKDIR /src 3 | COPY benchmarks/BenchmarkWeb . 4 | RUN dotnet publish "BenchmarkWeb.csproj" -c Release -o /app/publish /p:DefineConstants=CARTER /p:UseAppHost=false 5 | 6 | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final 7 | EXPOSE 8080 8 | WORKDIR /app 9 | COPY --from=build /app/publish . 10 | ENTRYPOINT ["dotnet", "BenchmarkWeb.dll"] 11 | -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/controllers.dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build 2 | WORKDIR /src 3 | COPY benchmarks/BenchmarkWeb . 4 | RUN dotnet publish "BenchmarkWeb.csproj" -c Release -o /app/publish /p:DefineConstants=CTRL /p:UseAppHost=false 5 | 6 | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final 7 | EXPOSE 8080 8 | WORKDIR /app 9 | COPY --from=build /app/publish . 10 | ENTRYPOINT ["dotnet", "BenchmarkWeb.dll"] 11 | -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/minimal.dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build 2 | WORKDIR /src 3 | COPY benchmarks/BenchmarkWeb . 4 | RUN dotnet publish "BenchmarkWeb.csproj" -c Release -o /app/publish /p:DefineConstants=MINIMAL /p:UseAppHost=false 5 | 6 | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final 7 | EXPOSE 8080 8 | WORKDIR /app 9 | COPY --from=build /app/publish . 10 | ENTRYPOINT ["dotnet", "BenchmarkWeb.dll"] 11 | -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/fastendpoints.dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build 2 | WORKDIR /src 3 | COPY benchmarks/BenchmarkWeb . 4 | RUN dotnet publish "BenchmarkWeb.csproj" -c Release -o /app/publish /p:DefineConstants=FASTEP /p:UseAppHost=false 5 | 6 | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final 7 | EXPOSE 8080 8 | WORKDIR /app 9 | COPY --from=build /app/publish . 10 | ENTRYPOINT ["dotnet", "BenchmarkWeb.dll"] 11 | -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpoint/ScopedIncrementorEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.TestWeb.Endpoints.ReaperEndpoint; 2 | 3 | [ReaperRoute(HttpVerbs.Get, "/re/scoped")] 4 | [ReaperScoped] 5 | public class ScopedIncrementorEndpoint(HelloWorldProvider hwProvider) : Reaper.ReaperEndpoint 6 | { 7 | public override Task ExecuteAsync() 8 | { 9 | return Context.Response.WriteAsync(hwProvider.GetHelloWorld()); 10 | } 11 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpoint/SingletonIncrementorEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.TestWeb.Endpoints.ReaperEndpoint; 2 | 3 | [ReaperRoute(HttpVerbs.Get, "/re/singleton")] 4 | public class SingletonIncrementorEndpoint([FromKeyedServices("hw_singleton")]HelloWorldProvider hwProvider) : Reaper.ReaperEndpoint 5 | { 6 | public override Task ExecuteAsync() 7 | { 8 | return Context.Response.WriteAsync(hwProvider.GetHelloWorld()); 9 | } 10 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpointXR/ServiceScopedStringEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.TestWeb.Endpoints.ReaperEndpointXR; 2 | 3 | [ReaperRoute(HttpVerbs.Get, "/rexr/service")] 4 | public class ServiceScopedStringEndpoint : ReaperEndpointXR 5 | { 6 | public override Task ExecuteAsync() 7 | { 8 | var service = Resolve(); 9 | Result = service.GetHelloWorld(); 10 | return Task.CompletedTask; 11 | } 12 | } -------------------------------------------------------------------------------- /src/Reaper/Attributes/ReaperRouteAttribute.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace Reaper.Attributes; 4 | 5 | [AttributeUsage(AttributeTargets.Class)] 6 | public class ReaperRouteAttribute : Attribute 7 | { 8 | public string Verb { get; } 9 | public string Route { get; } 10 | 11 | public ReaperRouteAttribute(string verb, [StringSyntax("Route")] string route) 12 | { 13 | Verb = verb; 14 | Route = route; 15 | } 16 | } -------------------------------------------------------------------------------- /src/Reaper/Context/IReaperExecutionContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Reaper.Validation; 3 | 4 | namespace Reaper.Context; 5 | 6 | public interface IReaperExecutionContext 7 | { 8 | HttpContext HttpContext { get; } 9 | 10 | IReaperValidationContext ValidationContext { get; } 11 | 12 | string RequestTraceIdentifier { get; } 13 | 14 | void TrySetDefaultContexts(HttpContext httpContext, IReaperValidationContext validationContext); 15 | } -------------------------------------------------------------------------------- /src/Reaper/Attributes/ReaperForceHandlerAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.Attributes; 2 | 3 | /// 4 | /// This attribute states that it should use the Reaper handler instead of the default Minimal API handler. 5 | /// 6 | /// This is typically automatically discovered in certain cases, but you can force it for performance and/or simplicity reasons. 7 | /// 8 | [AttributeUsage(AttributeTargets.Class)] 9 | public class ReaperForceHandlerAttribute : Attribute 10 | { 11 | 12 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/FastEndpoints/TestEndpoint.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkWeb.Services; 2 | using FastEndpoints; 3 | 4 | namespace BenchmarkWeb.FastEndpoints; 5 | 6 | public class TestEndpoint(GetMeAStringService svc) : EndpointWithoutRequest 7 | { 8 | public override void Configure() 9 | { 10 | Get("/ep"); 11 | AllowAnonymous(); 12 | } 13 | 14 | public override async Task ExecuteAsync(CancellationToken ct) 15 | { 16 | return await svc.GetMeAString(); 17 | } 18 | 19 | } -------------------------------------------------------------------------------- /benchmarks/Benchmarker/Paths.cs: -------------------------------------------------------------------------------- 1 | using DotNet.Testcontainers.Builders; 2 | 3 | namespace Benchmarker; 4 | 5 | public static class Paths 6 | { 7 | public static readonly CommonDirectoryPath SolutionPath = CommonDirectoryPath.GetSolutionDirectory(); 8 | public static readonly string SolutionDirectory = SolutionPath.DirectoryPath; 9 | public static readonly CommonDirectoryPath ProjectPath = CommonDirectoryPath.GetProjectDirectory(); 10 | public static readonly string ProjectDirectory = ProjectPath.DirectoryPath; 11 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/minimal-aot.dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build 2 | WORKDIR /src 3 | RUN apt-get update \ 4 | && apt-get install -y --no-install-recommends \ 5 | clang zlib1g-dev 6 | COPY benchmarks/BenchmarkWeb . 7 | RUN dotnet publish "BenchmarkWeb.csproj" -c Release -o /app/publish /p:DefineConstants=MINIMAL /p:PublishAot=true 8 | 9 | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final 10 | EXPOSE 8080 11 | WORKDIR /app 12 | COPY --from=build /app/publish . 13 | ENTRYPOINT ["./BenchmarkWeb"] 14 | -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpointRX/ReflectorWriteEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.TestWeb.Endpoints.ReaperEndpointRX; 2 | 3 | [ReaperRoute(HttpVerbs.Post, "/rerx/reflector")] 4 | public class ReflectorWriteEndpoint : ReaperEndpointRX 5 | { 6 | public override Task ExecuteAsync(ReflectorWriteRequest request) 7 | { 8 | return Context.Response.WriteAsync(request.Message); 9 | } 10 | 11 | public class ReflectorWriteRequest 12 | { 13 | public string Message { get; set; } = string.Empty; 14 | } 15 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpointXR/JsonEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.TestWeb.Endpoints.ReaperEndpointXR; 2 | 3 | [ReaperRoute(HttpVerbs.Get, "/rexr/json")] 4 | public class JsonEndpoint : ReaperEndpointXR 5 | { 6 | public override Task ExecuteAsync() 7 | { 8 | Result = new JsonResponse 9 | { 10 | Message = "Hello, World!" 11 | }; 12 | return Task.CompletedTask; 13 | } 14 | 15 | public class JsonResponse 16 | { 17 | public string Message { get; set; } = string.Empty; 18 | } 19 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpoint`ReqResp/ReflectorEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.TestWeb.Endpoints.ReaperEndpoint_ReqResp; 2 | 3 | [ReaperRoute(HttpVerbs.Post, "/rerr/reflector")] 4 | public class ReflectorEndpoint : ReaperEndpoint 5 | { 6 | public override Task ExecuteAsync(ReflectorRequestResponse request) 7 | { 8 | Result = request; 9 | return Task.CompletedTask; 10 | } 11 | 12 | public class ReflectorRequestResponse 13 | { 14 | public string Message { get; set; } = string.Empty; 15 | } 16 | } -------------------------------------------------------------------------------- /src/Reaper/Context/ReaperExecutionContextProvider.cs: -------------------------------------------------------------------------------- 1 | namespace Reaper.Context; 2 | 3 | public class ReaperExecutionContextProvider : IReaperExecutionContextProvider 4 | { 5 | private static readonly AsyncLocal executionContext = new(); 6 | 7 | public IReaperExecutionContext Context 8 | { 9 | get => executionContext.Value ?? throw new InvalidOperationException("Reaper execution context is not available."); 10 | internal set => executionContext.Value = value; 11 | } 12 | 13 | public void SetContext(IReaperExecutionContext context) 14 | { 15 | Context = context; 16 | } 17 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/reaper.dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build 2 | WORKDIR /src 3 | COPY . . 4 | RUN dotnet pack src/Reaper.SourceGenerator/Reaper.SourceGenerator.csproj -c Release -o /app/nuget 5 | RUN mv /app/nuget/*.nupkg /app/nuget/Reaper.SourceGenerator.1.0.0.nupkg 6 | RUN dotnet nuget add source /app/nuget 7 | RUN dotnet publish "benchmarks/BenchmarkWeb/BenchmarkWeb.csproj" -c Release -o /app/publish /p:DefineConstants=REAPER /p:UseAppHost=false 8 | 9 | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final 10 | EXPOSE 8080 11 | WORKDIR /app 12 | COPY --from=build /app/publish . 13 | ENTRYPOINT ["dotnet", "BenchmarkWeb.dll"] 14 | -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "applicationUrl": "http://localhost:5123", 8 | "environmentVariables": { 9 | "ASPNETCORE_ENVIRONMENT": "Development" 10 | } 11 | }, 12 | "https": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "applicationUrl": "https://localhost:7268;http://localhost:5123", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 2 | WORKDIR /src 3 | RUN apt-get update \ 4 | && apt-get install -y --no-install-recommends \ 5 | clang zlib1g-dev 6 | COPY . . 7 | RUN dotnet pack src/Reaper.SourceGenerator/Reaper.SourceGenerator.csproj -c Release -o /app/nuget 8 | RUN mv /app/nuget/*.nupkg /app/nuget/Reaper.SourceGenerator.1.0.0.nupkg 9 | RUN dotnet nuget add source /app/nuget 10 | RUN dotnet publish tests/Reaper.TestWeb/Reaper.TestWeb.csproj -c Release -o /app/publish /p:PublishAot=true 11 | 12 | FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final 13 | EXPOSE 8080 14 | WORKDIR /app 15 | COPY --from=build /app/publish . 16 | ENTRYPOINT ["./Reaper.TestWeb"] 17 | -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/README.md: -------------------------------------------------------------------------------- 1 | # BenchmarkWeb 2 | 3 | This has multiple compilation targets to simulate different frameworks. 4 | 5 | Specifically: 6 | 7 | - Carter 8 | - ASP.NET Core MVC 9 | - FastEndpoints 10 | - ASP.NET Core Minimal API 11 | - Reaper (non-AOT) 12 | - Reaper (AOT) 13 | 14 | The endpoint for high load is currently simplistic (in that it just returns "Hello, World!") and is located at `/ep`. In 15 | the future, this may change a JSON endpoint for example (or MessagePack), or even better, be switchable for further tests. 16 | 17 | **This needs work**: Ideally we want to simulate a "real world" app that has a reasonable amount of endpoints (~50) to 18 | be wired at startup. This is not currently the case. -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Carter/TypicalCarterModule.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkWeb.Dtos; 2 | using Carter; 3 | 4 | namespace BenchmarkWeb.Carter; 5 | 6 | public class TypicalCarterModule : ICarterModule 7 | { 8 | public void AddRoutes(IEndpointRouteBuilder app) 9 | { 10 | app.MapGet("/typical/dosomething", () => TypedResults.Ok()); 11 | app.MapPost("/typical/acceptsomething", (SampleRequest req) => TypedResults.Ok()); 12 | app.MapPost("/typical/returnsomething", (SampleRequest req) => new SampleResponse 13 | { 14 | Output = req.Input, 15 | SomeOtherOutput = req.SomeOtherInput, 16 | SomeBool = req.SomeBool, 17 | GeneratedAt = DateTime.UtcNow 18 | }); 19 | } 20 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Services/GetMeAStringService.cs: -------------------------------------------------------------------------------- 1 | namespace BenchmarkWeb.Services; 2 | 3 | public class GetMeAStringService 4 | { 5 | /// 6 | /// This purely exists to simulate some work. 7 | /// 8 | /// "Hello, World!" 9 | public async Task GetMeAString() 10 | { 11 | using var str = new MemoryStream(); 12 | await using var writer = new StreamWriter(str); 13 | using var reader = new StreamReader(str); 14 | 15 | await writer.WriteAsync("Hello, "); 16 | await writer.WriteAsync("World!"); 17 | await writer.FlushAsync(); 18 | str.Seek(0, SeekOrigin.Begin); 19 | return await reader.ReadToEndAsync(); 20 | } 21 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/reaper-aot.dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build 2 | WORKDIR /src 3 | RUN apt-get update \ 4 | && apt-get install -y --no-install-recommends \ 5 | clang zlib1g-dev 6 | COPY . . 7 | RUN dotnet pack src/Reaper.SourceGenerator/Reaper.SourceGenerator.csproj -c Release -o /app/nuget 8 | RUN mv /app/nuget/*.nupkg /app/nuget/Reaper.SourceGenerator.1.0.0.nupkg 9 | RUN dotnet nuget add source /app/nuget 10 | RUN dotnet publish "benchmarks/BenchmarkWeb/BenchmarkWeb.csproj" -c Release -o /app/publish /p:DefineConstants=REAPER /p:PublishAot=true 11 | 12 | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final 13 | EXPOSE 8080 14 | WORKDIR /app 15 | COPY --from=build /app/publish . 16 | ENTRYPOINT ["./BenchmarkWeb"] 17 | -------------------------------------------------------------------------------- /tests/UnitTests/Resources/EndpointHarness.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Reaper; 3 | using Reaper.Context; 4 | using Reaper.Validation; 5 | 6 | namespace UnitTests.Resources; 7 | 8 | public class EndpointHarness : IReaperEndpoint 9 | { 10 | public void SetContextProvider(IReaperExecutionContextProvider provider) 11 | { 12 | throw new NotImplementedException(); 13 | } 14 | 15 | public IReaperExecutionContext ReaperExecutionContext { get; } = default!; 16 | public HttpContext Context { get; } = new DefaultHttpContext(); 17 | public HttpRequest Request => Context.Request; 18 | public HttpResponse Response => Context.Response; 19 | public IReaperValidationContext ValidationContext { get; } = default!; 20 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/ServicesInterceptor/ExtensionMethods.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Microsoft.CodeAnalysis.CSharp.Syntax; 3 | 4 | namespace Reaper.SourceGenerator.ServicesInterceptor; 5 | 6 | public static class ExtensionMethods 7 | { 8 | public static bool IsTargetForServicesGenerator(this SyntaxNode node) 9 | { 10 | return node is InvocationExpressionSyntax 11 | { 12 | Expression: MemberAccessExpressionSyntax 13 | { 14 | Name: SimpleNameSyntax 15 | { 16 | Identifier: SyntaxToken 17 | { 18 | ValueText: "UseReaper" 19 | } 20 | } 21 | } 22 | }; 23 | } 24 | } -------------------------------------------------------------------------------- /src/Reaper/Handlers/DefaultValidationFailureHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using Reaper.Context; 3 | using Reaper.Validation; 4 | 5 | namespace Reaper.Handlers; 6 | 7 | public class DefaultValidationFailureHandler(IReaperExecutionContextProvider reaperExecutionContextProvider) : IValidationFailureHandler 8 | { 9 | private readonly IReaperExecutionContext reaperExecutionContext = reaperExecutionContextProvider.Context; 10 | 11 | public Task HandleValidationFailure() 12 | { 13 | if (reaperExecutionContext.ValidationContext.FailureType != RequestValidationFailureType.None) 14 | { 15 | reaperExecutionContext.HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; 16 | } 17 | return Task.CompletedTask; 18 | } 19 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Carter/AnotherTypicalCarterModule.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkWeb.Dtos; 2 | using Carter; 3 | 4 | namespace BenchmarkWeb.Carter; 5 | 6 | public class AnotherTypicalCarterModule : ICarterModule 7 | { 8 | public void AddRoutes(IEndpointRouteBuilder app) 9 | { 10 | app.MapGet("/anothertypical/dosomething", () => TypedResults.Ok()); 11 | app.MapPost("/anothertypical/acceptsomething", (SampleRequest req) => TypedResults.Ok()); 12 | app.MapPost("/anothertypical/returnsomething", (SampleRequest req) => new SampleResponse 13 | { 14 | Output = req.Input, 15 | SomeOtherOutput = req.SomeOtherInput, 16 | SomeBool = req.SomeBool, 17 | GeneratedAt = DateTime.UtcNow 18 | }); 19 | } 20 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/MapperInterceptor/ExtensionMethods.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Microsoft.CodeAnalysis.CSharp.Syntax; 3 | 4 | namespace Reaper.SourceGenerator.MapperInterceptor; 5 | 6 | public static class ExtensionMethods 7 | { 8 | public static bool IsTargetForMapperInterceptor(this SyntaxNode node) 9 | { 10 | return node is InvocationExpressionSyntax 11 | { 12 | Expression: MemberAccessExpressionSyntax 13 | { 14 | Name: SimpleNameSyntax 15 | { 16 | Identifier: SyntaxToken 17 | { 18 | ValueText: "MapReaperEndpoints" 19 | } 20 | } 21 | } 22 | }; 23 | } 24 | } -------------------------------------------------------------------------------- /tests/UnitTests/StatusCodeHelperTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Reaper.Response; 3 | using UnitTests.Resources; 4 | 5 | namespace UnitTests; 6 | 7 | public class StatusCodeHelperTests 8 | { 9 | [Fact] 10 | public async Task TestStatusCodesAreOperative() 11 | { 12 | var harness = new EndpointHarness(); 13 | await harness.NotFound(); 14 | Assert.Equal(StatusCodes.Status404NotFound, harness.Response.StatusCode); 15 | // Note that this should not work real-world as it also starts the response 16 | await harness.Ok(); 17 | Assert.Equal(StatusCodes.Status200OK, harness.Response.StatusCode); 18 | await harness.BadRequest(); 19 | Assert.Equal(StatusCodes.Status400BadRequest, harness.Response.StatusCode); 20 | } 21 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpointRX/ValidatorWriteEndpoint.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | using Reaper.Validation; 3 | 4 | namespace Reaper.TestWeb.Endpoints.ReaperEndpointRX; 5 | 6 | [ReaperRoute(HttpVerbs.Post, "/rerx/validator")] 7 | public class ValidatorWriteEndpoint : ReaperEndpointRX 8 | { 9 | public override Task ExecuteAsync(ValidatorWriteRequest request) 10 | { 11 | return Context.Response.WriteAsync(request.Message!); 12 | } 13 | } 14 | 15 | public class ValidatorWriteRequest 16 | { 17 | public string? Message { get; set; } 18 | } 19 | 20 | public class ValidatorWriteRequestValidator : RequestValidator 21 | { 22 | public ValidatorWriteRequestValidator() 23 | { 24 | RuleFor(x => x.Message).NotEmpty(); 25 | } 26 | } -------------------------------------------------------------------------------- /src/Reaper/Context/ReaperExecutionContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Reaper.Validation; 3 | 4 | namespace Reaper.Context; 5 | 6 | public class ReaperExecutionContext : IReaperExecutionContext 7 | { 8 | public HttpContext HttpContext { get; private set; } = default!; 9 | 10 | public IReaperValidationContext ValidationContext { get; private set; } = default!; 11 | 12 | public string RequestTraceIdentifier => HttpContext.TraceIdentifier; 13 | 14 | public void TrySetDefaultContexts(HttpContext httpContext, IReaperValidationContext validationContext) 15 | { 16 | if (HttpContext != default! || ValidationContext != default!) 17 | { 18 | return; 19 | } 20 | 21 | HttpContext = httpContext; 22 | ValidationContext = validationContext; 23 | } 24 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/IsExternalInit.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | 3 | namespace System.Runtime.CompilerServices 4 | { 5 | 6 | [EditorBrowsable(EditorBrowsableState.Never)] 7 | internal class IsExternalInit 8 | { 9 | } 10 | 11 | [EditorBrowsable(EditorBrowsableState.Never)] 12 | public class RequiredMemberAttribute : Attribute 13 | { 14 | } 15 | 16 | [EditorBrowsable(EditorBrowsableState.Never)] 17 | public class CompilerFeatureRequiredAttribute : Attribute 18 | { 19 | public CompilerFeatureRequiredAttribute(string name) 20 | { 21 | } 22 | } 23 | } 24 | 25 | namespace System.Diagnostics.CodeAnalysis 26 | { 27 | [EditorBrowsable(EditorBrowsableState.Never)] 28 | [AttributeUsage(AttributeTargets.Constructor)] 29 | public class SetsRequiredMembersAttribute : Attribute 30 | { 31 | } 32 | } -------------------------------------------------------------------------------- /src/Reaper/Middleware/ReaperExecutionContextMiddleware.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Reaper.Context; 4 | using Reaper.Validation; 5 | 6 | namespace Reaper.Middleware; 7 | 8 | public class ReaperExecutionContextMiddleware(RequestDelegate next) 9 | { 10 | public virtual async Task InvokeAsync(HttpContext context) 11 | { 12 | var ctxProvider = context.RequestServices.GetRequiredService(); 13 | var validationContext = context.RequestServices.GetRequiredService(); 14 | var executionContext = context.RequestServices.GetRequiredService(); 15 | executionContext.TrySetDefaultContexts(context, validationContext); 16 | ctxProvider.SetContext(executionContext); 17 | 18 | await next(context); 19 | } 20 | } -------------------------------------------------------------------------------- /src/Reaper.Validation/WebApplicationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Http.Json; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.DependencyInjection.Extensions; 5 | using Microsoft.Extensions.Options; 6 | using Reaper.Handlers; 7 | using Reaper.Validation; 8 | using Reaper.Validation.Context; 9 | 10 | namespace Reaper; 11 | 12 | public static class WebApplicationBuilderExtensions 13 | { 14 | public static void UseReaperValidation(this WebApplicationBuilder builder) 15 | { 16 | builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ReaperProblemDetailsJsonOptionsSetup>()); 17 | builder.Services.TryAddTransient(); 18 | builder.Services.TryAddScoped(); 19 | } 20 | } -------------------------------------------------------------------------------- /benchmarks/Benchmarker/Benchmarker.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net9.0 6 | enable 7 | enable 8 | Linux 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | .dockerignore 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Controllers/TypicalController.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkWeb.Dtos; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace BenchmarkWeb.Controllers; 5 | 6 | [Route("typical")] 7 | public class TypicalController : Controller 8 | { 9 | [HttpGet("dosomething")] 10 | public async Task DoSomething() 11 | { 12 | return new OkResult(); 13 | } 14 | 15 | [HttpPost("acceptsomething")] 16 | public async Task AcceptSomething(SampleRequest req) 17 | { 18 | return new OkResult(); 19 | } 20 | 21 | [HttpPost("returnsomething")] 22 | public async Task ReturnSomething(SampleRequest req) 23 | { 24 | return new ObjectResult(new SampleResponse 25 | { 26 | Output = req.Input, 27 | SomeOtherOutput = req.SomeOtherInput, 28 | SomeBool = req.SomeBool, 29 | GeneratedAt = DateTime.UtcNow 30 | }); 31 | } 32 | } -------------------------------------------------------------------------------- /tests/IntegrationTests/WafTests/WafTextFixture.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.AspNetCore.Mvc.Testing; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace IntegrationTests.WafTests; 6 | 7 | // ReSharper disable once ClassNeverInstantiated.Global 8 | public class WafTextFixture : IAsyncLifetime 9 | { 10 | public WebApplicationFactory App { get; private set; } = default!; 11 | public HttpClient Client { get; private set; } = default!; 12 | 13 | public Task InitializeAsync() 14 | { 15 | App = new WebApplicationFactory().WithWebHostBuilder( 16 | b => 17 | { 18 | b.ConfigureLogging(l => l.ClearProviders().AddDebug()); 19 | }); 20 | Client = App.CreateClient(); 21 | return Task.CompletedTask; 22 | } 23 | 24 | public async Task DisposeAsync() 25 | { 26 | Client.Dispose(); 27 | await App.DisposeAsync(); 28 | } 29 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Controllers/AnotherTypicalController.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkWeb.Dtos; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace BenchmarkWeb.Controllers; 5 | 6 | [Route("anothertypical")] 7 | public class AnotherTypicalController : Controller 8 | { 9 | [HttpGet("dosomething")] 10 | public async Task DoSomething() 11 | { 12 | return new OkResult(); 13 | } 14 | 15 | [HttpPost("acceptsomething")] 16 | public async Task AcceptSomething(SampleRequest req) 17 | { 18 | return new OkResult(); 19 | } 20 | 21 | [HttpPost("returnsomething")] 22 | public async Task ReturnSomething(SampleRequest req) 23 | { 24 | return new ObjectResult(new SampleResponse 25 | { 26 | Output = req.Input, 27 | SomeOtherOutput = req.SomeOtherInput, 28 | SomeBool = req.SomeBool, 29 | GeneratedAt = DateTime.UtcNow 30 | }); 31 | } 32 | } -------------------------------------------------------------------------------- /src/Reaper/WebApplicationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.DependencyInjection.Extensions; 5 | using Reaper.Context; 6 | 7 | namespace Reaper; 8 | 9 | public static class WebApplicationBuilderExtensions 10 | { 11 | public static void UseReaper(this WebApplicationBuilder builder, Action? configure = null) 12 | { 13 | throw new InvalidProgramException("Reaper Source Generator Interceptors not operative."); 14 | } 15 | 16 | public static void AddReaperEndpoint<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TEndpoint>(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Singleton) 17 | where TEndpoint: ReaperEndpointBase 18 | { 19 | services.TryAdd(new ServiceDescriptor(typeof(TEndpoint), typeof(TEndpoint), lifetime)); 20 | } 21 | } -------------------------------------------------------------------------------- /src/Reaper/WebApplicationExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Logging; 4 | using Reaper.Middleware; 5 | 6 | namespace Reaper; 7 | 8 | // To be intercepted 9 | public static class WebApplicationExtensions 10 | { 11 | public static void MapReaperEndpoints(this WebApplication app) 12 | { 13 | var loggerFactory = app.Services.GetRequiredService(); 14 | var logger = loggerFactory.CreateLogger("Reaper"); 15 | 16 | logger.LogCritical("💀❌ Reaper is not working correctly. This call should have been intercepted. Is the source generator running?"); 17 | throw new InvalidProgramException("Reaper Source Generator Interceptor not operative."); 18 | } 19 | 20 | public static WebApplication UseReaperMiddleware(this WebApplication app) 21 | { 22 | app.UseMiddleware(); 23 | return app; 24 | } 25 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/Resources/System.Text.Json.SourceGeneration.Reaper.txt: -------------------------------------------------------------------------------- 1 | This is a minor modification to the .NET 8 internal Source Generator. 2 | 3 | The modification is on our fork of the runtime and available in the following commit: 4 | https://github.com/Reaper-Net/runtime/commit/2cfce418262a152ba7b13dccf9939f4484d75503 5 | 6 | The changes are specifically: 7 | - The namespace is changed to remove conflicts. In testing, the .NET 8 Source Generator is loaded internally if it 8 | doesn't match so this is a requisite. 9 | - All of the internal private classes were made public, so we can construct what we need to. 10 | - ReportDiagnosticsAndEmitSource was made public so we can use it directly. 11 | 12 | It's the only way to allow us to virtually chain the generators. If we don't, it doesn't generate the context. 13 | 14 | You can see a discussion on this nonsense here: https://github.com/dotnet/roslyn/discussions/48358 15 | 16 | If the situation changes in the future, of course this will be updated / removed. -------------------------------------------------------------------------------- /src/Reaper/Response/ReaperResponse.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace Reaper.Response; 4 | 5 | // TODO Confirm 6 | public class ReaperResponse : IResult 7 | { 8 | public ReaperResponse() { } 9 | 10 | public ReaperResponse(int statusCode) 11 | { 12 | statusCode = this.statusCode; 13 | } 14 | 15 | public ReaperResponse(int statusCode, string contentType) 16 | { 17 | statusCode = this.statusCode; 18 | contentType = this.contentType; 19 | } 20 | 21 | private readonly int statusCode = StatusCodes.Status200OK; 22 | private readonly string contentType = "text/plain"; 23 | 24 | public async Task ExecuteAsync(HttpContext httpContext) 25 | { 26 | ArgumentNullException.ThrowIfNull(httpContext, nameof (httpContext)); 27 | 28 | httpContext.Response.StatusCode = statusCode; 29 | httpContext.Response.ContentType = contentType; 30 | 31 | //await httpContext.Response.WriteAsJsonAsync("Hello World!"); 32 | } 33 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpoint`ReqResp/ConstrainedRouteEndpoint.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace Reaper.TestWeb.Endpoints.ReaperEndpoint_ReqResp; 4 | 5 | [ReaperRoute(HttpVerbs.Get, "/rerr/croute/{first:int}/{second:guid}/{third:datetime}/{fourth:bool}/{fifth:length(1,5)}/{sixth:range(5,10)}")] 6 | public class ConstrainedRouteEndpoint : ReaperEndpoint 7 | { 8 | public override Task ExecuteAsync(ConstrainedRouteEndpointRequestResponse request) 9 | { 10 | Result = request; 11 | return Task.CompletedTask; 12 | } 13 | 14 | public class ConstrainedRouteEndpointRequestResponse 15 | { 16 | [FromRoute] public int First { get; set; } 17 | [FromRoute] public Guid Second { get; set; } 18 | [FromRoute] public DateTime Third { get; set; } 19 | [FromRoute] public bool Fourth { get; set; } 20 | [FromRoute] public string? Fifth { get; set; } 21 | [FromRoute] public int Sixth { get; set; } 22 | } 23 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 divergent Limited 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | using System.Text.Json.Serialization.Metadata; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.Extensions.Options; 5 | using Reaper; 6 | using Reaper.TestWeb; 7 | using JsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions; 8 | 9 | [assembly: InternalsVisibleTo("IntegrationTests")] 10 | 11 | var builder = WebApplication.CreateSlimBuilder(args); 12 | builder.Services.AddScoped(); 13 | builder.Services.AddKeyedSingleton("hw_singleton"); 14 | 15 | builder.UseReaperValidation(); 16 | builder.UseReaper(); 17 | 18 | var app = builder.Build(); 19 | 20 | var jsonOptions = app.Services.GetService>()!.Value; 21 | 22 | foreach (var item in jsonOptions.SerializerOptions.TypeInfoResolverChain) 23 | { 24 | Console.WriteLine(item.GetType().FullName); 25 | } 26 | var jsonTypeInfo = (JsonTypeInfo)jsonOptions.SerializerOptions.GetTypeInfo(typeof(ProblemDetails)); 27 | Console.WriteLine(jsonTypeInfo); 28 | 29 | app.UseReaperMiddleware(); 30 | app.MapReaperEndpoints(); 31 | 32 | app.Run(); 33 | 34 | public partial class Program { } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:53520", 8 | "sslPort": 44375 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "applicationUrl": "http://localhost:5103", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "https": { 22 | "commandName": "Project", 23 | "dotnetRunMessages": true, 24 | "launchBrowser": true, 25 | "applicationUrl": "https://localhost:7092;http://localhost:5103", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | }, 30 | "IIS Express": { 31 | "commandName": "IISExpress", 32 | "launchBrowser": true, 33 | "environmentVariables": { 34 | "ASPNETCORE_ENVIRONMENT": "Development" 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Reaper/EndpointMapper/ReaperEndpointDefinition.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace Reaper.EndpointMapper; 4 | 5 | public abstract record ReaperEndpointDefinition(string Route, string Verb) 6 | where TEndpoint: ReaperEndpointBase 7 | { 8 | } 9 | 10 | public record DirectReaperEndpointDefinition(string Route, string Verb) 11 | : ReaperEndpointDefinition(Route, Verb) 12 | where TEndpoint: ReaperEndpointBase 13 | { 14 | public required Delegate Handler { get; init; } 15 | 16 | public required Func RequestDelegateGenerator { get; init; } 17 | 18 | public bool IsJsonRequest { get; set; } 19 | } 20 | 21 | public record HandlerReaperEndpointDefinition(string Route, string Verb) 22 | : ReaperEndpointDefinition(Route, Verb) 23 | where TEndpoint: ReaperEndpointBase 24 | { 25 | 26 | } 27 | 28 | public struct EmptyRequestResponse { } -------------------------------------------------------------------------------- /tests/UnitTests/UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Reaper/TypicalEndpoints.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkWeb.Dtos; 2 | using Reaper; 3 | using Reaper.Attributes; 4 | 5 | namespace BenchmarkWeb.Reaper; 6 | 7 | [ReaperRoute(HttpVerbs.Get, "/typical/dosomething")] 8 | public class TypicalEndpointDoSomething : ReaperEndpoint 9 | { 10 | public override Task ExecuteAsync() 11 | { 12 | return Task.CompletedTask; 13 | } 14 | } 15 | 16 | [ReaperRoute(HttpVerbs.Post, "/typical/acceptsomething")] 17 | public class TypicalEndpointAcceptSomething : ReaperEndpointRX 18 | { 19 | public override Task ExecuteAsync(SampleRequest request) 20 | { 21 | return Task.CompletedTask; 22 | } 23 | } 24 | 25 | [ReaperRoute(HttpVerbs.Post, "/typical/returnsomething")] 26 | public class TypicalEndpointReturnSomething : ReaperEndpoint 27 | { 28 | public override Task ExecuteAsync(SampleRequest request) 29 | { 30 | Result = new SampleResponse() 31 | { 32 | Output = request.Input, 33 | SomeOtherOutput = request.SomeOtherInput, 34 | SomeBool = request.SomeBool, 35 | GeneratedAt = DateTime.UtcNow 36 | }; 37 | return Task.CompletedTask; 38 | } 39 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpoint`ReqResp/FromSourcesEndpoint.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace Reaper.TestWeb.Endpoints.ReaperEndpoint_ReqResp; 4 | 5 | [ReaperRoute(HttpVerbs.Post, "/rerr/fromsource/{test}/{anothertest}")] 6 | public class FromSourcesEndpoint : ReaperEndpoint 7 | { 8 | public override Task ExecuteAsync(FromSourceRequestResponse request) 9 | { 10 | Result = request; 11 | return Task.CompletedTask; 12 | } 13 | 14 | public class FromSourceRequestResponse 15 | { 16 | [FromRoute] public int Test { get; set; } 17 | 18 | [FromRoute(Name = "anothertest")] public string TestString { get; set; } = default!; 19 | 20 | [FromQuery] public int QueryValue { get; set; } 21 | 22 | [FromQuery(Name = "another")] public int AnotherQueryValue { get; set; } 23 | 24 | [FromBody] public JsonBound Json { get; set; } = default!; 25 | 26 | public class JsonBound 27 | { 28 | public string Test { get; set; } = string.Empty; 29 | 30 | public int AnotherTest { get; set; } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Reaper/AnotherTypicalEndpoints.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkWeb.Dtos; 2 | using Reaper; 3 | using Reaper.Attributes; 4 | 5 | namespace BenchmarkWeb.Reaper; 6 | 7 | [ReaperRoute(HttpVerbs.Get, "/anothertypical/dosomething")] 8 | public class AnotherTypicalEndpointDoSomething : ReaperEndpoint 9 | { 10 | public override Task ExecuteAsync() 11 | { 12 | return Task.CompletedTask; 13 | } 14 | } 15 | 16 | [ReaperRoute(HttpVerbs.Post, "/anothertypical/acceptsomething")] 17 | public class AnotherTypicalEndpointAcceptSomething : ReaperEndpointRX 18 | { 19 | public override Task ExecuteAsync(SampleRequest request) 20 | { 21 | return Task.CompletedTask; 22 | } 23 | } 24 | 25 | [ReaperRoute(HttpVerbs.Post, "/anothertypical/returnsomething")] 26 | public class AnotherTypicalEndpointReturnSomething : ReaperEndpoint 27 | { 28 | public override Task ExecuteAsync(SampleRequest request) 29 | { 30 | Result = new SampleResponse() 31 | { 32 | Output = request.Input, 33 | SomeOtherOutput = request.SomeOtherInput, 34 | SomeBool = request.SomeBool, 35 | GeneratedAt = DateTime.UtcNow 36 | }; 37 | return Task.CompletedTask; 38 | } 39 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpoint`ReqResp/OptionalFromSourcesEndpoint.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace Reaper.TestWeb.Endpoints.ReaperEndpoint_ReqResp; 4 | 5 | [ReaperRoute(HttpVerbs.Post, "/rerr/optfsource/{test?}/{anothertest?}")] 6 | public class OptionalFromSourcesEndpoint : ReaperEndpoint 7 | { 8 | public override Task ExecuteAsync(OptionalFromSourcesRequestResponse request) 9 | { 10 | Result = request; 11 | return Task.CompletedTask; 12 | } 13 | 14 | public class OptionalFromSourcesRequestResponse 15 | { 16 | [FromRoute] public int? Test { get; set; } 17 | 18 | [FromRoute(Name = "anothertest")] public string? TestString { get; set; } = default!; 19 | 20 | [FromQuery] public int? QueryValue { get; set; } 21 | 22 | [FromQuery(Name = "another")] public int? AnotherQueryValue { get; set; } 23 | 24 | [FromBody] public JsonBoundOptional? Json { get; set; } 25 | 26 | public class JsonBoundOptional 27 | { 28 | public string Test { get; set; } = string.Empty; 29 | 30 | public int AnotherTest { get; set; } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /benchmarks/Benchmarker/DockerStats.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Text.Json; 3 | 4 | namespace Benchmarker; 5 | 6 | public class DockerStats 7 | { 8 | public static async Task GetMemoryUsageForContainer(string container) 9 | { 10 | var proc = Process.Start(new ProcessStartInfo("docker", "stats " + container + " --no-stream --format json") 11 | { 12 | CreateNoWindow = true, 13 | WindowStyle = ProcessWindowStyle.Hidden, 14 | RedirectStandardOutput = true 15 | })!; 16 | await proc.WaitForExitAsync(); 17 | var json = JsonSerializer.Deserialize(proc.StandardOutput.BaseStream)!; 18 | // Find the usage 19 | var memUsage = json.MemUsage; 20 | var xibStart = memUsage.IndexOf('M'); 21 | var isMiB = true; 22 | if (xibStart == -1) 23 | { 24 | xibStart = memUsage.IndexOf('G'); 25 | isMiB = false; 26 | } 27 | var memUsageStr = memUsage.Substring(0, xibStart); 28 | var memUsageNum = decimal.Parse(memUsageStr); 29 | if (!isMiB) 30 | { 31 | memUsageNum *= 1024; 32 | } 33 | return memUsageNum; 34 | } 35 | } 36 | 37 | 38 | public class DockerStatOutput 39 | { 40 | public string MemUsage { get; set; } 41 | } -------------------------------------------------------------------------------- /src/Reaper.Validation/ReaperValidationJsonContext.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.Extensions.Options; 4 | using JsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions; 5 | using ValidationProblemDetails = Reaper.Validation.Responses.ValidationProblemDetails; 6 | 7 | namespace Reaper.Validation; 8 | 9 | [JsonSerializable(typeof(ProblemDetails))] 10 | [JsonSerializable(typeof(ValidationProblemDetails))] 11 | internal partial class ReaperValidationJsonContext : JsonSerializerContext 12 | { 13 | 14 | } 15 | 16 | internal sealed class ReaperProblemDetailsJsonOptionsSetup : IConfigureOptions 17 | { 18 | public void Configure(JsonOptions options) 19 | { 20 | // Always insert the ProblemDetailsJsonContext to the beginning of the chain at the time 21 | // this Configure is invoked. This JsonTypeInfoResolver will be before the default reflection-based resolver, 22 | // and before any other resolvers currently added. 23 | // If apps need to customize ProblemDetails serialization, they can prepend a custom ProblemDetails resolver 24 | // to the chain in an IConfigureOptions registered after the call to AddProblemDetails(). 25 | options.SerializerOptions.TypeInfoResolverChain.Insert(0, new ReaperValidationJsonContext()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/IntegrationTests/IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | all 20 | 21 | 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | all 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Reaper/Reaper.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0;net9.0 5 | enable 6 | enable 7 | Library 8 | true 9 | Reaper.Core 10 | https://github.com/Reaper-Net/Reaper 11 | MIT 12 | reaper,mvc,minimalapis,minimal,repr 13 | 14 | Provides the REPR pattern for Minimal APIs, sorta. 15 | 16 | README.md 17 | 0.1 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | all 29 | runtime; build; native; contentfiles; analyzers; buildtransitive 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /tests/IntegrationTests/AotTests/AotTestFixture.cs: -------------------------------------------------------------------------------- 1 | using DotNet.Testcontainers.Builders; 2 | using DotNet.Testcontainers.Containers; 3 | 4 | namespace IntegrationTests.AotTests; 5 | 6 | // ReSharper disable once ClassNeverInstantiated.Global 7 | public class AotTestFixture : IAsyncLifetime 8 | { 9 | public IContainer Container { get; private set; } = default!; 10 | public HttpClient Client { get; private set; } = default!; 11 | 12 | public async Task InitializeAsync() 13 | { 14 | var image = new ImageFromDockerfileBuilder() 15 | .WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), string.Empty) 16 | .WithDockerfile("tests/Reaper.TestWeb/Dockerfile") 17 | .WithDeleteIfExists(true) 18 | .Build(); 19 | await image.CreateAsync(); 20 | Container = new ContainerBuilder() 21 | .WithImage(image) 22 | .WithPortBinding(8080, true) 23 | .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(8080)) 24 | .Build(); 25 | await Container.StartAsync(); 26 | 27 | var baseAddress = new UriBuilder(Uri.UriSchemeHttp, Container.Hostname, Container.GetMappedPublicPort(8080)).Uri; 28 | Client = new HttpClient() 29 | { 30 | BaseAddress = baseAddress 31 | }; 32 | } 33 | 34 | public async Task DisposeAsync() 35 | { 36 | await Container.DisposeAsync(); 37 | Client.Dispose(); 38 | } 39 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/FastEndpoints/TypicalEndpoints.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkWeb.Dtos; 2 | using FastEndpoints; 3 | 4 | namespace BenchmarkWeb.FastEndpoints; 5 | 6 | public class TypicalEndpointDoSomething : EndpointWithoutRequest 7 | { 8 | public override void Configure() 9 | { 10 | Get("/typical/dosomething"); 11 | AllowAnonymous(); 12 | } 13 | 14 | public override Task HandleAsync(CancellationToken ct) 15 | { 16 | return Task.CompletedTask; 17 | } 18 | } 19 | 20 | public class TypicalEndpointAcceptSomething : Endpoint 21 | { 22 | public override void Configure() 23 | { 24 | Post("/typical/acceptsomething"); 25 | AllowAnonymous(); 26 | } 27 | 28 | public override Task HandleAsync(SampleRequest req, CancellationToken ct) 29 | { 30 | return Task.CompletedTask; 31 | } 32 | } 33 | 34 | public class TypicalEndpointReturnSomething : Endpoint 35 | { 36 | public override void Configure() 37 | { 38 | Post("/typical/returnsomething"); 39 | AllowAnonymous(); 40 | } 41 | 42 | public override Task ExecuteAsync(SampleRequest req, CancellationToken ct) 43 | { 44 | return Task.FromResult(new SampleResponse() 45 | { 46 | Output = req.Input, 47 | SomeOtherOutput = req.SomeOtherInput, 48 | SomeBool = req.SomeBool, 49 | GeneratedAt = DateTime.UtcNow 50 | }); 51 | } 52 | } -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Publish 🚀 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | #tags: 8 | # - "[0-9]+.[0-9]+.[0-9]+" 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Install .NET 21 | uses: actions/setup-dotnet@v3 22 | with: 23 | dotnet-version: 9.0.x 24 | 25 | - name: Restore dependencies 26 | run: dotnet restore 27 | 28 | #- name: Test 29 | # id: test 30 | # run: dotnet test 31 | 32 | - name: Build & Package 33 | id: build 34 | # if: steps.test.outcome == 'success' 35 | run: dotnet pack -c Release --no-restore 36 | 37 | - name: Publish Reaper 38 | if: steps.build.outcome == 'success' 39 | run: dotnet nuget push ./src/Reaper/bin/Release/*.nupkg --api-key ${{secrets.NUGET_PAT}} --skip-duplicate -s https://api.nuget.org/v3/index.json 40 | 41 | - name: Publish Source Generator 42 | if: steps.build.outcome == 'success' 43 | run: dotnet nuget push ./src/Reaper.SourceGenerator/bin/Release/*.nupkg --api-key ${{secrets.NUGET_PAT}} --skip-duplicate -s https://api.nuget.org/v3/index.json 44 | 45 | - name: Publish Validation 46 | if: steps.build.outcome == 'success' 47 | run: dotnet nuget push ./src/Reaper.Validation/bin/Release/*.nupkg --api-key ${{secrets.NUGET_PAT}} --skip-duplicate -s https://api.nuget.org/v3/index.json 48 | -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/FastEndpoints/AnotherTypicalEndpoints.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkWeb.Dtos; 2 | using FastEndpoints; 3 | 4 | namespace BenchmarkWeb.FastEndpoints; 5 | 6 | public class AnotherTypicalEndpointDoSomething : EndpointWithoutRequest 7 | { 8 | public override void Configure() 9 | { 10 | Get("/anothertypical/dosomething"); 11 | AllowAnonymous(); 12 | } 13 | 14 | public override Task HandleAsync(CancellationToken ct) 15 | { 16 | return Task.CompletedTask; 17 | } 18 | } 19 | 20 | public class AnotherTypicalEndpointAcceptSomething : Endpoint 21 | { 22 | public override void Configure() 23 | { 24 | Post("/anothertypical/acceptsomething"); 25 | AllowAnonymous(); 26 | } 27 | 28 | public override Task HandleAsync(SampleRequest req, CancellationToken ct) 29 | { 30 | return Task.CompletedTask; 31 | } 32 | } 33 | 34 | public class AnotherTypicalEndpointReturnSomething : Endpoint 35 | { 36 | public override void Configure() 37 | { 38 | Post("/anothertypical/returnsomething"); 39 | AllowAnonymous(); 40 | } 41 | 42 | public override Task ExecuteAsync(SampleRequest req, CancellationToken ct) 43 | { 44 | return Task.FromResult(new SampleResponse() 45 | { 46 | Output = req.Input, 47 | SomeOtherOutput = req.SomeOtherInput, 48 | SomeBool = req.SomeBool, 49 | GeneratedAt = DateTime.UtcNow 50 | }); 51 | } 52 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Reaper.TestWeb.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | true 8 | Generated 9 | 10 | $(InterceptorsPreviewNamespaces);Reaper.Generated 11 | 12 | Linux 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | .dockerignore 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/IntegrationTests/Tests/ReaperEndpointXRTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | 4 | namespace IntegrationTests.Tests; 5 | 6 | // ReSharper disable once InconsistentNaming 7 | public abstract class ReaperEndpointXRTests(HttpClient client) 8 | { 9 | [Fact] 10 | public async Task JsonEndpointIsReturning() 11 | { 12 | var resp = await client.GetAsync("/rexr/json"); 13 | var json = await resp.Content.ReadFromJsonAsync(); 14 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 15 | Assert.NotNull(json); 16 | Assert.Equal("Hello, World!", json.Message); 17 | } 18 | 19 | [Fact] 20 | public async Task StringWriteEndpointIsWriting() 21 | { 22 | var resp = await client.GetAsync("/rexr/string"); 23 | var str = await resp.Content.ReadAsStringAsync(); 24 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 25 | Assert.Equal("Hello, World!", str); 26 | } 27 | 28 | [Fact] 29 | public async Task ScopedServiceEndpointIsWriting() 30 | { 31 | var expected = "Hello, World! Counter: 1"; 32 | var resp = await client.GetAsync("/rexr/service"); 33 | var str = await resp.Content.ReadAsStringAsync(); 34 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 35 | Assert.Equal(expected, str); 36 | 37 | // Scoped test 38 | resp = await client.GetAsync("/rexr/service"); 39 | str = await resp.Content.ReadAsStringAsync(); 40 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 41 | Assert.Equal(expected, str); 42 | } 43 | } -------------------------------------------------------------------------------- /src/Reaper.Validation/Reaper.Validation.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0;net9.0 5 | enable 6 | enable 7 | Library 8 | true 9 | Reaper.Validation 10 | https://github.com/Reaper-Net/Reaper 11 | MIT 12 | reaper,mvc,minimalapis,minimal,repr,validation 13 | 14 | Adds FluentValidation support to Reaper. 15 | 16 | README.md 17 | 0.1 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | all 31 | runtime; build; native; contentfiles; analyzers; buildtransitive 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /Reaper.sln.DotSettings.user: -------------------------------------------------------------------------------- 1 | 2 | True 3 | ForceIncluded 4 | ForceIncluded 5 | <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> 6 | <Solution /> 7 | </SessionState> -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/RoslynHelpers/ExtensionMethods.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using Microsoft.CodeAnalysis; 3 | using Microsoft.CodeAnalysis.Operations; 4 | 5 | namespace Reaper.SourceGenerator.RoslynHelpers; 6 | 7 | public static class ExtensionMethods 8 | { 9 | public static (bool valid, IInvocationOperation? operation) GetValidReaperInvokationOperation(this GeneratorSyntaxContext ctx, CancellationToken ct) 10 | { 11 | var operation = ctx.SemanticModel.GetOperation(ctx.Node, ct); 12 | if (operation == null) 13 | { 14 | return (false, null); 15 | } 16 | 17 | if (operation is IInvocationOperation 18 | { 19 | TargetMethod: 20 | { 21 | ContainingNamespace: 22 | { 23 | Name: "Reaper" 24 | }, 25 | ContainingAssembly: 26 | { 27 | Name: "Reaper" 28 | } 29 | } 30 | }) 31 | { 32 | return (true, (IInvocationOperation?)operation); 33 | } 34 | 35 | return (false, null); 36 | } 37 | 38 | [SuppressMessage("MicrosoftCodeAnalysisCorrectness", "RS1024:Symbols should be compared for equality", 39 | Justification = "Symbol equality checks for generic type equality.")] 40 | public static bool EqualsWithoutGeneric(this INamedTypeSymbol symbol, INamedTypeSymbol compare) 41 | { 42 | if (!Equals(symbol.ContainingNamespace, compare.ContainingNamespace)) 43 | return false; 44 | 45 | if (symbol.Name != compare.Name) 46 | return false; 47 | 48 | if (symbol.IsGenericType != compare.IsGenericType) 49 | return false; 50 | 51 | return true; 52 | } 53 | } -------------------------------------------------------------------------------- /tests/Reaper.TestWeb/Endpoints/ReaperEndpoint/StatusCodeEndpoints.cs: -------------------------------------------------------------------------------- 1 | using Reaper.Response; 2 | 3 | namespace Reaper.TestWeb.Endpoints.ReaperEndpoint; 4 | 5 | [ReaperRoute(HttpVerbs.Get, "/re/200")] 6 | public class Status200Endpoint : Reaper.ReaperEndpoint 7 | { 8 | public override Task ExecuteAsync() 9 | { 10 | return this.Ok(); 11 | } 12 | } 13 | 14 | [ReaperRoute(HttpVerbs.Get, "/re/400")] 15 | public class Status400Endpoint : Reaper.ReaperEndpoint 16 | { 17 | public override Task ExecuteAsync() 18 | { 19 | return this.BadRequest(); 20 | } 21 | } 22 | 23 | [ReaperRoute(HttpVerbs.Get, "/re/404")] 24 | public class Status404Endpoint : Reaper.ReaperEndpoint 25 | { 26 | public override Task ExecuteAsync() 27 | { 28 | return this.NotFound(); 29 | } 30 | } 31 | 32 | [ReaperRoute(HttpVerbs.Get, "/re/w200")] 33 | public class Status200WriterEndpoint : Reaper.ReaperEndpoint 34 | { 35 | public override Task ExecuteAsync() 36 | { 37 | return this.Ok("Hello, World!"); 38 | } 39 | } 40 | 41 | [ReaperRoute(HttpVerbs.Get, "/re/w400")] 42 | public class Status400WriterEndpoint : Reaper.ReaperEndpoint 43 | { 44 | public override Task ExecuteAsync() 45 | { 46 | return this.BadRequest("Hello, World!"); 47 | } 48 | } 49 | 50 | [ReaperRoute(HttpVerbs.Get, "/re/w404")] 51 | public class Status404WriterEndpoint : Reaper.ReaperEndpoint 52 | { 53 | public override Task ExecuteAsync() 54 | { 55 | return this.NotFound("Hello, World!"); 56 | } 57 | } 58 | 59 | [ReaperRoute(HttpVerbs.Get, "/re/j200")] 60 | public class Status200JsonWriterEndpoint : Reaper.ReaperEndpointXR 61 | { 62 | public override async Task ExecuteAsync() 63 | { 64 | await this.Ok(new SampleResponse 65 | { 66 | Message = "Hello, World!" 67 | }); 68 | } 69 | } 70 | 71 | public class SampleResponse 72 | { 73 | public string Message { get; set; } 74 | } 75 | -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/JsonContextGenerator/JsonContextGenerator.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using System.Text; 3 | using Microsoft.CodeAnalysis.Text; 4 | using Reaper.SourceGenerator.Internal; 5 | using Reaper.SourceGenerator.ReaperEndpoints; 6 | 7 | namespace Reaper.SourceGenerator.JsonContextGenerator; 8 | 9 | internal class JsonContextGenerator(ImmutableArray endpoints) 10 | { 11 | private readonly CodeWriter codeWriter = new(); 12 | 13 | public SourceText Generate() 14 | { 15 | codeWriter.AppendLine(GeneratorStatics.FileHeader); 16 | 17 | codeWriter.Namespace("Reaper.Generated"); 18 | 19 | codeWriter.AppendLine("using System.Collections.Generic;"); 20 | codeWriter.AppendLine("using System.Text.Json.Serialization;"); 21 | codeWriter.AppendLine(string.Empty); 22 | 23 | foreach (var endpoint in endpoints.Where(m => m.HasRequest || m.HasResponse)) 24 | { 25 | if (endpoint.HasRequest && endpoint.RequestMap!.RequestBodyType!.ContainingNamespace is not { Name: "System" }) 26 | { 27 | codeWriter.Append("[JsonSerializable(typeof("); 28 | codeWriter.Append(endpoint.RequestBodyTypeName); 29 | codeWriter.AppendLine("))]"); 30 | } 31 | 32 | if (endpoint.HasResponse && endpoint.ResponseSymbol!.ContainingNamespace is not { Name: "System" }) 33 | { 34 | codeWriter.Append("[JsonSerializable(typeof("); 35 | codeWriter.Append(endpoint.ResponseTypeName); 36 | codeWriter.AppendLine("))]"); 37 | } 38 | } 39 | 40 | codeWriter.AppendLine("[JsonSerializable(typeof(Dictionary))]"); 41 | codeWriter.StartClass("ReaperJsonSerializerContext", "public partial", "JsonSerializerContext", true); 42 | 43 | codeWriter.CloseBlock(); 44 | codeWriter.CloseBlock(); 45 | 46 | return SourceText.From(codeWriter.ToString(), Encoding.UTF8); 47 | } 48 | } -------------------------------------------------------------------------------- /benchmarks/Benchmarker/README.md: -------------------------------------------------------------------------------- 1 | # Benchmarker 2 | 3 | This tool is used to gauge memory and startup timings in a very crude manner. 4 | 5 | It's not meant to serve as a benchmark for platform performance, but rather as a tool to help us to identify performance 6 | of the framework on startup and after heavy load. 7 | 8 | It should only be ran on major changes that may affect Startup or high load runtime performance and does not replace 9 | the test suite. 10 | 11 | Guidance for interpreting results: 12 | - We *always* want to be faster, lower memory, and higher req/s than `controllers`. That is the ultimate goal. 13 | - Ideally, we want to be very close to `minimal`. 14 | - In AOT land, we should always be close to `minimal-aot`. 15 | 16 | Sample run: 17 | 18 | | Framework | Startup Time | Memory Usage (MiB) - Startup | Memory Usage (MiB) - Load Test | Requests/sec | 19 | |---------------|--------------|------------------------------|--------------------------------|--------------| 20 | | carter | 115 | 23.1 | 269.6 | 121725.32 | 21 | | controllers | 143 | 24.14 | 308.9 | 106056.19 | 22 | | fastendpoints | 134 | 23.86 | 303.6 | 118512.82 | 23 | | minimal | 103 | 21.68 | 258.2 | 123264.17 | 24 | | minimal-aot | 21 | 20.81 | 26.96 | 144059.81 | 25 | | reaper | 109 | 20.41 | 294.2 | 121946.15 | 26 | | reaper-aot | 21 | 18.89 | 30.83 | 139910.28 | 27 | 28 | For actual performance data, refer to TechEmpower. 29 | 30 | Until Reaper is added officially, local machine (OSX, M1 Ultra, 128GB RAM) test results are available [here for JSON](https://www.techempower.com/benchmarks/#section=test&shareid=75585734-6c92-4a79-8cc9-dab0979ffb38&hw=ph&test=json) 31 | and [here for Plaintext](https://www.techempower.com/benchmarks/#section=test&shareid=75585734-6c92-4a79-8cc9-dab0979ffb38&hw=ph&test=plaintext). -------------------------------------------------------------------------------- /src/Reaper/RequestDelegateSupport/ResponseHelpers.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Runtime.CompilerServices; 3 | using System.Text.Json.Serialization.Metadata; 4 | using Microsoft.AspNetCore.Http; 5 | 6 | namespace Reaper.RequestDelegateSupport; 7 | 8 | public static class ResponseHelpers 9 | { 10 | [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] 11 | public static Task ExecuteReturnAsync(object? obj, HttpContext httpContext, JsonTypeInfo jsonTypeInfo, string? contentType = null) 12 | { 13 | if (obj is IResult r) 14 | { 15 | return r.ExecuteAsync(httpContext); 16 | } 17 | else if (obj is string s) 18 | { 19 | return httpContext.Response.WriteAsync(s); 20 | } 21 | else 22 | { 23 | return WriteJsonResponseAsync(httpContext.Response, (T?)obj, jsonTypeInfo, contentType); 24 | } 25 | } 26 | 27 | [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", 28 | Justification = "The 'JsonSerializer.IsReflectionEnabledByDefault' feature switch, which is set to false by default for trimmed ASP.NET apps, ensures the JsonSerializer doesn't use Reflection.")] 29 | [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "See above.")] 30 | public static Task WriteJsonResponseAsync(HttpResponse response, T? value, JsonTypeInfo jsonTypeInfo, string? contentType = null) 31 | { 32 | var runtimeType = value?.GetType(); 33 | 34 | if (jsonTypeInfo.ShouldUseWith(runtimeType)) 35 | { 36 | return response.WriteAsJsonAsync(value, jsonTypeInfo, contentType); 37 | } 38 | 39 | return response.WriteAsJsonAsync(value, jsonTypeInfo.Options); 40 | } 41 | 42 | private static bool HasKnownPolymorphism(this JsonTypeInfo jsonTypeInfo) 43 | => jsonTypeInfo.Type.IsSealed || jsonTypeInfo.Type.IsValueType || jsonTypeInfo.PolymorphismOptions is not null; 44 | 45 | private static bool ShouldUseWith(this JsonTypeInfo jsonTypeInfo, [NotNullWhen(false)] Type? runtimeType) 46 | => runtimeType is null || jsonTypeInfo.Type == runtimeType || jsonTypeInfo.HasKnownPolymorphism(); 47 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/Reaper.SourceGenerator.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | false 6 | 12.0 7 | enable 8 | true 9 | true 10 | true 11 | Library 12 | true 13 | false 14 | Reaper.SourceGenerator 15 | https://github.com/Reaper-Net/Reaper 16 | MIT 17 | reaper,mvc,minimalapis,minimal,repr,generator 18 | 19 | Reaper's Source Generator. 20 | 21 | README.md 22 | 0.1 23 | 24 | 25 | 26 | 27 | 28 | 29 | all 30 | runtime; build; native; contentfiles; analyzers; buildtransitive 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | Resources\System.Text.Json.SourceGeneration.Reaper.dll 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/Reaper/EndpointMapper/ReaperMapper.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | using System.Reflection; 3 | using System.Text.Json.Serialization.Metadata; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.AspNetCore.Http.Json; 7 | using Microsoft.Extensions.Options; 8 | using Microsoft.AspNetCore.Http.Metadata; 9 | using Microsoft.AspNetCore.Routing; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Reaper.RequestDelegateSupport; 12 | 13 | namespace Reaper.EndpointMapper; 14 | 15 | public static class ReaperEndpointMapper 16 | { 17 | public static void MapReaperEndpoint( 18 | this IEndpointRouteBuilder endpoints, 19 | ReaperEndpointDefinition definition) 20 | where TEndpoint : ReaperEndpointBase 21 | { 22 | if (definition is HandlerReaperEndpointDefinition) 23 | { 24 | // TODO later, Mpack etc 25 | } 26 | else if (definition is DirectReaperEndpointDefinition) 27 | { 28 | MapRequestInternal(endpoints, (DirectReaperEndpointDefinition)definition); 29 | } 30 | } 31 | 32 | internal static void MapRequestInternal( 33 | IEndpointRouteBuilder endpoints, 34 | DirectReaperEndpointDefinition definition) 35 | where TEndpoint: ReaperEndpointBase 36 | { 37 | var epBuilder = RouteHandlerServices.Map(endpoints, 38 | definition.Route, 39 | definition.Handler, 40 | new [] { definition.Verb }, 41 | emptyMetadataResult, 42 | definition.RequestDelegateGenerator 43 | ); 44 | if (definition.IsJsonRequest) 45 | { 46 | epBuilder.WithMetadata(new AcceptsMetadata(type: typeof(TRequestBody), isOptional: false, contentTypes: jsonContentType)); 47 | } 48 | } 49 | 50 | private static readonly Func emptyMetadataResult = 51 | (_, _) => new() { EndpointMetadata = new List() }; 52 | private static readonly string[] jsonContentType = new [] { "application/json" }; 53 | public static JsonOptions FallbackJsonOptions = new(); 54 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/BenchmarkWeb.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | Linux 8 | 9 | 10 | $(InterceptorsPreviewNamespaces);Reaper.Generated 11 | 12 | 13 | 14 | 15 | .dockerignore 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/Reaper/RequestDelegateSupport/RequestHelpers.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | namespace Reaper.RequestDelegateSupport; 4 | 5 | public static class RequestHelpers 6 | { 7 | public static bool TryConvertValue(object value, out T? result) 8 | { 9 | string sv = value.ToString()!; 10 | 11 | if (typeof(T) == typeof(string)) 12 | { 13 | result = (T)(object)sv; 14 | return true; 15 | } 16 | if (typeof(T) == typeof(int)) 17 | { 18 | if (int.TryParse(sv, CultureInfo.InvariantCulture, out var iv)) 19 | { 20 | result = (T)(object)iv; 21 | return true; 22 | } 23 | } else if (typeof(T) == typeof(double)) 24 | { 25 | if (double.TryParse(sv, CultureInfo.InvariantCulture, out var dv)) 26 | { 27 | result = (T)(object)dv; 28 | return true; 29 | } 30 | } else if (typeof(T) == typeof(float)) 31 | { 32 | if (float.TryParse(sv, CultureInfo.InvariantCulture, out var fv)) 33 | { 34 | result = (T) (object)fv; 35 | return true; 36 | } 37 | } else if (typeof(T) == typeof(decimal)) { 38 | if (decimal.TryParse(sv, CultureInfo.InvariantCulture, out var dv)) 39 | { 40 | result = (T) (object)dv; 41 | return true; 42 | } 43 | } else if (typeof(T) == typeof(bool)) 44 | { 45 | if (bool.TryParse(sv, out var bv)) 46 | { 47 | result = (T)(object)bv; 48 | return true; 49 | } 50 | } else if (typeof(T) == typeof(DateTime)) 51 | { 52 | if (DateTime.TryParse(sv, CultureInfo.InvariantCulture, out var dtv)) 53 | { 54 | result = (T)(object)dtv; 55 | return true; 56 | } 57 | } else if (typeof(T) == typeof(Guid)) 58 | { 59 | if (Guid.TryParse(sv, CultureInfo.InvariantCulture, out var g)) 60 | { 61 | result = (T)(object)g; 62 | return true; 63 | } 64 | } else if (typeof(T).IsEnum) 65 | { 66 | if (Enum.TryParse(typeof(T), sv, true, out var ev)) 67 | { 68 | result = (T) ev; 69 | return true; 70 | } 71 | } 72 | 73 | result = default; 74 | return false; 75 | } 76 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/Internal/CodeWriter.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace Reaper.SourceGenerator.Internal; 4 | 5 | public class CodeWriter 6 | { 7 | private readonly StringBuilder builder = new(); 8 | private readonly int indentAmount = 4; 9 | private int indentLevel; 10 | private bool indentPending = true; 11 | 12 | public CodeWriter() { } 13 | 14 | public CodeWriter(CodeWriter baseWriter) 15 | { 16 | indentLevel = baseWriter.indentAmount; 17 | } 18 | 19 | public void In() 20 | { 21 | indentLevel++; 22 | } 23 | 24 | public void Out() 25 | { 26 | indentLevel--; 27 | } 28 | 29 | public void Namespace(string name) 30 | { 31 | builder.Append("namespace "); 32 | builder.AppendLine(name); 33 | builder.AppendLine("{"); 34 | In(); 35 | } 36 | 37 | public void OpenBlock() 38 | { 39 | AppendLine("{"); 40 | In(); 41 | } 42 | 43 | public void CloseBlock() 44 | { 45 | Out(); 46 | AppendLine("}"); 47 | } 48 | 49 | public void StartClass(string name, string accessModifier = "public", string? bases = null, bool skipGenCode = false) 50 | { 51 | if (!skipGenCode) 52 | { 53 | AppendLine(GeneratorStatics.GeneratedCodeAttribute); 54 | } 55 | 56 | Append(accessModifier); 57 | Append(" class "); 58 | Append(name); 59 | if (bases != null) 60 | { 61 | Append(" : "); 62 | Append(bases); 63 | } 64 | 65 | AppendLine(""); 66 | AppendLine("{"); 67 | In(); 68 | } 69 | 70 | public void Append(string value) 71 | { 72 | if (indentPending) 73 | { 74 | indentPending = false; 75 | builder.Append(' ', indentLevel * indentAmount); 76 | } 77 | builder.Append(value); 78 | } 79 | 80 | public void Append(int value) 81 | { 82 | if (indentPending) 83 | { 84 | indentPending = false; 85 | builder.Append(' ', indentLevel * indentAmount); 86 | } 87 | builder.Append(value); 88 | } 89 | 90 | public void AppendLine(string value) 91 | { 92 | if (indentPending) 93 | { 94 | builder.Append(' ', indentLevel * indentAmount); 95 | } 96 | builder.AppendLine(value); 97 | indentPending = true; 98 | } 99 | 100 | public override string ToString() => builder.ToString(); 101 | } -------------------------------------------------------------------------------- /src/Reaper/RequestDelegateSupport/JsonBodyResolver.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization.Metadata; 2 | using Microsoft.AspNetCore.Http; 3 | 4 | namespace Reaper.RequestDelegateSupport; 5 | 6 | public static class JsonBodyResolver 7 | { 8 | public static async ValueTask<(bool, T?)> TryResolveBodyAsync(HttpContext httpContext, LogOrThrowExceptionHelper logOrThrowExceptionHelper, string parameterTypeName, string parameterName, JsonTypeInfo jsonTypeInfo, bool required = true) 9 | { 10 | var feature = httpContext.Features.Get(); 11 | T? bodyValue = default; 12 | var bodyValueSet = false; 13 | 14 | if (feature?.CanHaveBody == true) 15 | { 16 | if (!httpContext.Request.HasJsonContentType()) 17 | { 18 | logOrThrowExceptionHelper.UnexpectedJsonContentType(httpContext.Request.ContentType); 19 | httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; 20 | return (false, default); 21 | } 22 | try 23 | { 24 | bodyValue = await httpContext.Request.ReadFromJsonAsync(jsonTypeInfo); 25 | bodyValueSet = bodyValue != null; 26 | } 27 | catch (BadHttpRequestException badHttpRequestException) 28 | { 29 | logOrThrowExceptionHelper.RequestBodyIOException(badHttpRequestException); 30 | httpContext.Response.StatusCode = badHttpRequestException.StatusCode; 31 | return (false, default); 32 | } 33 | catch (IOException ioException) 34 | { 35 | logOrThrowExceptionHelper.RequestBodyIOException(ioException); 36 | httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; 37 | return (false, default); 38 | } 39 | catch (System.Text.Json.JsonException jsonException) 40 | { 41 | logOrThrowExceptionHelper.InvalidJsonRequestBody(parameterTypeName, parameterName, jsonException); 42 | httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; 43 | return (false, default); 44 | } 45 | } 46 | 47 | if (!bodyValueSet && required) 48 | { 49 | httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; 50 | return (false, bodyValue); 51 | } 52 | 53 | return (true, bodyValue); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/IntegrationTests/Tests/ReaperEndpointRXTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | using System.Text.Json; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Reaper.Response; 6 | using Reaper.TestWeb.Endpoints.ReaperEndpointRX; 7 | using ValidationProblemDetails = Reaper.Validation.Responses.ValidationProblemDetails; 8 | 9 | namespace IntegrationTests.Tests; 10 | 11 | // ReSharper disable once InconsistentNaming 12 | public abstract class ReaperEndpointRXTests(HttpClient client) 13 | { 14 | [Fact] 15 | public async Task ReflectorEndpointIsReflecting() 16 | { 17 | var expected = "Reflect me!"; 18 | var resp = await client.PostAsJsonAsync("/rerx/reflector", new ReflectorWriteEndpoint.ReflectorWriteRequest 19 | { 20 | Message = expected 21 | }); 22 | var str = await resp.Content.ReadAsStringAsync(); 23 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 24 | Assert.Equal(expected, str); 25 | } 26 | 27 | [Fact] 28 | public async Task MissingResponseBodyShouldThrow400() 29 | { 30 | var resp = await client.PostAsync("/rerx/validator", null); 31 | var details = await resp.Content.ReadFromJsonAsync(); 32 | Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); 33 | Assert.Equal("application/problem+json", resp.Content.Headers.ContentType?.MediaType); 34 | Assert.NotNull(details); 35 | Assert.Equal("https://reaper.divergent.dev/validation/body-required-not-provided", details.Type); 36 | } 37 | 38 | [Fact] 39 | public async Task InvalidValidationShouldThrow400() 40 | { 41 | var resp = await client.PostAsJsonAsync("/rerx/validator", new ValidatorWriteRequest 42 | { 43 | Message = null 44 | }); 45 | var details = await resp.Content.ReadFromJsonAsync(); 46 | Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); 47 | Assert.Equal("application/problem+json", resp.Content.Headers.ContentType?.MediaType); 48 | Assert.NotNull(details); 49 | Assert.Equal("https://reaper.divergent.dev/validation/failure", details.Type); 50 | Assert.Single(details.ValidationFailures!); 51 | } 52 | 53 | [Fact] 54 | public async Task ValidValidationShouldNotThrow400() 55 | { 56 | var resp = await client.PostAsJsonAsync("/rerx/validator", new ValidatorWriteRequest 57 | { 58 | Message = "Hello" 59 | }); 60 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 61 | } 62 | 63 | public class ExpectedValidationFailure 64 | { 65 | public string Key { get; set; } 66 | public string Code { get; set; } 67 | public string Error { get; set; } 68 | } 69 | } -------------------------------------------------------------------------------- /benchmarks/BenchmarkWeb/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Runtime.CompilerServices; 3 | using System.Text.Json.Serialization; 4 | using BenchmarkWeb.Dtos; 5 | using BenchmarkWeb.Services; 6 | 7 | #if REAPER 8 | using Reaper; 9 | #elif FASTEP 10 | using FastEndpoints; 11 | #elif CARTER 12 | using Carter; 13 | #elif MINIMAL 14 | using Microsoft.AspNetCore.Mvc; 15 | #endif 16 | 17 | [assembly:InternalsVisibleTo("Benchmarker")] 18 | 19 | var sw = new Stopwatch(); 20 | sw.Start(); 21 | 22 | var builder = WebApplication.CreateSlimBuilder(args); 23 | 24 | // A service for more real world example 25 | builder.Services.AddSingleton(); 26 | 27 | #if REAPER 28 | builder.UseReaper(); 29 | #elif FASTEP 30 | builder.Services.AddFastEndpoints(); 31 | #elif CARTER 32 | builder.Services.AddCarter(); 33 | #elif CTRL 34 | builder.Services.AddControllers(); 35 | #elif MINIMAL 36 | builder.Services.ConfigureHttpJsonOptions(options => 37 | { 38 | options.SerializerOptions.TypeInfoResolverChain.Insert( 39 | 0, AppJsonSerializerContext.Default); 40 | }); 41 | #endif 42 | 43 | var app = builder.Build(); 44 | app.Lifetime.ApplicationStarted.Register(() => 45 | { 46 | sw.Stop(); 47 | Console.WriteLine("BenchmarkWeb Startup: " + sw.ElapsedMilliseconds); 48 | }); 49 | 50 | #if MINIMAL 51 | app.MapGet("/ep", async (GetMeAStringService svc) => await svc.GetMeAString()); 52 | app.MapGet("/typical/dosomething", () => TypedResults.Ok()); 53 | app.MapPost("/typical/acceptsomething", (SampleRequest req) => TypedResults.Ok()); 54 | app.MapPost("/typical/returnsomething", (SampleRequest req) => new SampleResponse 55 | { 56 | Output = req.Input, 57 | SomeOtherOutput = req.SomeOtherInput, 58 | SomeBool = req.SomeBool, 59 | GeneratedAt = DateTime.UtcNow 60 | }); 61 | app.MapGet("/anothertypical/dosomething", () => TypedResults.Ok()); 62 | app.MapPost("/anothertypical/acceptsomething", (SampleRequest req) => TypedResults.Ok()); 63 | app.MapPost("/anothertypical/returnsomething", (SampleRequest req) => new SampleResponse 64 | { 65 | Output = req.Input, 66 | SomeOtherOutput = req.SomeOtherInput, 67 | SomeBool = req.SomeBool, 68 | GeneratedAt = DateTime.UtcNow 69 | }); 70 | #elif REAPER 71 | app.UseReaperMiddleware(); 72 | app.MapReaperEndpoints(); 73 | #elif FASTEP 74 | app.UseFastEndpoints(); 75 | #elif CARTER 76 | app.MapCarter(); 77 | #elif CTRL 78 | app.MapControllers(); 79 | #endif 80 | 81 | app.Run(); 82 | 83 | namespace BenchmarkWeb 84 | { 85 | public partial class Program 86 | { 87 | } 88 | } 89 | 90 | #if MINIMAL 91 | [JsonSerializable(typeof(SampleRequest))] 92 | [JsonSerializable(typeof(SampleResponse))] 93 | public partial class AppJsonSerializerContext : System.Text.Json.Serialization.JsonSerializerContext 94 | { 95 | 96 | } 97 | #endif -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/Internal/GeneratorStatics.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | namespace Reaper.SourceGenerator.Internal; 4 | 5 | public static class GeneratorStatics 6 | { 7 | public static AssemblyName AssemblyName = Assembly.GetExecutingAssembly().GetName(); 8 | public static string AssemblyFullName = AssemblyName.FullName; 9 | public static string AssemblyVersion = AssemblyName.Version.ToString(); 10 | 11 | public const string StepEndpoints = "Endpoints"; 12 | public const string StepMapper = "Mapper"; 13 | public const string StepServices = "Services"; 14 | public const string StepValidators = "Validators"; 15 | 16 | public const string FileHeader = """ 17 | //------------------------------------------------------------------------------ 18 | // 19 | // This code was generated by a cool tool. 20 | // 21 | // Changes to this file may cause incorrect behavior and will be lost if 22 | // the code is regenerated. 23 | // 24 | //------------------------------------------------------------------------------ 25 | 26 | #nullable enable 27 | 28 | """; 29 | 30 | public static string CodeInterceptorAttribute = $$""" 31 | namespace System.Runtime.CompilerServices 32 | { 33 | [System.CodeDom.Compiler.GeneratedCodeAttribute("{{AssemblyFullName}}", "{{AssemblyVersion}}")] 34 | [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] 35 | file sealed class InterceptsLocationAttribute : Attribute 36 | { 37 | public InterceptsLocationAttribute(string filePath, int line, int column) 38 | { 39 | } 40 | } 41 | } 42 | 43 | """; 44 | 45 | public static string GeneratedCodeAttribute = $"[System.CodeDom.Compiler.GeneratedCodeAttribute(\"{AssemblyFullName}\", \"{AssemblyVersion}\")]"; 46 | 47 | public const string MethodImplAggressiveInlining = "[MethodImpl(MethodImplOptions.AggressiveInlining)]"; 48 | } -------------------------------------------------------------------------------- /src/Reaper.Validation/Handlers/FluentValidationFailureHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization.Metadata; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Options; 6 | using Reaper.Context; 7 | using Reaper.RequestDelegateSupport; 8 | using Reaper.Validation; 9 | using Reaper.Validation.Context; 10 | using JsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions; 11 | using ValidationProblemDetails = Reaper.Validation.Responses.ValidationProblemDetails; 12 | 13 | namespace Reaper.Handlers; 14 | 15 | // ReSharper disable once ClassWithVirtualMembersNeverInherited.Global 16 | public class FluentValidationFailureHandler(IReaperExecutionContextProvider reaperExecutionContextProvider) : IValidationFailureHandler 17 | { 18 | private readonly IReaperExecutionContext reaperExecutionContext = reaperExecutionContextProvider.Context; 19 | 20 | public virtual async Task HandleValidationFailure() 21 | { 22 | if (reaperExecutionContext.ValidationContext is not FluentValidationContext validationContext) 23 | { 24 | throw new InvalidOperationException("FluentValidationContext is not available."); 25 | } 26 | 27 | var httpContext = reaperExecutionContext.HttpContext; 28 | var jsonOptions = httpContext.RequestServices.GetService>()!.Value; 29 | var jsonTypeInfo = (JsonTypeInfo)jsonOptions.SerializerOptions.GetTypeInfo(typeof(ProblemDetails)); 30 | var validationJsonTypeInfo = (JsonTypeInfo)jsonOptions.SerializerOptions.GetTypeInfo(typeof(ValidationProblemDetails)); 31 | 32 | switch (validationContext.FailureType) 33 | { 34 | case RequestValidationFailureType.None: 35 | // No action, we shouldn't be here. 36 | return; 37 | case RequestValidationFailureType.BodyRequiredNotProvided: 38 | httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; 39 | await ResponseHelpers.ExecuteReturnAsync(new ProblemDetails() 40 | { 41 | Type = "https://reaper.divergent.dev/validation/body-required", 42 | Title = "Request body is required.", 43 | }, httpContext, jsonTypeInfo, "application/problem+json"); 44 | break; 45 | case RequestValidationFailureType.UserDefinedValidationFailure: 46 | httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; 47 | var validationFailures = validationContext.ValidationResult!.Errors.GroupBy(m => m.PropertyName) 48 | .ToDictionary(m => m.Key, m => string.Join(", ", m.Select(n => n.ErrorMessage))); 49 | await ResponseHelpers.ExecuteReturnAsync(new ValidationProblemDetails 50 | { 51 | Type = "https://reaper.divergent.dev/validation/failure", 52 | Title = "Validation failed for this request.", 53 | ValidationFailures = validationFailures 54 | }, httpContext, validationJsonTypeInfo, "application/problem+json"); 55 | break; 56 | default: 57 | throw new ArgumentOutOfRangeException(); 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/Reaper/ReaperEndpoint.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Reaper.Context; 4 | using Reaper.Response; 5 | using Reaper.Validation; 6 | 7 | namespace Reaper; 8 | 9 | public interface IReaperEndpoint 10 | { 11 | void SetContextProvider(IReaperExecutionContextProvider provider); 12 | 13 | IReaperExecutionContext ReaperExecutionContext { get; } 14 | HttpContext Context { get; } 15 | HttpRequest Request { get; } 16 | HttpResponse Response { get; } 17 | IReaperValidationContext ValidationContext { get; } 18 | } 19 | 20 | public abstract class ReaperEndpointBase : IReaperEndpoint 21 | { 22 | private IReaperExecutionContextProvider? reaperExecutionContextProvider; 23 | 24 | public void SetContextProvider(IReaperExecutionContextProvider provider) 25 | { 26 | reaperExecutionContextProvider = provider; 27 | } 28 | 29 | public IReaperExecutionContext ReaperExecutionContext => reaperExecutionContextProvider!.Context; 30 | public HttpContext Context => reaperExecutionContextProvider!.Context.HttpContext; 31 | 32 | public HttpRequest Request => Context.Request; 33 | public HttpResponse Response => Context.Response; 34 | 35 | public IReaperValidationContext ValidationContext => ReaperExecutionContext.ValidationContext; 36 | 37 | protected T Resolve() where T : notnull => reaperExecutionContextProvider!.Context.HttpContext.RequestServices.GetRequiredService(); 38 | 39 | protected async Task Ok() => await ReaperEndpointExtensions.Ok(this); 40 | protected async Task Ok(T? result) => await ReaperEndpointExtensions.Ok(this, result); 41 | 42 | protected async Task BadRequest() => await ReaperEndpointExtensions.BadRequest(this); 43 | protected async Task BadRequest(T? result) => await ReaperEndpointExtensions.BadRequest(this, result); 44 | 45 | protected async Task NotFound() => await ReaperEndpointExtensions.NotFound(this); 46 | protected async Task NotFound(T? result) => await ReaperEndpointExtensions.NotFound(this, result); 47 | 48 | protected async Task InternalServerError() => await ReaperEndpointExtensions.InternalServerError(this); 49 | protected async Task InternalServerError(T? result) => await ReaperEndpointExtensions.InternalServerError(this, result); 50 | 51 | protected async Task StatusCode(int statusCode) => await ReaperEndpointExtensions.StatusCode(this, statusCode); 52 | protected async Task StatusCode(int statusCode, T? result) => await ReaperEndpointExtensions.StatusCode(this, statusCode, result); 53 | } 54 | 55 | public abstract class ReaperEndpoint : ReaperEndpointBase 56 | { 57 | public abstract Task ExecuteAsync(); 58 | } 59 | 60 | // ReSharper disable once InconsistentNaming 61 | public abstract class ReaperEndpointRX : ReaperEndpointBase 62 | { 63 | public abstract Task ExecuteAsync(TRequest request); 64 | } 65 | 66 | // ReSharper disable once InconsistentNaming 67 | public abstract class ReaperEndpointXR : ReaperEndpointBase 68 | { 69 | public TResponse? Result { get; protected set; } 70 | 71 | public abstract Task ExecuteAsync(); 72 | } 73 | 74 | public abstract class ReaperEndpoint : ReaperEndpointBase 75 | { 76 | public TResponse? Result { get; protected set; } 77 | 78 | public abstract Task ExecuteAsync(TRequest request); 79 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/RoslynHelpers/WellKnownTypes.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | // ReSharper disable InconsistentNaming 3 | 4 | namespace Reaper.SourceGenerator.RoslynHelpers; 5 | 6 | internal class WellKnownTypes(Compilation compilation) 7 | { 8 | private readonly Lazy reaperEndpoint = new(() => compilation.GetTypeByMetadataName("Reaper.ReaperEndpoint")!); 9 | internal INamedTypeSymbol ReaperEndpoint => reaperEndpoint.Value; 10 | 11 | private readonly Lazy reaperEndpointRR = new(() => compilation.GetTypeByMetadataName("Reaper.ReaperEndpoint`2")!); 12 | internal INamedTypeSymbol ReaperEndpointRR => reaperEndpointRR.Value; 13 | 14 | private readonly Lazy reaperEndpointRX = new(() => compilation.GetTypeByMetadataName("Reaper.ReaperEndpointRX`1")!); 15 | internal INamedTypeSymbol ReaperEndpointRX => reaperEndpointRX.Value; 16 | 17 | private readonly Lazy reaperEndpointXR = new(() => compilation.GetTypeByMetadataName("Reaper.ReaperEndpointXR`1")!); 18 | internal INamedTypeSymbol ReaperEndpointXR => reaperEndpointXR.Value; 19 | 20 | private readonly Lazy reaperEndpointBase = new(() => compilation.GetTypeByMetadataName("Reaper.ReaperEndpointBase")!); 21 | internal INamedTypeSymbol ReaperEndpointBase => reaperEndpointBase.Value; 22 | 23 | private readonly Lazy reaperRouteAttribute = new(() => compilation.GetTypeByMetadataName("Reaper.Attributes.ReaperRouteAttribute")!); 24 | internal INamedTypeSymbol ReaperRouteAttribute => reaperRouteAttribute.Value; 25 | 26 | private readonly Lazy reaperScopedAttribute = new(() => compilation.GetTypeByMetadataName("Reaper.Attributes.ReaperScopedAttribute")!); 27 | internal INamedTypeSymbol ReaperScopedAttribute => reaperScopedAttribute.Value; 28 | 29 | private readonly Lazy reaperForceHandlerAttribute = new(() => compilation.GetTypeByMetadataName("Reaper.Attributes.ReaperForceHandlerAttribute")!); 30 | internal INamedTypeSymbol ReaperForceHandlerAttribute => reaperForceHandlerAttribute.Value; 31 | 32 | private readonly Lazy aspnetFromBodyAttribute = new(() => compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Mvc.FromBodyAttribute")!); 33 | internal INamedTypeSymbol AspNetFromBodyAttribute => aspnetFromBodyAttribute.Value; 34 | 35 | private readonly Lazy aspnetFromRouteAttribute = new(() => compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Mvc.FromRouteAttribute")!); 36 | internal INamedTypeSymbol AspNetFromRouteAttribute => aspnetFromRouteAttribute.Value; 37 | 38 | private readonly Lazy aspnetFromQueryAttribute = new(() => compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Mvc.FromQueryAttribute")!); 39 | internal INamedTypeSymbol AspNetFromQueryAttribute => aspnetFromQueryAttribute.Value; 40 | 41 | private readonly Lazy stringType = new(() => compilation.GetTypeByMetadataName("System.String")!); 42 | internal INamedTypeSymbol StringType => stringType.Value; 43 | 44 | private readonly Lazy reaperRequestValidator = new(() => compilation.GetTypeByMetadataName("Reaper.Validation.RequestValidator`1")); 45 | internal INamedTypeSymbol? ReaperRequestValidator => reaperRequestValidator.Value; 46 | 47 | private static WellKnownTypes? instance; 48 | internal static WellKnownTypes GetOrCreate(Compilation compilation) 49 | { 50 | if (instance == default) 51 | { 52 | instance = new WellKnownTypes(compilation); 53 | } 54 | 55 | return instance; 56 | } 57 | } -------------------------------------------------------------------------------- /benchmarks/Benchmarker/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using System.Text.Json; 3 | using Benchmarker; 4 | using CommandLine; 5 | using Spectre.Console; 6 | 7 | var parseOpts = Parser.Default.ParseArguments(args); 8 | var opts = parseOpts.Value; 9 | if (opts == null) 10 | { 11 | return; 12 | } 13 | 14 | var tests = opts.RunTests.ToList(); 15 | 16 | if (opts.AllTests) 17 | { 18 | tests = ["carter", "controllers", "fastendpoints", "minimal", "minimal-aot", "reaper", "reaper-aot"]; 19 | } 20 | 21 | AnsiConsole.MarkupLine("[bold]💀 Reaper Benchmarker[/]"); 22 | AnsiConsole.MarkupLine("[dim][bold]{0}[/] threads, [bold]{1}[/] HTTP connections, for [bold]{2}[/] seconds[/]", opts.Threads, opts.Http, opts.Duration); 23 | AnsiConsole.MarkupLine("[dim]Included tests: [bold]{0}[/][/]", string.Join(", ", tests)); 24 | 25 | var testRunner = new TestRunner(); 26 | await testRunner.InitialiseAsync(); 27 | 28 | List output = []; 29 | 30 | foreach(var test in tests) 31 | { 32 | output.Add(await testRunner.ExecuteTestAsync(test)); 33 | } 34 | 35 | // Carter 36 | //output.Add(await testRunner.ExecuteTestAsync("carter")); 37 | // Controllers 38 | //output.Add(await testRunner.ExecuteTestAsync("controllers")); 39 | // FastEndpoints 40 | //output.Add(await testRunner.ExecuteTestAsync("fastendpoints")); 41 | // Minimal 42 | //output.Add(await testRunner.ExecuteTestAsync("minimal")); 43 | // Minimal AOT 44 | //output.Add(await testRunner.ExecuteTestAsync("minimal-aot")); 45 | // Reaper 46 | //output.Add(await testRunner.ExecuteTestAsync("reaper")); 47 | // Reaper AOT 48 | //output.Add(await testRunner.ExecuteTestAsync("reaper-aot")); 49 | 50 | var csvOut = new StringBuilder(); 51 | csvOut.AppendLine("Framework,Startup Time,Memory Usage (MiB) - Startup,Memory Usage (MiB) - Load Test,Requests/sec"); 52 | foreach(var result in output) { 53 | csvOut.AppendLine($"{result.Container},{result.StartupTimeMs},{result.MemoryStartup},{result.MemoryLoadTest},{result.RequestsSec}"); 54 | } 55 | var csv = csvOut.ToString(); 56 | 57 | if (!opts.NoFile) 58 | { 59 | var fileName = $"benchmark-run-{DateTime.Now:dd-MMM_HH-mm-ss}.csv"; 60 | File.WriteAllText(fileName, csv); 61 | AnsiConsole.MarkupLine($"[dim]Output written to {fileName}[/]"); 62 | } 63 | 64 | if (AnsiConsole.Confirm("Do you want some output in this console too?")) 65 | { 66 | var fmt = AnsiConsole.Prompt(new SelectionPrompt() 67 | .Title("Which format?") 68 | .AddChoices("json", "csv")); 69 | if (fmt == "csv") 70 | { 71 | AnsiConsole.WriteLine(csv); 72 | } 73 | else 74 | { 75 | AnsiConsole.WriteLine(JsonSerializer.Serialize(output, new JsonSerializerOptions 76 | { 77 | WriteIndented = true 78 | })); 79 | } 80 | } 81 | 82 | public class BenchmarkerOptions 83 | { 84 | [Option('t', "threads", Required = false, HelpText = "Threads to use for wrk", Default = 10)] 85 | public int Threads { get; set; } 86 | [Option('h', "http", Required = false, HelpText = "HTTP Connections to use for wrk", Default = 100)] 87 | public int Http { get; set; } 88 | [Option('d', "duration", Required = false, HelpText = "Seconds to run wrk for", Default = 7)] 89 | public int Duration { get; set; } 90 | 91 | [Option('a', "all", Required = false, HelpText = "Run all tests", Default = false)] 92 | public bool AllTests { get; set; } 93 | 94 | [Option("no-file", Required = false, HelpText = "Don't write output to a file automatically", Default = false)] 95 | public bool NoFile { get; set; } 96 | 97 | [Option('r', "run", Required = false, Separator = ',', 98 | HelpText = "Tests to run (carter, controllers, fastendpoints, minimal, minimal-aot, reaper, reaper-aot", 99 | Default = new [] { "fastendpoints", "minimal", "reaper" })] 100 | public IEnumerable RunTests { get; set; } 101 | } -------------------------------------------------------------------------------- /src/Reaper/Response/ReaperEndpointExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization.Metadata; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Http.Json; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Options; 6 | using Reaper.RequestDelegateSupport; 7 | 8 | namespace Reaper.Response; 9 | 10 | public static class ReaperEndpointExtensions 11 | { 12 | public static async Task StatusCode(this IReaperEndpoint endpoint, int statusCode) 13 | { 14 | endpoint.Response.StatusCode = statusCode; 15 | } 16 | 17 | public static async Task StatusCode(this IReaperEndpoint endpoint, int statusCode, T? result = default) 18 | { 19 | await endpoint.StatusCode(statusCode); 20 | await ExecuteResponseAsync(endpoint.Context, result); 21 | } 22 | 23 | public static Task Ok(this IReaperEndpoint endpoint) 24 | { 25 | return endpoint.StatusCode(StatusCodes.Status200OK); 26 | } 27 | 28 | public static async Task Ok(this IReaperEndpoint endpoint, T? result = default) 29 | { 30 | await endpoint.Ok(); 31 | await ExecuteResponseAsync(endpoint.Context, result); 32 | } 33 | 34 | public static Task BadRequest(this IReaperEndpoint endpoint) 35 | { 36 | return endpoint.StatusCode(StatusCodes.Status400BadRequest); 37 | } 38 | 39 | public static async Task BadRequest(this IReaperEndpoint endpoint, T? result = default) 40 | { 41 | await endpoint.BadRequest(); 42 | await ExecuteResponseAsync(endpoint.Context, result); 43 | } 44 | 45 | public static Task NotFound(this IReaperEndpoint endpoint) 46 | { 47 | return endpoint.StatusCode(StatusCodes.Status404NotFound); 48 | } 49 | 50 | public static async Task NotFound(this IReaperEndpoint endpoint, T? result = default) 51 | { 52 | await endpoint.NotFound(); 53 | await ExecuteResponseAsync(endpoint.Context, result); 54 | } 55 | 56 | public static Task InternalServerError(this IReaperEndpoint endpoint) 57 | { 58 | return endpoint.StatusCode(StatusCodes.Status500InternalServerError); 59 | } 60 | 61 | public static async Task InternalServerError(this IReaperEndpoint endpoint, T? result = default) 62 | { 63 | await endpoint.InternalServerError(); 64 | await ExecuteResponseAsync(endpoint.Context, result); 65 | } 66 | 67 | public static Task NoContent(this IReaperEndpoint endpoint) 68 | { 69 | return endpoint.StatusCode(StatusCodes.Status204NoContent); 70 | } 71 | 72 | public static Task Created(this IReaperEndpoint endpoint) 73 | { 74 | return endpoint.StatusCode(StatusCodes.Status201Created); 75 | } 76 | 77 | public static async Task Created(this IReaperEndpoint endpoint, T? result = default) 78 | { 79 | await endpoint.Created(); 80 | await ExecuteResponseAsync(endpoint.Context, result); 81 | } 82 | 83 | private static async Task ExecuteResponseAsync(HttpContext httpContext, T? response = default) 84 | { 85 | if (response == null) 86 | { 87 | await httpContext.Response.CompleteAsync(); 88 | return; 89 | } 90 | if (response is IResult r) 91 | { 92 | await r.ExecuteAsync(httpContext); 93 | } 94 | else if (response is string s) 95 | { 96 | await httpContext.Response.WriteAsync(s); 97 | } 98 | else 99 | { 100 | var jsonOptions = httpContext.RequestServices.GetRequiredService>().Value; 101 | var jsonTypeInfo = (JsonTypeInfo)jsonOptions.SerializerOptions.GetTypeInfo(typeof(T)); 102 | await ResponseHelpers.WriteJsonResponseAsync(httpContext.Response, response, jsonTypeInfo); 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /tests/IntegrationTests/Tests/ReaperEndpointTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | using Reaper.TestWeb.Endpoints.ReaperEndpoint; 4 | 5 | namespace IntegrationTests.Tests; 6 | 7 | public abstract class ReaperEndpointTests(HttpClient client) 8 | { 9 | [Fact] 10 | public async Task NoneEndpointIs200Ok() 11 | { 12 | var resp = await client.GetAsync("/re/none"); 13 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 14 | } 15 | 16 | [Fact] 17 | public async Task StringWriteEndpointIsWriting() 18 | { 19 | var resp = await client.GetAsync("/re/string"); 20 | var str = await resp.Content.ReadAsStringAsync(); 21 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 22 | Assert.Equal("Hello, World!", str); 23 | } 24 | 25 | [Fact] 26 | public async Task SingletonIncrementorEndpointIsIncrementing() 27 | { 28 | var resp = await client.GetAsync("/re/singleton"); 29 | var str = await resp.Content.ReadAsStringAsync(); 30 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 31 | Assert.Equal("Hello, World! Counter: 1", str); 32 | resp = await client.GetAsync("/re/singleton"); 33 | str = await resp.Content.ReadAsStringAsync(); 34 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 35 | Assert.Equal("Hello, World! Counter: 2", str); 36 | } 37 | 38 | [Fact] 39 | public async Task ScopedIncrementorEndpointIsNotIncrementing() 40 | { 41 | var resp = await client.GetAsync("/re/scoped"); 42 | var str = await resp.Content.ReadAsStringAsync(); 43 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 44 | Assert.Equal("Hello, World! Counter: 1", str); 45 | resp = await client.GetAsync("/re/scoped"); 46 | str = await resp.Content.ReadAsStringAsync(); 47 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 48 | Assert.Equal("Hello, World! Counter: 1", str); 49 | } 50 | 51 | [Fact] 52 | public async Task Status200Is200() 53 | { 54 | var resp = await client.GetAsync("/re/200"); 55 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 56 | } 57 | 58 | [Fact] 59 | public async Task Status400Is400() 60 | { 61 | var resp = await client.GetAsync("/re/400"); 62 | Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); 63 | } 64 | 65 | [Fact] 66 | public async Task Status404Is404() 67 | { 68 | var resp = await client.GetAsync("/re/404"); 69 | Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); 70 | } 71 | 72 | [Fact] 73 | public async Task Status200WriterIs200() 74 | { 75 | var resp = await client.GetAsync("/re/w200"); 76 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 77 | Assert.Equal("Hello, World!", await resp.Content.ReadAsStringAsync()); 78 | } 79 | 80 | [Fact] 81 | public async Task Status400WriterIs400() 82 | { 83 | var resp = await client.GetAsync("/re/w400"); 84 | Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); 85 | Assert.Equal("Hello, World!", await resp.Content.ReadAsStringAsync()); 86 | } 87 | 88 | [Fact] 89 | public async Task Status404WriterIs404() 90 | { 91 | var resp = await client.GetAsync("/re/w404"); 92 | Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); 93 | Assert.Equal("Hello, World!", await resp.Content.ReadAsStringAsync()); 94 | } 95 | 96 | [Fact] 97 | public async Task Status200JsonWriterIs200() 98 | { 99 | var resp = await client.GetAsync("/re/j200"); 100 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 101 | var sampleResponse = new SampleResponse 102 | { 103 | Message = "Hello, World!" 104 | }; 105 | Assert.Equal(sampleResponse, await resp.Content.ReadFromJsonAsync()); 106 | } 107 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/ServicesInterceptor/ServicesInterceptorGenerator.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using System.Text; 3 | using Microsoft.CodeAnalysis.Operations; 4 | using Microsoft.CodeAnalysis.Text; 5 | using Reaper.SourceGenerator.Internal; 6 | using Reaper.SourceGenerator.ReaperEndpoints; 7 | 8 | namespace Reaper.SourceGenerator.ServicesInterceptor; 9 | 10 | internal class ServicesInterceptorGenerator(ImmutableArray endpoints, IInvocationOperation servicesOperation) 11 | { 12 | private readonly CodeWriter codeWriter = new(); 13 | 14 | internal SourceText Generate() 15 | { 16 | var location = servicesOperation.GetInvocationLocation(); 17 | 18 | codeWriter.AppendLine(GeneratorStatics.FileHeader); 19 | codeWriter.AppendLine(GeneratorStatics.CodeInterceptorAttribute); 20 | codeWriter.Namespace("Reaper.Generated"); 21 | codeWriter.AppendLine("using Microsoft.Extensions.DependencyInjection.Extensions;"); 22 | codeWriter.AppendLine("using System.Runtime.CompilerServices;"); 23 | codeWriter.AppendLine("using Reaper.Context;"); 24 | codeWriter.AppendLine("using Reaper.Handlers;"); 25 | codeWriter.AppendLine("using Reaper.Validation;"); 26 | codeWriter.AppendLine("using Reaper.Validation.Context;"); 27 | codeWriter.StartClass("ServiceAdditionInterceptor", "file static"); 28 | codeWriter.Append("[InterceptsLocation(\""); 29 | codeWriter.Append(location.file); 30 | codeWriter.Append("\", "); 31 | codeWriter.Append(location.line); 32 | codeWriter.Append(", "); 33 | codeWriter.Append(location.pos); 34 | codeWriter.AppendLine(")]"); 35 | codeWriter.AppendLine("public static void UseReaper_Impl(this WebApplicationBuilder app, Action? configure = null)"); 36 | codeWriter.AppendLine("{"); 37 | codeWriter.In(); 38 | codeWriter.AppendLine("var options = new ReaperOptions();"); 39 | codeWriter.AppendLine("configure?.Invoke(options);"); 40 | codeWriter.AppendLine("app.Services.TryAddTransient();"); 41 | codeWriter.AppendLine("app.Services.TryAddTransient();"); 42 | codeWriter.AppendLine("app.Services.TryAddSingleton();"); 43 | codeWriter.AppendLine(string.Empty); 44 | 45 | if (endpoints.Any(m => m.HasRequest || m.HasResponse)) 46 | { 47 | codeWriter.AppendLine("app.Services.ConfigureHttpJsonOptions(options =>"); 48 | codeWriter.In(); 49 | codeWriter.AppendLine("{"); 50 | codeWriter.In(); 51 | codeWriter.AppendLine("options.SerializerOptions.TypeInfoResolverChain.Insert(0, ReaperJsonSerializerContext.Default);"); 52 | codeWriter.Out(); 53 | codeWriter.AppendLine("});"); 54 | codeWriter.Out(); 55 | } 56 | 57 | codeWriter.AppendLine("app.Services.TryAddScoped();"); 58 | codeWriter.AppendLine(string.Empty); 59 | 60 | codeWriter.AppendLine("// Endpoints"); 61 | 62 | var validEndpoints = endpoints.Where(m => m.IsFullyConfigured).ToList(); 63 | 64 | foreach (var endpoint in validEndpoints) 65 | { 66 | codeWriter.Append("app.Services.AddReaperEndpoint<"); 67 | codeWriter.Append(endpoint.TypeName); 68 | if (endpoint.IsScoped) 69 | { 70 | codeWriter.AppendLine(">(ServiceLifetime.Scoped);"); 71 | } 72 | else 73 | { 74 | codeWriter.AppendLine(">();"); 75 | } 76 | 77 | if (endpoint.HasRequestValidator) 78 | { 79 | codeWriter.Append("app.Services.AddSingleton<"); 80 | codeWriter.Append(endpoint.RequestValidatorTypeName); 81 | codeWriter.AppendLine(">();"); 82 | } 83 | } 84 | 85 | codeWriter.CloseBlock(); 86 | codeWriter.CloseBlock(); 87 | codeWriter.CloseBlock(); 88 | 89 | return SourceText.From(codeWriter.ToString(), Encoding.UTF8); 90 | } 91 | } -------------------------------------------------------------------------------- /tests/IntegrationTests/Tests/ReaperEndpointRRTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | using DotNet.Testcontainers.Configurations; 4 | using Reaper.TestWeb.Endpoints.ReaperEndpoint_ReqResp; 5 | 6 | namespace IntegrationTests.Tests; 7 | 8 | // ReSharper disable once InconsistentNaming 9 | public abstract class ReaperEndpointRRTests(HttpClient client) 10 | { 11 | [Fact] 12 | public async Task ReflectorEndpointIsReflecting() 13 | { 14 | var expected = new ReflectorEndpoint.ReflectorRequestResponse() 15 | { 16 | Message = "Reflect Me!" 17 | }; 18 | var resp = await client.PostAsJsonAsync("/rerr/reflector", expected); 19 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 20 | var json = await resp.Content.ReadFromJsonAsync(); 21 | Assert.Equivalent(expected, json); 22 | } 23 | 24 | [Fact] 25 | public async Task FromSourcesEndpointIsFromSourcing() 26 | { 27 | var requestBody = new FromSourcesEndpoint.FromSourceRequestResponse.JsonBound 28 | { 29 | AnotherTest = 4, 30 | Test = "Help me, I'm stuck in some JSON!" 31 | }; 32 | var expected = new FromSourcesEndpoint.FromSourceRequestResponse 33 | { 34 | Test = 1, 35 | TestString = "Hello", 36 | QueryValue = 2, 37 | AnotherQueryValue = 3, 38 | Json = requestBody 39 | }; 40 | var resp = await client.PostAsJsonAsync("/rerr/fromsource/1/Hello?queryValue=2&another=3", requestBody); 41 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 42 | var json = await resp.Content.ReadFromJsonAsync(); 43 | Assert.Equivalent(expected, json); 44 | } 45 | 46 | [Fact] 47 | public async Task ConstrainedRouteIsConstraining() 48 | { 49 | var guid = Guid.NewGuid(); 50 | var expected = new ConstrainedRouteEndpoint.ConstrainedRouteEndpointRequestResponse 51 | { 52 | First = 1, 53 | Second = guid, 54 | Third = DateTime.Today, 55 | Fourth = true, 56 | Fifth = "abc", 57 | Sixth = 6 58 | }; 59 | 60 | var resp = await client.GetAsync($"/rerr/croute/1/{guid}/{DateTime.Today:yyyy-MM-dd}/true/abc/6"); 61 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 62 | var json = await resp.Content.ReadFromJsonAsync(); 63 | Assert.Equivalent(expected, json); 64 | resp = await client.GetAsync($"/rerr/croute/1/notaguid/{DateTime.Today:yyyy-MM-dd}/true/abc/6"); 65 | Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); 66 | resp = await client.GetAsync($"/rerr/croute/1/{guid}/1234/true/abc/6"); 67 | Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); 68 | resp = await client.GetAsync($"/rerr/croute/1/{guid}/{DateTime.Today:yyyy-MM-dd}/test/abc/6"); 69 | Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); 70 | } 71 | 72 | [Fact] 73 | public async Task OptionalSourcesRouteIsOptional() 74 | { 75 | var requestBody = new OptionalFromSourcesEndpoint.OptionalFromSourcesRequestResponse.JsonBoundOptional 76 | { 77 | AnotherTest = 4, 78 | Test = "Help me, I'm stuck in some JSON!" 79 | }; 80 | var expected = new OptionalFromSourcesEndpoint.OptionalFromSourcesRequestResponse 81 | { 82 | Test = 1, 83 | TestString = "Hello", 84 | QueryValue = 2, 85 | AnotherQueryValue = 3, 86 | Json = requestBody 87 | }; 88 | var resp = await client.PostAsJsonAsync("/rerr/optfsource/1/Hello?queryValue=2&another=3", requestBody); 89 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 90 | var json = await resp.Content.ReadFromJsonAsync(); 91 | Assert.Equivalent(expected, json); 92 | 93 | // Optional Route/Query Values 94 | expected.TestString = null; 95 | expected.QueryValue = null; 96 | resp = await client.PostAsJsonAsync("/rerr/optfsource/1?another=3", requestBody); 97 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 98 | json = await resp.Content.ReadFromJsonAsync(); 99 | Assert.Equivalent(expected, json); 100 | 101 | 102 | expected.Json = null; 103 | resp = await client.PostAsJsonAsync("/rerr/optfsource/1?another=3", (object?)null); 104 | Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 105 | json = await resp.Content.ReadFromJsonAsync(); 106 | Assert.Equivalent(expected, json); 107 | } 108 | } -------------------------------------------------------------------------------- /Reaper.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Reaper", "src\Reaper\Reaper.csproj", "{4EA32884-C315-405F-AF6B-3CE9AFB0B717}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Reaper.SourceGenerator", "src\Reaper.SourceGenerator\Reaper.SourceGenerator.csproj", "{3BF323A5-E703-40EC-B7A3-51BF85EDE27D}" 6 | EndProject 7 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{4E11526E-A855-40DB-BC78-24C0B503BE76}" 8 | EndProject 9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Reaper.TestWeb", "tests\Reaper.TestWeb\Reaper.TestWeb.csproj", "{12413687-45BD-4B12-93DD-5118AA6B1BE8}" 10 | EndProject 11 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntegrationTests", "tests\IntegrationTests\IntegrationTests.csproj", "{20BADD15-9140-4B56-A63B-FA624786F65D}" 12 | EndProject 13 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "tests\UnitTests\UnitTests.csproj", "{41150D2A-2666-48A1-8286-C94EE827147C}" 14 | EndProject 15 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Benchmarks", "Benchmarks", "{87EDAF12-611E-40EF-9165-E959ED6A0246}" 16 | EndProject 17 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmarker", "benchmarks\Benchmarker\Benchmarker.csproj", "{0D74F8B7-75E1-47C3-B70B-1D677FFB51AB}" 18 | EndProject 19 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenchmarkWeb", "benchmarks\BenchmarkWeb\BenchmarkWeb.csproj", "{1D0B7DC1-0C7B-4C5E-87F3-3978A8151B6F}" 20 | EndProject 21 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Reaper.Validation", "src\Reaper.Validation\Reaper.Validation.csproj", "{2C789211-0648-41D9-B14D-F45DB32074E5}" 22 | EndProject 23 | Global 24 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 25 | Debug|Any CPU = Debug|Any CPU 26 | Release|Any CPU = Release|Any CPU 27 | EndGlobalSection 28 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 29 | {4EA32884-C315-405F-AF6B-3CE9AFB0B717}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {4EA32884-C315-405F-AF6B-3CE9AFB0B717}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {4EA32884-C315-405F-AF6B-3CE9AFB0B717}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {4EA32884-C315-405F-AF6B-3CE9AFB0B717}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {3BF323A5-E703-40EC-B7A3-51BF85EDE27D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 34 | {3BF323A5-E703-40EC-B7A3-51BF85EDE27D}.Debug|Any CPU.Build.0 = Debug|Any CPU 35 | {3BF323A5-E703-40EC-B7A3-51BF85EDE27D}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {3BF323A5-E703-40EC-B7A3-51BF85EDE27D}.Release|Any CPU.Build.0 = Release|Any CPU 37 | {12413687-45BD-4B12-93DD-5118AA6B1BE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {12413687-45BD-4B12-93DD-5118AA6B1BE8}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {12413687-45BD-4B12-93DD-5118AA6B1BE8}.Release|Any CPU.ActiveCfg = Release|Any CPU 40 | {12413687-45BD-4B12-93DD-5118AA6B1BE8}.Release|Any CPU.Build.0 = Release|Any CPU 41 | {20BADD15-9140-4B56-A63B-FA624786F65D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 42 | {20BADD15-9140-4B56-A63B-FA624786F65D}.Debug|Any CPU.Build.0 = Debug|Any CPU 43 | {20BADD15-9140-4B56-A63B-FA624786F65D}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {20BADD15-9140-4B56-A63B-FA624786F65D}.Release|Any CPU.Build.0 = Release|Any CPU 45 | {41150D2A-2666-48A1-8286-C94EE827147C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 46 | {41150D2A-2666-48A1-8286-C94EE827147C}.Debug|Any CPU.Build.0 = Debug|Any CPU 47 | {41150D2A-2666-48A1-8286-C94EE827147C}.Release|Any CPU.ActiveCfg = Release|Any CPU 48 | {41150D2A-2666-48A1-8286-C94EE827147C}.Release|Any CPU.Build.0 = Release|Any CPU 49 | {0D74F8B7-75E1-47C3-B70B-1D677FFB51AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 50 | {0D74F8B7-75E1-47C3-B70B-1D677FFB51AB}.Debug|Any CPU.Build.0 = Debug|Any CPU 51 | {0D74F8B7-75E1-47C3-B70B-1D677FFB51AB}.Release|Any CPU.ActiveCfg = Release|Any CPU 52 | {0D74F8B7-75E1-47C3-B70B-1D677FFB51AB}.Release|Any CPU.Build.0 = Release|Any CPU 53 | {1D0B7DC1-0C7B-4C5E-87F3-3978A8151B6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 54 | {1D0B7DC1-0C7B-4C5E-87F3-3978A8151B6F}.Debug|Any CPU.Build.0 = Debug|Any CPU 55 | {1D0B7DC1-0C7B-4C5E-87F3-3978A8151B6F}.Release|Any CPU.ActiveCfg = Release|Any CPU 56 | {1D0B7DC1-0C7B-4C5E-87F3-3978A8151B6F}.Release|Any CPU.Build.0 = Release|Any CPU 57 | {2C789211-0648-41D9-B14D-F45DB32074E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 58 | {2C789211-0648-41D9-B14D-F45DB32074E5}.Debug|Any CPU.Build.0 = Debug|Any CPU 59 | {2C789211-0648-41D9-B14D-F45DB32074E5}.Release|Any CPU.ActiveCfg = Release|Any CPU 60 | {2C789211-0648-41D9-B14D-F45DB32074E5}.Release|Any CPU.Build.0 = Release|Any CPU 61 | EndGlobalSection 62 | GlobalSection(NestedProjects) = preSolution 63 | {12413687-45BD-4B12-93DD-5118AA6B1BE8} = {4E11526E-A855-40DB-BC78-24C0B503BE76} 64 | {20BADD15-9140-4B56-A63B-FA624786F65D} = {4E11526E-A855-40DB-BC78-24C0B503BE76} 65 | {41150D2A-2666-48A1-8286-C94EE827147C} = {4E11526E-A855-40DB-BC78-24C0B503BE76} 66 | {0D74F8B7-75E1-47C3-B70B-1D677FFB51AB} = {87EDAF12-611E-40EF-9165-E959ED6A0246} 67 | {1D0B7DC1-0C7B-4C5E-87F3-3978A8151B6F} = {87EDAF12-611E-40EF-9165-E959ED6A0246} 68 | EndGlobalSection 69 | EndGlobal 70 | -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/EndpointMapper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Reaper.SourceGenerator.Internal; 3 | using Reaper.SourceGenerator.JsonContextGenerator; 4 | using Reaper.SourceGenerator.MapperInterceptor; 5 | using Reaper.SourceGenerator.ServicesInterceptor; 6 | using Reaper.SourceGenerator.ReaperEndpoints; 7 | 8 | namespace Reaper.SourceGenerator 9 | { 10 | [Generator] 11 | public class EndpointMapper : IIncrementalGenerator 12 | { 13 | public void Initialize(IncrementalGeneratorInitializationContext context) 14 | { 15 | JsonSourceGenerationSupport.RegisterAssemblyResolver(); 16 | 17 | var reaperEndpointDefinitions = context.SyntaxProvider 18 | .CreateSyntaxProvider( 19 | predicate: static (s, _) => s.IsEndpointTarget(), 20 | transform: static (ctx, ct) => 21 | { 22 | var reaperDefinition = ctx.GetValidReaperDefinition(ct); 23 | if (reaperDefinition.valid) 24 | { 25 | return reaperDefinition.definition!; 26 | } 27 | 28 | return null; 29 | }) 30 | .Where(m => m != null) 31 | .WithTrackingName(GeneratorStatics.StepEndpoints); 32 | 33 | var reaperValidatorDefinitions = context.SyntaxProvider 34 | .CreateSyntaxProvider( 35 | predicate: static (s, _) => s.IsValidatorTarget(), 36 | transform: static (ctx, ct) => 37 | { 38 | var validatorDefinition = ctx.GetValidReaperValidatorDefinition(ct); 39 | if (validatorDefinition.valid) 40 | { 41 | return validatorDefinition.definition!; 42 | } 43 | 44 | return null; 45 | }) 46 | .Where(m => m != null) 47 | .WithTrackingName(GeneratorStatics.StepValidators); 48 | 49 | var mapReaperEndpointCall = context.SyntaxProvider.CreateSyntaxProvider( 50 | predicate: static (s, _) => s.IsTargetForMapperInterceptor(), 51 | transform: static (ctx, ct) => ctx.TransformValidInvocation(ct)) 52 | .Where(m => m != null) 53 | .WithTrackingName(GeneratorStatics.StepMapper); 54 | 55 | var useReaperCall = context.SyntaxProvider.CreateSyntaxProvider( 56 | predicate: static (s, _) => s.IsTargetForServicesGenerator(), 57 | transform: static (ctx, ct) => ctx.TransformValidInvocation(ct)) 58 | .Where(m => m != null) 59 | .WithTrackingName(GeneratorStatics.StepServices); 60 | 61 | var collectedEndpoints = reaperEndpointDefinitions.Collect(); 62 | 63 | context.RunJsonSourceGeneratorWithModifiedProvider(collectedEndpoints); 64 | 65 | var allData = collectedEndpoints 66 | .Combine(reaperValidatorDefinitions.Collect()) 67 | .Combine(mapReaperEndpointCall.Collect().Select((x, _) => x.First())) 68 | .Combine(useReaperCall.Collect().Select((x, _) => x.First())); 69 | 70 | context.RegisterSourceOutput(allData, (ctx, data) => 71 | { 72 | var (((endpoints, validators), mapReaper), useReaper) = data; 73 | 74 | // Update the endpoints with the validators 75 | if (validators.Any()) 76 | { 77 | foreach (var validator in validators) 78 | { 79 | var endpoint = endpoints.FirstOrDefault(m => m!.HasRequest && m.RequestMap!.RequestType.Equals(validator!.RequestSymbol, SymbolEqualityComparer.Default)); 80 | if (endpoint != null) 81 | { 82 | endpoint.RequestMap!.SetValidator(validator!.Validator!); 83 | } 84 | } 85 | } 86 | 87 | var jsonContextGenerator = new JsonContextGenerator.JsonContextGenerator(endpoints!); 88 | var jsonContextCode = jsonContextGenerator.Generate(); 89 | ctx.AddSource("ReaperJsonSerializerContext.Base.g.cs", jsonContextCode); 90 | 91 | var serviceInterceptorGenerator = new ServicesInterceptorGenerator(endpoints!, useReaper!); 92 | var serviceInterceptorCode = serviceInterceptorGenerator.Generate(); 93 | ctx.AddSource("ServicesInterceptor.g.cs", serviceInterceptorCode); 94 | 95 | var mapperInterceptorGenerator = new MapperInterceptorGenerator(endpoints!, mapReaper!); 96 | var mapperInterceptorCode = mapperInterceptorGenerator.Generate(); 97 | ctx.AddSource("MapperInterceptor.g.cs", mapperInterceptorCode); 98 | }); 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/JsonContextGenerator/JsonSourceGenerationSupport.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using System.Reflection; 3 | using System.Text.Json.SourceGeneration.Reaper; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp; 6 | using Microsoft.CodeAnalysis.CSharp.Syntax; 7 | using Reaper.SourceGenerator.ReaperEndpoints; 8 | using SourceGenerators; 9 | 10 | namespace Reaper.SourceGenerator.JsonContextGenerator; 11 | 12 | public static class JsonSourceGenerationSupport 13 | { 14 | internal static void RunJsonSourceGeneratorWithModifiedProvider( 15 | this IncrementalGeneratorInitializationContext context, 16 | IncrementalValueProvider> endpointData) 17 | { 18 | var jsonSourceGenerator = new JsonSourceGenerator(); 19 | 20 | var specs = endpointData 21 | .Select((symbol, token) => 22 | { 23 | // We add the SyntaxTree (of what is generated by JsonContextGenerator) to this context, so that the source generator is aware 24 | var comp = symbol.First()!.SemanticModel.Compilation; 25 | var ctxTree = GetSyntaxTreeForContext(comp, symbol!); 26 | var classDeclaration = ctxTree.GetRoot().DescendantNodes() 27 | .OfType() 28 | .First(); 29 | comp = comp.AddSyntaxTrees(ctxTree); 30 | var wellKnown = new KnownTypeSymbols(comp); 31 | var model = comp.GetSemanticModel(ctxTree); 32 | JsonSourceGenerator.Parser parser = new(wellKnown); 33 | ContextGenerationSpec? contextGenerationSpec = parser.ParseContextGenerationSpec(classDeclaration, model, token); 34 | ImmutableEquatableArray diagnostics = new ImmutableEquatableArray(parser.Diagnostics); 35 | return (contextGenerationSpec, diagnostics); 36 | }); 37 | 38 | context.RegisterSourceOutput(specs, jsonSourceGenerator.ReportDiagnosticsAndEmitSource); 39 | } 40 | 41 | public static void RegisterAssemblyResolver() 42 | { 43 | AppDomain.CurrentDomain.AssemblyResolve += (sender, args) => 44 | { 45 | var assemblyName = new AssemblyName(args.Name); 46 | if (assemblyName.Name.Contains("System.Text.Json.SourceGeneration")) 47 | { 48 | var asm = Assembly.GetExecutingAssembly().GetManifestResourceStream("Reaper.SourceGenerator.Resources.System.Text.Json.SourceGeneration.Reaper.dll")!; 49 | byte[] asmBytes; 50 | using(var memoryStream = new MemoryStream()) 51 | { 52 | asm.CopyTo(memoryStream); 53 | asmBytes = memoryStream.ToArray(); 54 | } 55 | // Disable the Banned API analyser, as we need to load this dynamically from resources 56 | #pragma warning disable RS1035 57 | return Assembly.Load(asmBytes); 58 | #pragma warning restore RS1035 59 | } 60 | 61 | return null; 62 | }; 63 | } 64 | 65 | internal static SyntaxTree GetSyntaxTreeForContext(Compilation compilation, ImmutableArray endpoints) 66 | { 67 | AttributeSyntax CreateJsonSerializableAttribute(string typeName) 68 | { 69 | return SyntaxFactory.Attribute(SyntaxFactory.ParseName("System.Text.Json.Serialization.JsonSerializable")) 70 | .WithArgumentList(SyntaxFactory.AttributeArgumentList( 71 | SyntaxFactory.SingletonSeparatedList( 72 | SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression(SyntaxFactory.ParseTypeName(typeName))) 73 | ) 74 | )); 75 | } 76 | 77 | var attributes = new List(); 78 | foreach (var endpoint in endpoints) 79 | { 80 | if (endpoint.HasRequest) 81 | attributes.Add(CreateJsonSerializableAttribute(endpoint.RequestBodyTypeName)); 82 | if (endpoint.HasResponse) 83 | attributes.Add(CreateJsonSerializableAttribute(endpoint.ResponseTypeName)); 84 | } 85 | 86 | var attributeList = SyntaxFactory.AttributeList(SyntaxFactory.SeparatedList(attributes)) 87 | .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed); 88 | 89 | var classDeclaration = SyntaxFactory.ClassDeclaration("ReaperJsonSerializerContext") 90 | .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.PartialKeyword)) 91 | .AddBaseListTypes(SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName("System.Text.Json.Serialization.JsonSerializerContext"))) 92 | .AddAttributeLists(attributeList); 93 | 94 | var namespaceDeclaration = SyntaxFactory.NamespaceDeclaration(SyntaxFactory.ParseName("Reaper.Generated")) 95 | .AddMembers(classDeclaration); 96 | 97 | var compUnit = SyntaxFactory.CompilationUnit() 98 | .AddMembers(namespaceDeclaration); 99 | var options = compilation.SyntaxTrees.First().Options as CSharpParseOptions; 100 | 101 | var syntaxTree = CSharpSyntaxTree.Create(compUnit, options); 102 | return syntaxTree; 103 | } 104 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/ReaperEndpoints/ReaperDefinition.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using Microsoft.CodeAnalysis; 3 | using Microsoft.CodeAnalysis.CSharp.Syntax; 4 | using Reaper.SourceGenerator.RoslynHelpers; 5 | 6 | namespace Reaper.SourceGenerator.ReaperEndpoints; 7 | 8 | internal record ReaperDefinition(ClassDeclarationSyntax ClassDeclarationSyntax, SemanticModel SemanticModel, INamedTypeSymbol Symbol, INamedTypeSymbol BaseSymbol) 9 | { 10 | public required ResponseOptimisationType ResponseOptimisationType { get; init; } 11 | 12 | public RequestTypeMap? RequestMap { get; init; } 13 | public bool HasRequest => RequestMap != null; 14 | public bool HasRequestValidator => HasRequest && RequestMap!.ValidatorType != null; 15 | 16 | public ITypeSymbol? ResponseSymbol { get; init; } 17 | public bool HasResponse => ResponseSymbol != null; 18 | 19 | public AttributeData? RouteAttribute { get; init; } 20 | public bool HasRouteAttribute => RouteAttribute != null; 21 | 22 | private string? baseTypeName; 23 | public string BaseTypeName => baseTypeName ??= BaseSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); 24 | 25 | public bool IsScoped { get; init; } 26 | public bool IsForceUseHandler { get; init; } 27 | 28 | public bool IsFullyConfigured => HasRouteAttribute; 29 | public bool RequiresReaperHandler => IsForceUseHandler; 30 | 31 | private string? typeName; 32 | public string TypeName => typeName ??= Symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); 33 | 34 | private string? requestTypeName; 35 | public string RequestTypeName => requestTypeName ??= RequestMap?.RequestType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? string.Empty; 36 | 37 | private string? requestValidatorTypeName; 38 | public string RequestValidatorTypeName => requestValidatorTypeName ??= RequestMap?.ValidatorType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? string.Empty; 39 | 40 | private string? requestBodyTypeName; 41 | public string RequestBodyTypeName => requestBodyTypeName ??= RequestMap?.RequestBodyType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? string.Empty; 42 | 43 | private string? responseTypeName; 44 | public string ResponseTypeName => responseTypeName ??= ResponseSymbol?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? string.Empty; 45 | 46 | private string? route; 47 | public string Route => route ??= RouteAttribute?.ConstructorArguments[1].Value?.ToString() ?? string.Empty; 48 | 49 | private string? verb; 50 | public string Verb => verb ??= RouteAttribute?.ConstructorArguments[0].Value?.ToString() ?? string.Empty; 51 | } 52 | 53 | public enum ResponseOptimisationType 54 | { 55 | None, 56 | StringResponse 57 | } 58 | 59 | internal record RequestTypeMap 60 | { 61 | public bool IsBoundRequest { get; set; } 62 | 63 | public ITypeSymbol RequestType { get; init; } 64 | public ITypeSymbol? ValidatorType { get; set; } 65 | public ITypeSymbol RequestBodyType { get; init; } 66 | public IPropertySymbol? RequestBodyProperty { get; init; } 67 | public bool BoundRequestBody => RequestBodyProperty != null; 68 | 69 | public ImmutableDictionary? RouteProperties { get; init; } 70 | public bool BoundRoutes => RouteProperties != null; 71 | public ImmutableDictionary? QueryProperties { get; init; } 72 | public bool BoundQueries => QueryProperties != null; 73 | 74 | public ImmutableArray Properties { get; init; } 75 | 76 | internal RequestTypeMap(ITypeSymbol type, WellKnownTypes wkt) 77 | { 78 | RequestType = type; 79 | RequestBodyType = type; 80 | Properties = type.GetMembers() 81 | .OfType() 82 | .Where(m => m.DeclaredAccessibility == Accessibility.Public) 83 | .ToImmutableArray(); 84 | 85 | var requestBodyProperties = Properties.Where(m => m.GetAttributes().Any(attr => attr.AttributeClass!.Equals(wkt.AspNetFromBodyAttribute, SymbolEqualityComparer.Default))); 86 | // We take the first one, need to implement diagnostics to alert the user that there's more than 1 87 | var requestBodyProperty = requestBodyProperties.FirstOrDefault(); 88 | if (requestBodyProperty != null) 89 | { 90 | RequestBodyProperty = requestBodyProperty; 91 | RequestBodyType = requestBodyProperty.Type; 92 | IsBoundRequest = true; 93 | } 94 | 95 | // Now we want to check if there's any [FromRoute] or [FromQuery] 96 | // TODO [FromForm]??? 97 | var routeOrQueryProperties = Properties.Select(m => 98 | new 99 | { 100 | Property = m, 101 | RouteAttribute = m.GetAttributes().FirstOrDefault(attr => attr.AttributeClass!.Equals(wkt.AspNetFromRouteAttribute, SymbolEqualityComparer.Default)), 102 | QueryAttribute = m.GetAttributes().FirstOrDefault(attr => attr.AttributeClass!.Equals(wkt.AspNetFromQueryAttribute, SymbolEqualityComparer.Default)) 103 | }) 104 | .ToImmutableArray(); 105 | 106 | var routeAttributeProperties = routeOrQueryProperties.Where(m => m.RouteAttribute != null).ToImmutableArray(); 107 | var queryAttributeProperties = routeOrQueryProperties.Where(m => m.QueryAttribute != null).ToImmutableArray(); 108 | if (routeAttributeProperties.Length > 0) 109 | { 110 | var builder = ImmutableDictionary.CreateBuilder(); 111 | foreach (var routeAttributeProperty in routeAttributeProperties) 112 | { 113 | // Determine if we're using the property name or the name specified in the attribute 114 | var name = routeAttributeProperty.RouteAttribute!.NamedArguments.FirstOrDefault(m => m.Key == "Name").Value.Value?.ToString() ?? 115 | routeAttributeProperty.Property.Name; 116 | builder.Add(name, routeAttributeProperty.Property); 117 | } 118 | RouteProperties = builder.ToImmutable(); 119 | IsBoundRequest = true; 120 | } 121 | 122 | if (queryAttributeProperties.Length > 0) 123 | { 124 | var builder = ImmutableDictionary.CreateBuilder(); 125 | foreach (var queryAttributeProperty in queryAttributeProperties) 126 | { 127 | // Determine if we're using the property name or the name specified in the attribute 128 | var name = queryAttributeProperty.QueryAttribute!.NamedArguments.FirstOrDefault(m => m.Key == "Name").Value.Value?.ToString() ?? 129 | queryAttributeProperty.Property.Name; 130 | builder.Add(name, queryAttributeProperty.Property); 131 | } 132 | QueryProperties = builder.ToImmutable(); 133 | IsBoundRequest = true; 134 | } 135 | } 136 | 137 | public void SetValidator(ITypeSymbol validatorType) 138 | { 139 | ValidatorType = validatorType; 140 | } 141 | } -------------------------------------------------------------------------------- /src/Reaper/RequestDelegateSupport/LogOrThrowExceptionHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace Reaper.RequestDelegateSupport; 7 | 8 | public sealed class LogOrThrowExceptionHelper 9 | { 10 | private readonly ILogger? _rdgLogger; 11 | private readonly bool _shouldThrow; 12 | 13 | public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) 14 | { 15 | var loggerFactory = serviceProvider?.GetRequiredService(); 16 | _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); 17 | _shouldThrow = options?.ThrowOnBadRequest ?? false; 18 | } 19 | 20 | public void RequestBodyIOException(IOException exception) 21 | { 22 | if (_rdgLogger != null) 23 | { 24 | _requestBodyIOException(_rdgLogger, exception); 25 | } 26 | } 27 | 28 | private static readonly Action _requestBodyIOException = 29 | LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); 30 | 31 | public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) 32 | { 33 | if (_shouldThrow) 34 | { 35 | var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); 36 | throw new BadHttpRequestException(message, exception); 37 | } 38 | 39 | if (_rdgLogger != null) 40 | { 41 | _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); 42 | } 43 | } 44 | 45 | private static readonly Action _invalidJsonRequestBody = 46 | LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); 47 | 48 | public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) 49 | { 50 | if (_shouldThrow) 51 | { 52 | var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); 53 | throw new BadHttpRequestException(message); 54 | } 55 | 56 | if (_rdgLogger != null) 57 | { 58 | _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); 59 | } 60 | } 61 | 62 | private static readonly Action _parameterBindingFailed = 63 | LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); 64 | 65 | public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) 66 | { 67 | if (_shouldThrow) 68 | { 69 | var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); 70 | throw new BadHttpRequestException(message); 71 | } 72 | 73 | if (_rdgLogger != null) 74 | { 75 | _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); 76 | } 77 | } 78 | 79 | private static readonly Action _requiredParameterNotProvided = 80 | LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); 81 | 82 | public void ImplicitBodyNotProvided(string parameterName) 83 | { 84 | if (_shouldThrow) 85 | { 86 | var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); 87 | throw new BadHttpRequestException(message); 88 | } 89 | 90 | if (_rdgLogger != null) 91 | { 92 | _implicitBodyNotProvided(_rdgLogger, parameterName, null); 93 | } 94 | } 95 | 96 | private static readonly Action _implicitBodyNotProvided = 97 | LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); 98 | 99 | public void UnexpectedJsonContentType(string? contentType) 100 | { 101 | if (_shouldThrow) 102 | { 103 | var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); 104 | throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); 105 | } 106 | 107 | if (_rdgLogger != null) 108 | { 109 | _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); 110 | } 111 | } 112 | 113 | private static readonly Action _unexpectedJsonContentType = 114 | LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); 115 | 116 | public void UnexpectedNonFormContentType(string? contentType) 117 | { 118 | if (_shouldThrow) 119 | { 120 | var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); 121 | throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); 122 | } 123 | 124 | if (_rdgLogger != null) 125 | { 126 | _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); 127 | } 128 | } 129 | 130 | private static readonly Action _unexpectedNonFormContentType = 131 | LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); 132 | 133 | public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) 134 | { 135 | if (_shouldThrow) 136 | { 137 | var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); 138 | throw new BadHttpRequestException(message, exception); 139 | } 140 | 141 | if (_rdgLogger != null) 142 | { 143 | _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); 144 | } 145 | } 146 | 147 | private static readonly Action _invalidFormRequestBody = 148 | LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); 149 | } -------------------------------------------------------------------------------- /src/Reaper.SourceGenerator/ReaperEndpoints/ExtensionMethods.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Microsoft.CodeAnalysis.CSharp.Syntax; 3 | using Microsoft.CodeAnalysis.Operations; 4 | using Reaper.SourceGenerator.RoslynHelpers; 5 | 6 | namespace Reaper.SourceGenerator.ReaperEndpoints; 7 | 8 | internal static class ExtensionMethods 9 | { 10 | internal static bool IsEndpointTarget(this SyntaxNode node) 11 | { 12 | var sc = node is ClassDeclarationSyntax 13 | { 14 | BaseList: 15 | { 16 | Types: 17 | { 18 | Count: > 0, 19 | }, 20 | }, 21 | }; 22 | if (!sc) 23 | { 24 | return false; 25 | } 26 | 27 | return (node as ClassDeclarationSyntax)!.BaseList!.Types.Any(m => m.Type.ToString().Contains("ReaperEndpoint")); 28 | } 29 | 30 | internal static bool IsValidatorTarget(this SyntaxNode node) 31 | { 32 | var sc = node is ClassDeclarationSyntax 33 | { 34 | BaseList: 35 | { 36 | Types: 37 | { 38 | Count: > 0, 39 | }, 40 | }, 41 | }; 42 | if (!sc) 43 | { 44 | return false; 45 | } 46 | 47 | return (node as ClassDeclarationSyntax)!.BaseList!.Types.Any(m => m.Type.ToString().Contains("RequestValidator")); 48 | } 49 | 50 | internal static IInvocationOperation? TransformValidInvocation(this GeneratorSyntaxContext ctx, CancellationToken ct) 51 | { 52 | var reaperOperation = ctx.GetValidReaperInvokationOperation(ct); 53 | if (reaperOperation.valid) 54 | { 55 | return reaperOperation.operation; 56 | } 57 | 58 | return null; 59 | } 60 | 61 | internal static (string file, int line, int pos) GetInvocationLocation(this IInvocationOperation operation) 62 | { 63 | var memberAccessorExpression = ((MemberAccessExpressionSyntax)((InvocationExpressionSyntax)operation.Syntax).Expression); 64 | var invocationNameSpan = memberAccessorExpression.Name.Span; 65 | var lineSpan = operation.Syntax.SyntaxTree.GetLineSpan(invocationNameSpan); 66 | var origFilePath = operation.Syntax.SyntaxTree.FilePath; 67 | var filePath =operation.SemanticModel?.Compilation.Options.SourceReferenceResolver?.NormalizePath(origFilePath, baseFilePath: null) ?? origFilePath; 68 | // LineSpan.LinePosition is 0-indexed, but we want to display 1-indexed line and character numbers in the interceptor attribute. 69 | return (filePath, lineSpan.StartLinePosition.Line + 1, lineSpan.StartLinePosition.Character + 1); 70 | } 71 | 72 | internal static INamedTypeSymbol? FindValidatorForRequest(this INamespaceSymbol namespaceSymbol, WellKnownTypes wellKnownTypes, ITypeSymbol requestTypeSymbol) 73 | { 74 | if (wellKnownTypes.ReaperRequestValidator == null) 75 | { 76 | return default; 77 | } 78 | 79 | foreach (var member in namespaceSymbol.GetMembers()) 80 | { 81 | if (member is INamedTypeSymbol typeSymbol && typeSymbol.BaseType != null) 82 | { 83 | if (typeSymbol.BaseType.EqualsWithoutGeneric(wellKnownTypes.ReaperRequestValidator)) 84 | { 85 | var bt = typeSymbol.BaseType.TypeArguments; 86 | foreach (var memb in bt) 87 | { 88 | if (SymbolEqualityComparer.Default.Equals(memb, requestTypeSymbol)) 89 | { 90 | return typeSymbol; 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | return default; 98 | } 99 | 100 | internal static (bool valid, ReaperValidatorDefinition? definition) GetValidReaperValidatorDefinition(this GeneratorSyntaxContext ctx, CancellationToken ct) 101 | { 102 | var wellKnownTypes = WellKnownTypes.GetOrCreate(ctx.SemanticModel.Compilation); 103 | // Reaper.Validation is not added 104 | if (wellKnownTypes.ReaperRequestValidator == null) 105 | { 106 | return (false, null); 107 | } 108 | var symbol = ctx.SemanticModel.GetDeclaredSymbol(ctx.Node, ct) as INamedTypeSymbol; 109 | 110 | var baseSymbol = symbol?.BaseType; 111 | if (baseSymbol!.EqualsWithoutGeneric(wellKnownTypes.ReaperRequestValidator!)) 112 | { 113 | var requestType = baseSymbol.TypeArguments[0]; 114 | return (true, new ReaperValidatorDefinition 115 | { 116 | Validator = symbol, 117 | RequestSymbol = requestType 118 | }); 119 | } 120 | 121 | return (false, null); 122 | } 123 | 124 | internal static (bool valid, ReaperDefinition? definition) GetValidReaperDefinition(this GeneratorSyntaxContext ctx, CancellationToken ct) 125 | { 126 | var wellKnownTypes = WellKnownTypes.GetOrCreate(ctx.SemanticModel.Compilation); 127 | var symbol = ctx.SemanticModel.GetDeclaredSymbol(ctx.Node, ct) as INamedTypeSymbol; 128 | 129 | var baseSymbol = symbol?.BaseType; 130 | var baseBaseSymbol = baseSymbol?.BaseType; 131 | if (baseBaseSymbol?.Equals(wellKnownTypes.ReaperEndpointBase, SymbolEqualityComparer.Default) == true) 132 | { 133 | bool isRequest = !baseSymbol!.EqualsWithoutGeneric(wellKnownTypes.ReaperEndpointXR); 134 | ITypeSymbol? request = default, response = default; 135 | 136 | foreach (var typeArg in baseSymbol!.TypeArguments) 137 | { 138 | if (isRequest) 139 | { 140 | request = typeArg; 141 | isRequest = false; 142 | } 143 | else 144 | { 145 | response = typeArg; 146 | } 147 | } 148 | 149 | var attributes = symbol!.GetAttributes(); 150 | AttributeData? routeAttribute = default; 151 | bool isScoped = false, isForceHandler = false; 152 | foreach (var attribute in attributes) 153 | { 154 | if (attribute.AttributeClass?.Equals(wellKnownTypes.ReaperRouteAttribute, SymbolEqualityComparer.Default) == true) 155 | { 156 | routeAttribute = attribute; 157 | } else if (attribute.AttributeClass?.Equals(wellKnownTypes.ReaperScopedAttribute, SymbolEqualityComparer.Default) == true) 158 | { 159 | isScoped = true; 160 | } else if (attribute.AttributeClass?.Equals(wellKnownTypes.ReaperForceHandlerAttribute, SymbolEqualityComparer.Default) == true) 161 | { 162 | isForceHandler = true; 163 | } 164 | } 165 | 166 | RequestTypeMap? requestTypeMap = default!; 167 | 168 | if (request != null) 169 | { 170 | requestTypeMap = new RequestTypeMap(request, wellKnownTypes); 171 | } 172 | 173 | var optimisationType = ResponseOptimisationType.None; 174 | if (response != null) 175 | { 176 | if (response.Equals(wellKnownTypes.StringType, SymbolEqualityComparer.Default)) 177 | { 178 | optimisationType = ResponseOptimisationType.StringResponse; 179 | } 180 | } 181 | 182 | return (true, new ReaperDefinition((ctx.Node as ClassDeclarationSyntax)!, ctx.SemanticModel, symbol, baseSymbol) 183 | { 184 | ResponseOptimisationType = optimisationType, 185 | RequestMap = requestTypeMap, 186 | ResponseSymbol = response, 187 | RouteAttribute = routeAttribute, 188 | IsScoped = isScoped, 189 | IsForceUseHandler = isForceHandler 190 | }); 191 | } 192 | return (false, default); 193 | } 194 | } -------------------------------------------------------------------------------- /benchmarks/Benchmarker/TestRunner.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using System.Text.Json; 3 | using BenchmarkWeb.Dtos; 4 | using DotNet.Testcontainers; 5 | using DotNet.Testcontainers.Builders; 6 | using DotNet.Testcontainers.Configurations; 7 | using DotNet.Testcontainers.Containers; 8 | using DotNet.Testcontainers.Images; 9 | using DotNet.Testcontainers.Networks; 10 | using Microsoft.Extensions.Logging.Abstractions; 11 | using Spectre.Console; 12 | 13 | namespace Benchmarker; 14 | 15 | public class TestRunner(int threads = 10, int http = 100, int timeinSeconds = 7) 16 | { 17 | 18 | private static readonly IFutureDockerImage wrkImage = new ImageFromDockerfileBuilder() 19 | .WithDockerfileDirectory(Paths.ProjectPath, string.Empty) 20 | .WithDockerfile("wrk.dockerfile") 21 | .WithDeleteIfExists(true) 22 | .Build(); 23 | 24 | public async Task InitialiseAsync() 25 | { 26 | await AnsiConsole.Status() 27 | .Spinner(Spinner.Known.SimpleDotsScrolling) 28 | .StartAsync("Initialising...", async ctx => 29 | { 30 | ctx.Status("Building reusable wrk container image"); 31 | TestcontainersSettings.Logger = NullLogger.Instance; 32 | //ConsoleLogger.Instance.DebugLogLevelEnabled = true; 33 | await wrkImage.CreateAsync(); 34 | 35 | AnsiConsole.WriteLine("Initialised."); 36 | }); 37 | } 38 | 39 | public async Task ExecuteTestAsync(string container) 40 | { 41 | long startupTime = 0; 42 | decimal memStartup = 0, memLoadTest = 0, requestsSec = 0; 43 | AnsiConsole.MarkupLine($"Running [bold]{container}[/]"); 44 | await AnsiConsole.Status() 45 | .Spinner(Spinner.Known.Aesthetic) 46 | .StartAsync("Executing '" + container + "'...", async ctx => 47 | { 48 | ctx.Status("Creating Network & building app image..."); 49 | var network = new NetworkBuilder() 50 | .WithName("reaper-test-network") 51 | .Build(); 52 | var appImage = await CreateAppImageAsync(container); 53 | var containers = CreateContainersWithNetworkAsync(appImage, network); 54 | await network.CreateAsync(); 55 | 56 | // Start the app image 57 | await using (var app = containers.app) 58 | { 59 | ctx.Spinner(Spinner.Known.Arc); 60 | ctx.Status("Starting app container..."); 61 | await app.StartAsync(); 62 | startupTime = await GetTimerFromLogsAsLongAsync(app, "BenchmarkWeb Startup:"); 63 | memStartup = await DockerStats.GetMemoryUsageForContainer("reaper-test-app"); 64 | 65 | ctx.Status("Hitting each endpoint as warmup..."); 66 | // Send warmup requests 67 | await RunWarmupAsync(app); 68 | // Send high load request to basic /ep 69 | await using (var wrk = containers.wrk) 70 | { 71 | ctx.Spinner(Spinner.Known.Monkey); 72 | ctx.Status("Starting wrk & executing high load simulation..."); 73 | await containers.wrk.StartAsync(); 74 | await Task.Delay(timeinSeconds * 1175); 75 | requestsSec = await GetTimerFromLogsAsDecimalAsync(wrk, "Requests/sec:"); 76 | await wrk.StopAsync(); 77 | } 78 | memLoadTest = await DockerStats.GetMemoryUsageForContainer("reaper-test-app"); 79 | await app.StopAsync(); 80 | } 81 | await network.DeleteAsync(); 82 | 83 | AnsiConsole.MarkupLineInterpolated($"[dim]:check_mark_button: Startup Time: {startupTime}ms, Startup Mem: {memStartup}, Req/s: {requestsSec}, End Mem: {memLoadTest}[/]"); 84 | }); 85 | 86 | return new() 87 | { 88 | StartupTimeMs = startupTime, 89 | MemoryStartup = memStartup, 90 | MemoryLoadTest = memLoadTest, 91 | Container = container, 92 | RequestsSec = requestsSec 93 | }; 94 | } 95 | 96 | private async Task RunWarmupAsync(IContainer container) 97 | { 98 | var routes = new Dictionary 99 | { 100 | {"/ep", "GET"}, 101 | {"/typical/dosomething", "GET"}, 102 | {"/typical/acceptsomething", "POST"}, 103 | {"/typical/returnsomething", "POST"}, 104 | {"/anothertypical/dosomething", "GET"}, 105 | {"/anothertypical/acceptsomething", "POST"}, 106 | {"/anothertypical/returnsomething", "POST"} 107 | }; 108 | 109 | using (var httpClient = new HttpClient()) 110 | { 111 | var sampleRequest = new SampleRequest 112 | { 113 | Input = "Warmup", 114 | SomeOtherInput = "Warmup", 115 | SomeBool = true 116 | }; 117 | 118 | var stringContent = new StringContent(JsonSerializer.Serialize(sampleRequest, new JsonSerializerOptions 119 | { 120 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase 121 | }), Encoding.UTF8, "application/json"); 122 | foreach (var route in routes) 123 | { 124 | var requestUri = new UriBuilder(Uri.UriSchemeHttp, container.Hostname, container.GetMappedPublicPort(8080), route.Key).Uri; 125 | 126 | var request = new HttpRequestMessage(new HttpMethod(route.Value), requestUri); 127 | if (route.Value == "POST") 128 | { 129 | request.Content = stringContent; 130 | } 131 | 132 | var resp = await httpClient.SendAsync(request); 133 | if (!resp.IsSuccessStatusCode) 134 | { 135 | throw new Exception("Container failed to handle request " + route.Key + " with status: " + resp.StatusCode + "."); 136 | } 137 | } 138 | } 139 | } 140 | 141 | private async Task GetTimerFromLogsAsync(IContainer container, string prefix) 142 | { 143 | var logs = await container.GetLogsAsync(); 144 | var split = logs.Stdout.Split('\n'); 145 | var valueLine = split.First(m => m.Contains(prefix)); 146 | //Console.WriteLine(valueLine); 147 | var value = valueLine.Substring(valueLine.LastIndexOf(':') + 2).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[0]; 148 | //Console.WriteLine(value); 149 | return value; 150 | } 151 | 152 | private async Task GetTimerFromLogsAsDecimalAsync(IContainer container, string prefix) => 153 | decimal.Parse(await GetTimerFromLogsAsync(container, prefix)); 154 | 155 | private async Task GetTimerFromLogsAsLongAsync(IContainer container, string prefix) => 156 | long.Parse(await GetTimerFromLogsAsync(container, prefix)); 157 | 158 | private async Task CreateAppImageAsync(string container) 159 | { 160 | var appImage = new ImageFromDockerfileBuilder() 161 | .WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), string.Empty) 162 | .WithDockerfile("benchmarks/BenchmarkWeb/" + container + ".dockerfile") 163 | .WithDeleteIfExists(true) 164 | .Build(); 165 | await appImage.CreateAsync(); 166 | return appImage; 167 | } 168 | 169 | private (IContainer wrk, IContainer app) CreateContainersWithNetworkAsync(IImage appImage, INetwork network) 170 | { 171 | var wrk = new ContainerBuilder() 172 | .WithCommand("wrk", $"-t{threads}", $"-c{http}", $"-d{timeinSeconds}s", "http://reaper-test-app:8080/ep") 173 | .WithImage(wrkImage) 174 | .WithName("reaper-test-wrk") 175 | .WithNetwork(network) 176 | .WithWaitStrategy(Wait.ForUnixContainer()) 177 | .Build(); 178 | var app = new ContainerBuilder() 179 | .WithImage(appImage) 180 | .WithName("reaper-test-app") 181 | .WithNetwork(network) 182 | .WithNetworkAliases("reaper-test-app") 183 | .WithPortBinding(8080, true) 184 | .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("BenchmarkWeb Startup: [0-9]+")) 185 | .Build(); 186 | return (wrk, app); 187 | } 188 | } 189 | 190 | public record TestResult 191 | { 192 | public string Container { get; set; } 193 | public long StartupTimeMs { get; set; } 194 | public decimal MemoryStartup { get; set; } 195 | public decimal MemoryLoadTest { get; set; } 196 | public decimal RequestsSec { get; set; } 197 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Reaper](https://avatars.githubusercontent.com/u/151158047) 2 | 3 | # Reaper 4 | 5 | Reaper is a .NET 8+ source-generator based, AOT focused library for writing your endpoints as classes in ASP.NET Core. 6 | 7 | Inspired by the awesome, and more full-featured [FastEndpoints](https://fast-endpoints.com/), Reaper aims to provide a 8 | REPR pattern implementation experience with a focus on performance and simplicity. 9 | 10 | ## Motivation 11 | 12 | Minimal APIs are great. They're fast, they're simple, and they're easy to reason about. However trying to separate 13 | your endpoints results in a tonne of classes. 14 | 15 | FastEndpoints is even better. It's fast (obviously), very well structured, very well documented, and provides a tonne 16 | of excellent features out of the box. 17 | 18 | But what if you want to sit in the middleground like us? Having FastEndpoints-style REPR endpoint definitions, but with 19 | Native AOT, lower runtime overhead, and an even more minimal approach? 20 | 21 | That's where we found ourselves whilst building out microservices to be deployed to ACA and looking for a super-simple 22 | way to build our endpoints in a pattern that we know and love, with super minimalistic memory footprints and Minimal API 23 | performance. For these smaller services, Minimal APIs were a better choice, but we wanted them to be more structured. For 24 | larger services, FastEndpoints is still used and is likely a much better choice. 25 | 26 | So is Reaper good for you? As with everything in development, it depends. 27 | 28 | ## Getting Started 29 | 30 | Reaper only supports .NET 8+. 31 | 32 | Add [Reaper.Core]() and [Reaper.SourceGenerator]() from NuGet. 33 | 34 | Edit your csproj to allow the generated namespace: 35 | 36 | ```xml 37 | 38 | $(InterceptorsPreviewNamespaces);Reaper.Generated 39 | 40 | ``` 41 | 42 | Add the following to your Program.cs: 43 | 44 | ```csharp 45 | builder.UseReaper(); 46 | 47 | // ... var app = builder.Build(); ... 48 | 49 | app.UseReaperMiddleware(); 50 | app.MapReaperEndpoints(); 51 | ``` 52 | 53 | Create your first Reaper Endpoint: 54 | 55 | ```csharp 56 | public class TestRequest 57 | { 58 | public string Test { get; set; } 59 | } 60 | 61 | public class TestResponse 62 | { 63 | public string Input { get; set; } 64 | public DateTime GeneratedAt { get; set; } 65 | } 66 | 67 | [ReaperRoute(HttpVerbs.Post, "reflector")] 68 | public class ReflectorEndpoint : ReaperEndpoint 69 | { 70 | public override async Task ExecuteAsync(TestRequest request) 71 | { 72 | Result = new TestResponse() 73 | { 74 | GeneratedAt = DateTime.UtcNow, 75 | Input = request.Test 76 | }; 77 | } 78 | } 79 | ``` 80 | 81 | Enjoy. 82 | 83 | ## Responses 84 | 85 | (This is a recent change and a work in progress) 86 | 87 | By default, your endpoint will return a 200 OK response with the Result property (which is typed). If you need to send 88 | something other than the typed response, you can use the `StatusCode` method (or others below). 89 | 90 | ```csharp 91 | public override async Task ExecuteAsync(MyRequestType request) { 92 | if (request.Something) { 93 | await BadRequest(); 94 | } 95 | 96 | if (creditsAvailable < 0) { 97 | await StatusCode(402); 98 | return; 99 | } 100 | 101 | Result = new MyResponseType(); 102 | } 103 | ``` 104 | 105 | Convenience methods available are `Ok`, `NotFound`, `BadRequest`, `NoContent`, `InternalServerError`. 106 | 107 | ## Good Endpoints 108 | 109 | The idea even if you have multiple input/output types, is to always consume and return a specific type. This not only 110 | means Reaper doesn't have to do too much binding work at any point, it also helps your endpoints to be more well 111 | defined. 112 | 113 | For example, if you wanted to return a list from an endpoint then sure, you could do: 114 | 115 | ```csharp 116 | public class AListingEndpoint : ReaperEndpointXR> 117 | ``` 118 | 119 | But in our very opinionated fashion, it's better to do: 120 | 121 | ```csharp 122 | public class AListingResponse { public IReadOnlyCollection Items { get; set; } } 123 | 124 | public class AListingEndpoint : ReaperEndpointXR 125 | ``` 126 | 127 | Why? Well, first off it's a bit more explicit, you're not using generic types for your endpoints, rather a defined DTO. 128 | Also if you've ever built a real app, you'll know that _things change_, like, a lot. 129 | 130 | What if you did want to add something else to the return from this endpoint? Without changing your implementation of 131 | clients etc, you can't. You'd have to change the type of the endpoint, which is a breaking change. With the above, you 132 | could add additional properties with no cost (assuming your client serializer isn't too strict of course). 133 | 134 | ### Request Binding 135 | 136 | When it comes to *Request* objects, we take a different approach from what you may be used to in Minimal APIs or 137 | Controllers, but we do reuse their `[FromBody]`, `[FromQuery]` and `[FromRoute]` attributes. It's more akin to what is 138 | available in FastEndpoints, though more explicit as you may expect. 139 | 140 | With a route of `/test/{id}`, you'd write something like this: 141 | 142 | ```csharp 143 | // Controller Action 144 | [HttpGet("/test/{id}")] 145 | public IActionResult Test(int id) { /* ... */ } 146 | 147 | // Minimal APIs 148 | app.MapGet("/test/{id}", (int id) => { /* ... */ }); 149 | 150 | // FastEndpoints 151 | public class RequestDto { public string Id { get; set; } } 152 | public class TestEndpoint : Endpoint { 153 | public override void Configure() { 154 | Get("/test/{id}"); 155 | } 156 | /* ... */ 157 | } 158 | 159 | // Reaper 160 | public class RequestDto { [FromRoute] public string Id { get; set; } } 161 | [ReaperRoute(HttpVerbs.Get, "/test/{id}")] 162 | public class TestEndpoint : ReaperEndpointRX { /* ... */ } 163 | ``` 164 | 165 | Notice the explicit `[FromRoute]` attribute. This is because there is no magic binding other than converting a whole 166 | Request DTO from JSON. 167 | 168 | What this means is, if you have any `[From*]` attributes, the request object will not be bound from JSON. If you need 169 | this in addition, create another object (it can be nested, though make sure it's uniquely named for the JSON Source 170 | Generator) and use it within the base Request DTO with `[FromBody]` for example: 171 | 172 | ```csharp 173 | public class RequestDto 174 | { 175 | [FromRoute] public string Id { get; set; } 176 | 177 | [FromBody] public RequestBodyDto Body { get; set; } 178 | 179 | public class RequestBodyDto 180 | { 181 | public string Name { get; set; } 182 | } 183 | } 184 | ``` 185 | 186 | This follows the philosophy of "less magic" and more definition that is prevalent throughout Reaper. 187 | 188 | ### Other Endpoint Bases 189 | 190 | Reaper provides a few other endpoint bases for your convenience: 191 | 192 | ```csharp 193 | public class NothingEndpoint : ReaperEndpoint { /* Use the HttpContext for anything directly */ } 194 | public class RequestOnlyEndpoint : ReaperEndpointRX { /* Use the Request only */ } 195 | public class ResponseOnlyEndpoint : ReaperEndpointXR { /* Use the Response only */ } 196 | ``` 197 | 198 | ### Validators 199 | 200 | To add validation support (via FluentValidation), add [Reaper.Validation]() from NuGet. 201 | 202 | Before your call to `UseReaper()`, also add `UseReaperValidation()`. 203 | 204 | You can then create validators by extending from `RequestValidator` and using FluentValidator just as you 205 | normally would: 206 | 207 | ```csharp 208 | public class TestRequest 209 | { 210 | public string Test { get; set; } 211 | } 212 | 213 | public class TestRequestValidator : RequestValidator 214 | { 215 | public TestRequestValidator() 216 | { 217 | RuleFor(x => x.Test).NotEmpty(); 218 | } 219 | } 220 | ``` 221 | 222 | Note again that validators are created as singletons so don't maintain state in your validator. Soon, the same type of 223 | mapping will be applied as for endpoints (reusing `[ReaperScoped]`). 224 | 225 | Validation results are formatted using a compatible version of the 226 | [RFC7807 Problem Details](https://datatracker.ietf.org/doc/html/rfc7807). 227 | 228 | You can override the response by implementing your own `IValidationFailureHandler`, you're basically responsible for 229 | doing everything to return a valid response (ie. you get the HttpContext, go wild). **This API is subject to change.** 230 | 231 | For Native AOT support, the actual result type returned is `Reaper.Validation.Responses.ValidationProblemDetails` and 232 | lives in its own JsonSerializerContext. If you're modifying the type returned yourself and need Native AOT support, 233 | you'll need your own context (see below). 234 | 235 | ### Native AOT Support 236 | 237 | The core of Reaper is Native AOT compatible. 238 | 239 | We currently generate a context for JSON Source Generation named `ReaperJsonSerializerContext` which will work for all 240 | of your request and response objects. 241 | 242 | It's also registered automatically against the Http.JsonOptions, if you need to use them elsewhere you can register it 243 | in the `.TypeResolverChain` of your `JsonSerializerOptions` like this: 244 | 245 | 246 | ```csharp 247 | options.SerializerOptions.TypeInfoResolverChain.Insert(0, ReaperJsonSerializerContext.Default); 248 | ``` 249 | 250 | If you are (de)serializing other types, it's recommended to create a new context with the objects you require. Due to 251 | the (super hacky) way that the context generator works, we're actually generating it in memory, so it's not possible to 252 | extend our context with your own types (even if you add another partial class). There's a [huge discussion](https://github.com/dotnet/roslyn/issues/57239) 253 | for chaining generators that is probably going nowhere, so we'll have to wait and see if this gets better. 254 | 255 | ### Implementation 256 | 257 | Your Endpoint is injected as a *singleton* by default. This means that you should not store any state in your Endpoint 258 | (not that you would anyway, right?). Your ExecuteAsync method is invoked on a per-request basis. 259 | 260 | To resolve services further services (scoped etc), use the `Resolve()` method (which includes singletons etc). 261 | 262 | An example would be: 263 | 264 | ```csharp 265 | var myService = Resolve(); 266 | ``` 267 | 268 | You can also use constructor injection, with the default rules being a singleton service being injected by the 269 | constructor, and use Resolve for any scoped services. 270 | 271 | Alternatively (though there may be a very minor performance hit), apply the `[ReaperScoped]` attribute to the endpoint 272 | and constructor injection will work the same way as you may be familiar with. 273 | 274 | ### What's coming 275 | 276 | - [x] Convenience methods for sending responses, where the type is too restrictive 277 | - [x] Ability to bind Request object from route, etc (e.g per-prop `[FromRoute]`) 278 | - [ ] Automatic (and customisable) Mapper support 279 | - [x] Automatic generation of Source Generatable DTOs (Request/Response) 280 | - [ ] More documentation 281 | - [x] Tests, obvs 282 | - [ ] More examples 283 | - [x] Support for [FluentValidation](https://github.com/FluentValidation/FluentValidation) 284 | - [ ] Support for [MessagePack](https://github.com/MessagePack-CSharp/MessagePack-CSharp) 285 | - [ ] Support for [MemoryPack](https://github.com/Cysharp/MemoryPack) 286 | - [ ] 🤨 Our own bare metal (read: Kestrel) routing implementation? Who knows. Maybe. 287 | 288 | ## Benchmarks 289 | 290 | Our own internal tool for benchmarking is not scientific (it's mainly designed to compare our own relative performance 291 | over time), but it does have somewhat representative results to our goals (below ordered by req/sec). 292 | 293 | This is a sample injecting a (singleton) service from the most recent version of our tool. The service simply creates 294 | a memory stream, writes the "Hello, World!" string to it in 2 parts, reads it back as a string, and returns it to the 295 | endpoint for sending back to the client. 296 | 297 | The possible reason that we're faster in this scenario as we resolve the service up front, whereas Minimal APIs resolve 298 | them per request as they support scoped. This is basically the exact scenario that we're working towards. 299 | 300 | | Framework | Startup Time | Memory Usage (MiB) - Startup | Memory Usage (MiB) - Load Test | Requests/sec | 301 | |---------------|--------------|------------------------------|--------------------------------|--------------| 302 | | reaper-aot | 21 | 20 | 88 | 121,284 | 303 | | minimal-aot | 21 | 18 | 85 | 119,071 | 304 | | reaper | 108 | 20 | 312 | 110,220 | 305 | | carter | 118 | 20 | 313 | 106,719 | 306 | | minimal | 98 | 20 | 313 | 105,830 | 307 | | fastendpoints | 137 | 23 | 317 | 99,591 | 308 | | controllers | 145 | 23 | 316 | 98,128 | 309 | 310 | This is from our original benchmark tool which just hits an endpoint with no interaction. 311 | 312 | | Framework | Startup Time | Memory Usage (MiB) - Startup | Memory Usage (MiB) - Load Test | Requests/sec | 313 | |---------------|--------------|------------------------------|--------------------------------|--------------| 314 | | minimal-aot | 21 | 21 | 27 | 144,060 | 315 | | reaper-aot | 21 | 19 | 31 | 139,910 | 316 | | minimal | 103 | 22 | 258 | 123,264 | 317 | | reaper | 109 | 20 | 294 | 121,946 | 318 | | carter | 115 | 23 | 270 | 121,725 | 319 | | fastendpoints | 134 | 24 | 304 | 118,513 | 320 | | controllers | 143 | 24 | 309 | 106,056 | 321 | 322 | We've submitted to the TechEmpower Framework Benchmark, however preliminary results (from an M1 Ultra, 128GB RAM) are 323 | available for [plaintext](https://www.techempower.com/benchmarks/#section=test&shareid=75585734-6c92-4a79-8cc9-dab0979ffb38&hw=ph&test=plaintext) 324 | and [json](https://www.techempower.com/benchmarks/#section=test&shareid=75585734-6c92-4a79-8cc9-dab0979ffb38&hw=ph&test=json). 325 | 326 | ## Prerelease notice 327 | 328 | Reaper is currently in prerelease. It may or may not support everything you need. It may or may not be stable. It may or 329 | may not be a good idea to use it in production. 330 | 331 | Code is messy right now. What's committed is an early proof of concept. It's not pretty but it works. This will be 332 | tidied up in due course. 333 | 334 | We are building Reaper alongside our own microservice requirements which are currently running in production. If you 335 | have any feedback, please feel free to open an issue or PR. 336 | 337 | **Important**: Note that the API is subject to change. We're trying to make things as customisable as possible without 338 | sacrificing performance or, of course, AOT compatibility. But do note that in this version, if you've overridden certain 339 | things, they may change in the future. --------------------------------------------------------------------------------