Jak obsłużyć zwracanie rezultatu w WebAPI

Aktualizacja 16.10.2020

Dodałem punkt dotyczący obsługi rezultatu przy użyciu middleware.

Jakiś czasem temu pisałem o tym, że nasze metody mogą zwracać rezultat lub zgłaszać wyjątek (tutaj). Zastanówmy się, jak mogłoby wyglądać nasze API, aby status rezultatu był mapowany na odpowiedni kod Http.

Nasz rezultat może przyjmować następujące stany:

public enum OperationResultStatus
{
    Success,
    Error,
    Forbidden,
    NotFound
    // ...
}

Załóżmy, że nasz kontroler wygląda następująco:

[HttpGet("{itemId}")]
public async Task<IActionResult> RetrieveByIdAsync(Guid itemId)
{
    var result = await _itemsReadService.RetrieveByIdAsync(itemId);
    return Ok(result.Value);
}

gdzie RetrieveByIdAsync zwraca OperationResult.

Metoda

Niestety w takiej sytuacji nasze API zawsze zwraca Ok. Dzieje się to nawet wtedy, gdy nasza operacja się nie powiedzie (status jest różny od Success). Aby poprawnie obsługiwać również takie przypadki, możemy stworzyć następującą metodę:

protected IActionResult HandleOperationResult(OperationResult result)
{
    switch (result.Status)
    {
        case OperationResultStatus.Success:
            if (result.GetValue() != null)
                return Ok(result.GetValue());
            return NoContent();
        case OperationResultStatus.Forbidden:
            return new ObjectResult(result.Message)
            {
                StatusCode = StatusCodes.Status403Forbidden
            };
        case OperationResultStatus.NotFound:
            return NotFound(result.Message);
        case OperationResultStatus.Error:
            return BadRequest(result.Message);
        // ...
        default:
            throw new ArgumentOutOfRangeException();
    }
}

I zmodyfikować nasz kontroler:

[HttpGet("{itemId}")]
public async Task<IActionResult> RetrieveByIdAsync(Guid itemId)
{
    var result = await _itemsReadService.RetrieveByIdAsync(itemId);
    return HandleOperationResult(result);
}

Takie rozwiązanie jak najbardziej działa. Metoda HandleOperationResult może zostać umieszczona w klasie bazowej i każdy kontroler miałby do niej dostęp. Jedyny minus jest taki, że za każdym razem przy zwracaniu wyniku trzeba pamiętać, aby ją wywołać.

Filtr

Jeśli chcemy, aby status automatycznie mapował się na odpowiedni kod http, możemy stworzyć odpowiednie filtry. W tym celu należy zaimplementować interfejs IAlwaysRunResultFilter lub IResultFilter i problem z głowy (oba te interfejsy mają odpowiedniki Async, ale w tym konkretnym przypadku nie wydają się one potrzebne).

Przykładowa implementacja może wyglądać tak:

public class OperationResultHandlerFilter : IAlwaysRunResultFilter
{
    public void OnResultExecuted(ResultExecutedContext context)
    {
    }

    public void OnResultExecuting(ResultExecutingContext context)
    {
        if (context != null &&
            context.Result is ObjectResult resultObj &&
            resultObj.Value is OperationResult result)
        {
            context.Result = result.Status switch
            {
                OperationResultStatus.Success => (result.GetValue() != null
                    ? (IActionResult)new OkObjectResult(result.GetValue())
                    : (IActionResult)new NoContentResult()),
                OperationResultStatus.Forbidden => new ObjectResult(result.Message)
                {
                    StatusCode = StatusCodes.Status403Forbidden
                },
                OperationResultStatus.NotFound => new NotFoundObjectResult(result.Message),
                OperationResultStatus.Error => new BadRequestObjectResult(result.Message),
                // ...
                _ => throw new ArgumentOutOfRangeException()
            };
        }
    }
}

Musimy jednak zmienić jeszcze typ zwracany przez nasz kontroler:

[HttpGet("{itemId}")]
public async Task<OperationResult> RetrieveByIdAsync(Guid itemId)
{
    var result = await _itemsReadService.RetrieveByIdAsync(itemId);
    return result;
}

Rejestracja takiego filtru:

services.AddControllers(options =>
{
    options.Filters.Add<OperationResultHandlerFilter>();
});

Middleware

Taki sam efekt jak przy użyciu filtrów można uzyskać, tworząc odpowiednie oprogramowanie pośredniczące (middleware). Niestety, aby odczytać, a następnie zmodyfikować odpowiedź (response), trzeba napisać trochę bardziej skomplikowany kod, dlatego nie polecam stosować tego sposobu. Jednakże jest to możliwe i oto przykładowy kod:

public class ResultHandlingMiddleware
{
    private readonly RequestDelegate _next;

    public ResultHandlingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        var existingBody = context.Response.Body;

        using (var newBody = new MemoryStream())
        {
            context.Response.Body = newBody;

            await _next(context);

            newBody.Seek(0, SeekOrigin.Begin);
            var body = new StreamReader(newBody).ReadToEnd();

            context.Response.Body = existingBody;

            await TryHandleOperationResultAsync(context, body);
        }
    }

    // ...
}

Ponieważ nie można odczytać strumienia (Stream) z context.Response.Body, tworzymy nowy strumień, aby móc odczytać ciało odpowiedzi. Jednakże początkowy strumień będzie nam potrzebny, aby zapisać do niego nową odpowiedź, więc zapisujemy go w zmiennej tymczasowej. Po przetworzeniu żądania (request) przesuwamy nasz nowy strumień na początek, aby odczytać z niego odpowiedź (response). Odczytujemy tę odpowiedź, a następnie nadpisujemy context.Response.Body początkowym strumieniem. Jeśli na tym byśmy zakończyli, nasza odpowiedź byłaby pusta. Dlatego w metodzie TryHandleOperationResultAsync modyfikujemy odpowiednio ciało odpowiedzi.

Metody TryHandleOperationResultAsync wygląda następująco:

private static async Task TryHandleOperationResultAsync(HttpContext context, string body)
{
    try
    {
        var result = JsonConvert.DeserializeObject<OperationResult<object>>(body);
        var bodyToReturn = result.GetValue() != null
            ? JsonConvert.SerializeObject(result.GetValue())
            : result.Message;

        switch (result.Status)
        {
            case OperationResultStatus.Success:
                var value = result.GetValue();
                if (value != null)
                {
                    context.Response.StatusCode = (int)HttpStatusCode.OK;
                    await context.Response.WriteAsync(bodyToReturn);
                }
                else
                {
                    context.Response.StatusCode = (int)HttpStatusCode.NoContent;
                }

                break;
            case OperationResultStatus.Error:
                context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
                context.Response.ContentType = "text/plain";
                await context.Response.WriteAsync(bodyToReturn);
                break;
            case OperationResultStatus.Forbid:
                context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
                context.Response.ContentType = "text/plain";
                await context.Response.WriteAsync(bodyToReturn);
                break;
            case OperationResultStatus.NotFound:
                context.Response.StatusCode = (int)HttpStatusCode.NotFound;
                context.Response.ContentType = "text/plain";
                await context.Response.WriteAsync(bodyToReturn);
                break;
        }
    }
    catch (JsonSerializationException e)
    {
        // ...
        await context.Response.WriteAsync(body);
    }
}

Na początku deserializujemy odpowiedź. Jeśli z metody faktycznie był zwracany OperationResult, to na podstawie statusu ustawiamy odpowiedni kod http, a do ciała odpowiedzi zapisujemy odpowiedzi komunikat lub obiekt. Jeśli deserializacja się nie udała, poleci nam JsonSerializationException. Jeśli go nie obsłużymy, nasza odpowiedź będzie pusta. W związku z tym, gdy deserializacja się nie uda, do ciała zwracanej odpowiedzi zapisujemy ciało z odczytanej odpowiedzi.

Musimy jeszcze pamiętać o rejestracji naszego middleware:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ...
    app.UseMiddleware<ResultHandlingMiddleware>();
    // ...
}

OperationResult

Dla przypomnienia jeszcze przykładowa implementacja OperationResult<T> i OperationResult:

public class OperationResult<T> : OperationResult
{
    public OperationResult(T value, string message, OperationResultStatus status)
        : base(message, status)
    {
        Value = value;
    }

    public T Value { get; }

    public override object GetValue()
    {
        return Value;
    }

    ...
}
public class OperationResult
{
    public OperationResult(string message, OperationResultStatus status)
    {
        Message = message;
        Status = status;
    }

    public string Message { get; }
    public OperationResultStatus Status { get; }

    public virtual object GetValue()
    {
        return null;
    }

    ...
}

1 komentarz do “Jak obsłużyć zwracanie rezultatu w WebAPI”

  1. Pingback: dotnetomaniak.pl

Leave a Reply