Skip to content

Instantly share code, notes, and snippets.

@dj-nitehawk
Created January 21, 2024 05:40
Show Gist options
  • Save dj-nitehawk/6e23842dcb7640b165fd80ba57967540 to your computer and use it in GitHub Desktop.
Save dj-nitehawk/6e23842dcb7640b165fd80ba57967540 to your computer and use it in GitHub Desktop.
Results pattern with a Post-Processor doing the response sending.
var bld = WebApplication.CreateBuilder(args);
bld.Services
.AddFastEndpoints()
.SwaggerDocument();
var app = bld.Build();
app.UseFastEndpoints()
.UseSwaggerGen();
app.Run();
sealed class Request
{
public bool IsHappyPath { get; set; }
}
sealed class Response
{
public string Message { get; set; }
}
sealed class TestEndpoint : Endpoint<Request, Result<Response>> //set response type to ardalis Result<T>
{
public override void Configure()
{
Get("test/{IsHappyPath}");
AllowAnonymous();
DontAutoSendResponse(); //disable auto send to allow post-processor to handle sending
PostProcessor<ResponseSender>(); //register post processor
Description(
x => x.Produces<Response>(200) //override swagger response type for 200 ok
.Produces<ErrorResponse>(400));
}
public override Task<Result<Response>> ExecuteAsync(Request r, CancellationToken ct)
=> Task.FromResult(HelloService.SayHello(r.IsHappyPath)); //return a Result<T>
}
sealed class ResponseSender : IPostProcessor<Request, Result<Response>>
{
public async Task PostProcessAsync(IPostProcessorContext<Request, Result<Response>> ctx, CancellationToken ct)
{
if (!ctx.HttpContext.ResponseStarted())
{
var result = ctx.Response!;
switch (result.Status)
{
case ResultStatus.Ok:
await ctx.HttpContext.Response.SendAsync(result.GetValue());
break;
case ResultStatus.Invalid:
var failures = result.ValidationErrors.Select(e => new ValidationFailure(e.Identifier, e.ErrorMessage)).ToList();
await ctx.HttpContext.Response.SendErrorsAsync(failures);
break;
}
}
}
}
sealed class HelloService
{
public static Result<Response> SayHello(bool isHappyPath)
{
if (!isHappyPath)
{
return Result<Response>.Invalid(
new List<ValidationError>
{
new()
{
Identifier = nameof(Request.IsHappyPath),
ErrorMessage = "I am unhappy!"
}
});
}
return Result<Response>.Success(new() { Message = "hello world..." });
}
}
@pradeepgururani
Copy link

How do I register my PostProcessor which needs to be generic to handle TRequest and TResponse from different flows?

following is my code:

sealed class AquaResponseSender<TRequest, TResponse> : IPostProcessor<TRequest, Result<TResponse>>
{
    public async Task PostProcessAsync(IPostProcessorContext<TRequest, Result<TResponse>> context, CancellationToken cancellationToken)
    {
        if (!context.HttpContext.ResponseStarted())
        {
            var result = context.Response!;

            switch (result.Status)
            {
             // removed it for brevity
            }
        }
    }
}

and I am registering it like this:

PostProcessor<AquaResponseSender<TRequest, TResponse>>();

but it never gets called. what am I missing?

@dj-nitehawk
Copy link
Author

@pradeepgururani just tried the following by making the post-processor generic, and it works.

screen-record-1711542921

@pradeepgururani
Copy link

Thank you for quick response. Actually I am trying to extend Endpoint to handle some work in my own implementation. Here is my code that is extending Endpoint.

public class AquaEndpoint<TRequest, TResponse>: Endpoint<TRequest, Result<TResponse>> where TRequest : class
{
    public override void Configure()
    {
        DontAutoSendResponse();
        PostProcessor<AquaResponseSender<TRequest, TResponse>>(); // this is brute force because it is not working with following signature:
       /*
The type 'Aqua.Api.AquaResponseSender<TRequest,Ardalis.Result.Result<TResponse>>' must be convertible to
 'FastEndpoints.IPostProcessor<TRequest,Ardalis.Result.Result<TResponse>>' in order to use it as 
parameter 'TPostProcessor' in the generic method 
'void FastEndpoints.Endpoint<TRequest,TResponse>.PostProcessor<TPostProcessor>()'
       */
        base.Configure();
    }
}

then my endpoint looks like this:

public class Update(IMediator mediator) : AquaEndpoint<UpdateAgeWiseMasterRequest, Result<AgeWiseMasterRecord>>
{
    public override void Configure()
    {
        //PostProcessor<AquaResponseSender<UpdateAgeWiseMasterRequest, Result<AgeWiseMasterRecord>>>();
        Put(UpdateAgeWiseMasterRequest.Route);
    }

    public override async Task HandleAsync(UpdateAgeWiseMasterRequest request, CancellationToken cancellationToken)
    {
    }
}

Here is PostProcessor:

sealed class AquaResponseSender<TRequest, TResponse> : IPostProcessor<TRequest, Result<TResponse>>
{
    public async Task PostProcessAsync(IPostProcessorContext<TRequest, Result<TResponse>> context, CancellationToken cancellationToken)
    {
    }
}

However with these things together AquaResponseSender is not triggering. If I comment PostProcessor<AquaResponseSender<TRequest, TResponse>>(); in base class and have it on actual Endpoint with specific type like this PostProcessor<AquaResponseSender<UpdateAgeWiseMasterRequest, Result<AgeWiseMasterRecord>>>(); then it is working. Working only for unhandled exceptions and all handled business scenarios are not executed because of context.HttpContext.ResponseStarted() check.

Am I on right track with the idea of extending EndPoint? Reason to go on that path is to avoid putting DontSend... and PostProcessor on each endpoint though I anyway will have to make them inherit from new base class.

@dj-nitehawk
Copy link
Author

Reason to go on that path is to avoid putting DontSend... and PostProcessor on each endpoint

in that case, i think the cleanest solution would be to use a global post processor and avoid subclassing the endpoint class.

sealed class GlobalResponseSender : IGlobalPostProcessor
{
    public async Task PostProcessAsync(IPostProcessorContext ctx, CancellationToken ct)
    {
        if (!ctx.HttpContext.ResponseStarted())
        {
            var result = (IResult)ctx.Response!; //cast is necessary since we don't know what the actual response dto type is

            switch (result.Status)
            {
                case ResultStatus.Ok:
                    await ctx.HttpContext.Response.SendAsync(result.GetValue());

                    break;

                case ResultStatus.Invalid:
                    var failures = result.ValidationErrors.Select(e => new ValidationFailure(e.Identifier, e.ErrorMessage)).ToList();
                    await ctx.HttpContext.Response.SendErrorsAsync(failures);

                    break;
            }
        }
    }
}

register it like so:

app.UseFastEndpoints(
       c => c.Endpoints.Configurator =
                ep =>
                {
                    ep.DontAutoSendResponse();
                    ep.PostProcessor<GlobalResponseSender>(Order.Before);
                })

@nhwilly
Copy link

nhwilly commented Sep 25, 2024

{Edit: Hold up on this, I might have this wrong...}

I am using the example as above and it works perfectly for me. But I have a couple of endpoints (OAuth related, of course) that I need to do a Redirect back to my client as the endpoint is being called by the OAuth provider. I guess the question is how could I avoid using the GlobalResponseSender when I don't want/need it?

I don't want to create a fork of Result and there is no StatusCode value for a redirect in their package. This is what the endpoint looks like:

 public override void Configure()
{
  Get(OAuthCallbackRequest.RouteTemplate);
}
public override async Task HandleAsync(OAuthCallbackRequest request, CancellationToken ct = default)
{
  var options = AcuityAuthOptions.Value;

  if (!TryValidateOAuthResponseAsync(request, options, out var invalidResponseRoute))
  {
    await SendRedirectAsync(invalidResponseRoute, allowRemoteRedirects: true);
    return;
  }

  var oauthStateValuesResult = await GetStateValuesAsync(options, request);
  if (!oauthStateValuesResult.IsSuccess)
  {
    var invalidStateRoute = CreateInvalidStateValuesRoute(options);
    await SendRedirectAsync(invalidStateRoute, allowRemoteRedirects: true);
    return;
  }

  // because this is a redirect from the user's client browser the cookie will still be present and we can get the user.
  var userId = User.GetUserId();
  var command = request.ToCommand(oauthStateValuesResult.Value, userId, ComposeOAuthCallbackToServerUri());
  var result = await mediator.Send(command, ct);

  var claims = command.OAuthStateValues ?? [];

  var route = CreateResponseRoute(
    errors: result.Errors.ToArray(),
    uriBase: GetResultUri(claims),
    uriPath: options.AuthorizationResultClientPath,
    originPage: GetClientOriginUri(claims),
    success: result.IsSuccess);

  await SendRedirectAsync(route, allowRemoteRedirects: true);
  return;
}

Is there a way to make the two work at the same time? TIA

@dj-nitehawk
Copy link
Author

@nhwilly
since you wrote {Edit: Hold up on this, I might have this wrong...}, i didn't read your code.
if you need further assitance regarding this, don't hesitate to create a new gh issue or discord post with a "simplified" repro ;-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment