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 ofEnsureSuccessStatusCode+ manualif (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 / 205 → Fail. T : notnull. |
ReadJsonMaybeAsync<T>(jsonTypeInfo, ct) |
Task<Result<HttpResponseMessage>> |
Task<Result<Maybe<T>>> |
Optional-payload deserialization. 204 / 205 / empty / JSON null → Ok(Maybe.None). Invalid JSON throws JsonException (intentional). T : notnull. |
ReadJsonOrNoneOn404Async<T>(jsonTypeInfo, ct) |
Task<HttpResponseMessage> |
Task<Result<Maybe<T>>> |
Terminal optional-resource read. 404 → Ok(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.
404means absence →ReadJsonOrNoneOn404Async.404is an error → bareToResultAsync()orHandleNotFoundAsync. - Always pass
CancellationToken. Every helper accepts it. - One status mapper, not many. Prefer
ToResultAsync(statusMap)over chaining multipleHandle*Asynccalls when you map more than one status.
Cross-references
- API surface:
trellis-api-http.md Result<T>andMaybe<T>semantics:trellis-api-core.md- Cookbook recipe (HTTP client → result pipelines):
trellis-api-cookbook.md - Migration from v1 (full collapsed-verbs table):
trellis-api-http.md→ Breaking changes