Table of Contents

HTTP Client Integration

Trellis.Http bridges Task<HttpResponseMessage> into Task<Result<HttpResponseMessage>> and Task<Result<T>> pipelines so calling code stops mixing status-code branching, exception flow, and JSON deserialization.

Patterns Index

Goal Use See
Bridge a raw HttpClient call into a result pipeline (no body inspection) ToResultAsync() or ToResultAsync(statusMap) Status mapping
Map one well-known status (404 / 401 / 409) to a typed Error HandleNotFoundAsync / HandleUnauthorizedAsync / HandleConflictAsync Single-status handlers
Map many statuses with a status switch ToResultAsync(Func<HttpStatusCode, Error?>) Multi-status status switch
Map statuses by inspecting the response body ToResultAsync(Func<HttpResponseMessage, CancellationToken, Task<Error?>>, ct) Body-aware mapping
Read a required JSON body ReadJsonAsync<T>(jsonTypeInfo, ct) Reading JSON
Read an optional body (allow empty / 204 / 205 / JSON null) ReadJsonMaybeAsync<T>(jsonTypeInfo, ct) Reading JSON
Treat 404 as "resource absent" (return Maybe.None) ReadJsonOrNoneOn404Async<T>(jsonTypeInfo, ct) Reading JSON

Use this guide when

  • You call another HTTP service from .NET and want results, not exceptions, for expected statuses.
  • You need to deserialize JSON into a typed payload while keeping AOT-friendly source-generated metadata.
  • You want a single primitive (ToResultAsync) instead of EnsureSuccessStatusCode + manual if (response.StatusCode == ...) branching.

Surface at a glance

Trellis.Http exposes one static class, HttpResponseExtensions, with eight extension methods.

Method Receiver Returns Purpose
ToResultAsync(statusMap?) Task<HttpResponseMessage> Task<Result<HttpResponseMessage>> Bridge into a result pipeline. With no map, 2xx → Ok, non-2xx → typed Trellis failure. With a map, return null to pass through. (No CancellationToken parameter — let the upstream *Async call carry it.)
ToResultAsync(mapper, ct) Task<HttpResponseMessage> Task<Result<HttpResponseMessage>> Body-aware bridge. Async mapper invoked only on non-success status codes.
HandleNotFoundAsync(error) Task<HttpResponseMessage> Task<Result<HttpResponseMessage>> Map 404 to a typed Fail.
HandleUnauthorizedAsync(error) Task<HttpResponseMessage> Task<Result<HttpResponseMessage>> Map 401 to a typed Fail.
HandleConflictAsync(error) Task<HttpResponseMessage> Task<Result<HttpResponseMessage>> Map 409 to a typed Fail.
ReadJsonAsync<T>(jsonTypeInfo, ct) Task<Result<HttpResponseMessage>> Task<Result<T>> Required-payload deserialization. Empty / null / invalid JSON / 204 / 205Fail. T : notnull.
ReadJsonMaybeAsync<T>(jsonTypeInfo, ct) Task<Result<HttpResponseMessage>> Task<Result<Maybe<T>>> Optional-payload deserialization. 204 / 205 / empty / JSON nullOk(Maybe.None). Invalid JSON throws JsonException (intentional). T : notnull.
ReadJsonOrNoneOn404Async<T>(jsonTypeInfo, ct) Task<HttpResponseMessage> Task<Result<Maybe<T>>> Terminal optional-resource read. 404Ok(Maybe.None); other non-2xx use strict mapping. T : notnull.

Full signatures: trellis-api-http.md.

Installation

dotnet add package Trellis.Http

Quick start

Call an endpoint, map one expected failure status, read a required payload.

using System.Net.Http;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Trellis;
using Trellis.Http;

[JsonSerializable(typeof(UserDto))]
internal partial class ApiJsonContext : JsonSerializerContext { }

public sealed record UserDto(string Id, string DisplayName);

public sealed class UserDirectoryClient(HttpClient httpClient)
{
    public Task<Result<UserDto>> GetUserAsync(string userId, CancellationToken ct) =>
        httpClient.GetAsync($"users/{userId}", ct)
            .HandleNotFoundAsync(new Error.NotFound(ResourceRef.For<UserDto>(userId)) { Detail = $"User {userId} not found" })
            .ReadJsonAsync(ApiJsonContext.Default.UserDto, ct);
}

Status mapping

Handle status codes before reading the body. This keeps transport failures separate from payload bugs.

Single-status handlers

Use these when one specific failure status is part of the contract.

Handler HTTP status Produces
HandleNotFoundAsync 404 Error.NotFound
HandleUnauthorizedAsync 401 Error.Unauthorized
HandleConflictAsync 409 Error.Conflict

Each operates on Task<HttpResponseMessage> (the entry point of the chain). The next operator is ReadJsonAsync / ReadJsonMaybeAsync.

using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Trellis;
using Trellis.Http;

[JsonSerializable(typeof(CreateOrderRequest))]
[JsonSerializable(typeof(OrderDto))]
internal partial class OrdersJsonContext : JsonSerializerContext { }

public sealed record CreateOrderRequest(string CustomerId, decimal Total);
public sealed record OrderDto(string Id, decimal Total);

public sealed class OrdersClient(HttpClient httpClient)
{
    public Task<Result<OrderDto>> CreateAsync(CreateOrderRequest request, CancellationToken ct) =>
        httpClient.PostAsJsonAsync("orders", request, OrdersJsonContext.Default.CreateOrderRequest, ct)
            .HandleUnauthorizedAsync(new Error.Unauthorized() { Detail = "Sign in before creating orders." })
            .ReadJsonAsync(OrdersJsonContext.Default.OrderDto, ct);
}

Multi-status status switch

For more than one mapped status, use ToResultAsync(Func<HttpStatusCode, Error?>). Return null to pass through; return an Error to short-circuit the chain.

using System;
using System.Net;
using System.Net.Http;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Trellis;
using Trellis.Http;

[JsonSerializable(typeof(ProductDto))]
internal partial class ProductsJsonContext : JsonSerializerContext { }

public sealed record ProductDto(string Id, string Name);

public sealed class ProductsClient(HttpClient httpClient)
{
    public Task<Result<ProductDto>> GetAsync(string productId, CancellationToken ct) =>
        httpClient.GetAsync($"products/{productId}", ct)
            .ToResultAsync(status => status switch
            {
                HttpStatusCode.NotFound  => new Error.NotFound(ResourceRef.For<ProductDto>(productId)),
                HttpStatusCode.Forbidden => new Error.Forbidden("products.read"),
                _ when (int)status >= 500 => new Error.InternalServerError(Guid.NewGuid().ToString("N")) { Detail = $"upstream {status}" },
                _ when (int)status >= 400 => new Error.InternalServerError(Guid.NewGuid().ToString("N")) { Detail = $"client error {status}" },
                _ => null,
            })
            .ReadJsonAsync(ProductsJsonContext.Default.ProductDto, ct);
}

Body-aware mapping

When status alone is not enough, supply Func<HttpResponseMessage, CancellationToken, Task<Error?>>. The mapper is invoked only on non-success responses and may read body, headers, or problem-details payload.

using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Trellis;
using Trellis.Http;

[JsonSerializable(typeof(CreateInvoiceRequest))]
[JsonSerializable(typeof(InvoiceDto))]
internal partial class InvoicesJsonContext : JsonSerializerContext { }

public sealed record CreateInvoiceRequest(string CustomerId, decimal Total);
public sealed record InvoiceDto(string Id, decimal Total);

public sealed class InvoicesClient(HttpClient httpClient)
{
    public Task<Result<InvoiceDto>> CreateAsync(CreateInvoiceRequest request, CancellationToken ct) =>
        httpClient.PostAsJsonAsync("invoices", request, InvoicesJsonContext.Default.CreateInvoiceRequest, ct)
            .ToResultAsync(async (response, token) =>
            {
                var body = await response.Content.ReadAsStringAsync(token);
                return response.StatusCode switch
                {
                    HttpStatusCode.Conflict   => new Error.Conflict(null, "conflict") { Detail = body },
                    HttpStatusCode.BadRequest => new Error.BadRequest("bad-req") { Detail = body },
                    _ => new Error.InternalServerError("upstream") { Detail = $"Invoice request failed with {(int)response.StatusCode}: {body}" },
                };
            }, ct)
            .ReadJsonAsync(InvoicesJsonContext.Default.InvoiceDto, ct);
}

Capture caller state via closure — the v1 TContext channel is gone (it was redundant).

Reading JSON

Three read modes, distinguished by what "no payload" means.

ReadJsonAsync<T> — required payload

Outcome Result
2xx + valid JSON Ok(value)
2xx + null body / empty / invalid JSON / 204 / 205 Fail
Non-2xx Fail (passes through prior failure or maps via strict default)

ReadJsonMaybeAsync<T> — optional payload

Outcome Result
2xx + valid JSON Ok(Maybe.From(value))
2xx + 204 / 205 / empty / JSON null Ok(Maybe.None)
2xx + invalid JSON throws JsonException (response disposed first)
Non-2xx Fail

ReadJsonOrNoneOn404Async<T> — optional resource

Outcome Result
2xx + valid JSON Ok(Maybe.From(value))
2xx + 204 / 205 / empty / JSON null Ok(Maybe.None)
404 Ok(Maybe.None)
Other non-2xx Fail (typed Trellis failure via strict mapping)
Warning

ReadJsonMaybeAsync does NOT catch JsonException. Use it when "optional body" is allowed, not when malformed JSON should be silent. The response is still disposed before the exception escapes.

Disposal contract

Trellis.Http owns HttpResponseMessage disposal on terminal and transformative paths.

Path Disposes?
ToResultAsync (both overloads) on the Fail branch Yes
Handle*Async on the matching status (Fail branch) Yes
Pass-through (success from bare ToResultAsync, non-matching Handle*Async, mapper returning null) No — caller owns until the chain reaches ReadJson*
ReadJsonAsync / ReadJsonMaybeAsync / ReadJsonOrNoneOn404Async Yes — always, success or failure

In practice: once you call any ReadJson*, you no longer need to dispose the response yourself.

Composition

Once an HTTP call becomes Result<T>, it composes with the rest of Trellis (Bind, Map, Ensure, etc.).

using System.Net.Http;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Trellis;
using Trellis.Http;

[JsonSerializable(typeof(InventoryCheckDto))]
[JsonSerializable(typeof(PaymentReceiptDto))]
internal partial class CheckoutJsonContext : JsonSerializerContext { }

public sealed record InventoryCheckDto(bool InStock);
public sealed record PaymentReceiptDto(string Id);

public sealed class CheckoutClient(HttpClient httpClient)
{
    public Task<Result<PaymentReceiptDto>> ChargeAsync(string productId, CancellationToken ct) =>
        httpClient.GetAsync($"inventory/{productId}", ct)
            .ToResultAsync()
            .ReadJsonAsync(CheckoutJsonContext.Default.InventoryCheckDto, ct)
            .EnsureAsync(
                inventory => inventory.InStock,
                new Error.UnprocessableContent(EquatableArray.Create(
                    new FieldViolation(InputPointer.ForProperty(nameof(productId)), "validation.error") { Detail = "Out of stock." })))
            .BindAsync(
                (_, token) => httpClient.PostAsync($"payments/{productId}", null, token)
                    .ToResultAsync()
                    .ReadJsonAsync(CheckoutJsonContext.Default.PaymentReceiptDto, token),
                ct);
}

Practical guidance

  • Use source-generated JSON metadata. Keeps the chain AOT-friendly and matches the JsonTypeInfo<T> overloads.
  • Pick the right read mode. 404 means absence → ReadJsonOrNoneOn404Async. 404 is an error → bare ToResultAsync() or HandleNotFoundAsync.
  • Always pass CancellationToken. Every helper accepts it.
  • One status mapper, not many. Prefer ToResultAsync(statusMap) over chaining multiple Handle*Async calls when you map more than one status.

Cross-references