Trellis.Http — API Reference
Package: Trellis.Http
Namespace: Trellis.Http
Purpose: Bridge Task<HttpResponseMessage> into Task<Result<HttpResponseMessage>> pipelines and deserialize JSON payloads into Result<T> / Result<Maybe<T>>.
See also: trellis-api-cookbook.md — recipes using this package.
Bare
ToResultAsync()is strict by default: non-2xx responses become typed Trellis failures instead of remaining on the success track.
Use this file when
- You are adapting
HttpClientcalls into TrellisResultpipelines. - You need optional-resource behavior where
404meansMaybe<T>.None. - You need response disposal rules for
HttpResponseMessagewhen chaining status mapping and JSON reads.
Patterns Index
| Goal | Canonical API / pattern | See |
|---|---|---|
| Strictly fail non-2xx responses | client.GetAsync(...).ToResultAsync() |
HttpResponseExtensions |
| Map one expected status to a domain error | HandleNotFoundAsync, HandleConflictAsync, HandleUnauthorizedAsync |
HttpResponseExtensions |
| Map several statuses | ToResultAsync(status => status switch { ... }) |
Multi-status mapping |
| Inspect an error body before deciding | Body-aware ToResultAsync((response, ct) => ...) |
Body-aware mapping |
| Deserialize a required JSON body | .ReadJsonAsync(jsonTypeInfo, ct) |
HttpResponseExtensions |
| Deserialize optional JSON body | .ReadJsonMaybeAsync(jsonTypeInfo, ct) |
HttpResponseExtensions |
Treat 404 as expected absence |
.ReadJsonOrNoneOn404Async(jsonTypeInfo, ct) |
HttpResponseExtensions |
Common traps
- Once you call any
ReadJson*terminal helper, the response is disposed by the helper. ReadJsonMaybeAsynctreats204,205, empty body, and JSONnullasMaybe.None; invalid JSON intentionally throws.- Use
ReadJsonOrNoneOn404Asyncfor optional reads. Do not hand-roll a separate 404 branch unless you need custom behavior.
Type
HttpResponseExtensions
public static class HttpResponseExtensions
| Signature | Returns | Notes |
|---|---|---|
ToResultAsync(this Task<HttpResponseMessage> response, Func<HttpStatusCode, Error?>? statusMap = null) |
Task<Result<HttpResponseMessage>> |
When statusMap is null, 2xx statuses pass through as Ok(response) and non-2xx statuses map to typed Trellis errors. When supplied, a null return passes through; a non-null Error becomes Fail and the underlying response is disposed. |
ToResultAsync(this Task<HttpResponseMessage> response, Func<HttpResponseMessage, CancellationToken, Task<Error?>> mapper, CancellationToken ct = default) |
Task<Result<HttpResponseMessage>> |
Body-aware bridge. The mapper is invoked only when IsSuccessStatusCode == false. null return -> Ok(response); non-null -> Fail (response disposed). |
HandleNotFoundAsync(this Task<HttpResponseMessage> response, Error.NotFound error) |
Task<Result<HttpResponseMessage>> |
Maps 404 to Fail(error) (response disposed); any other status passes through as Ok(response). |
HandleConflictAsync(this Task<HttpResponseMessage> response, Error.Conflict error) |
Task<Result<HttpResponseMessage>> |
Maps 409 to Fail(error) (response disposed); pass through otherwise. |
HandleUnauthorizedAsync(this Task<HttpResponseMessage> response, Error.Unauthorized error) |
Task<Result<HttpResponseMessage>> |
Maps 401 to Fail(error) (response disposed); pass through otherwise. |
ReadJsonAsync<T>(this Task<Result<HttpResponseMessage>> response, JsonTypeInfo<T> jsonTypeInfo, CancellationToken ct = default) where T : notnull |
Task<Result<T>> |
Already-failed input short-circuits with the upstream error. Otherwise reads the body and deserializes; non-success status, 204, 205, empty body, null payload, or JsonException (caught) all map to Fail<InternalServerError>. Always disposes the response after reading. |
ReadJsonMaybeAsync<T>(this Task<Result<HttpResponseMessage>> response, JsonTypeInfo<T> jsonTypeInfo, CancellationToken ct = default) where T : notnull |
Task<Result<Maybe<T>>> |
Already-failed input short-circuits. Non-success status -> Fail<InternalServerError>. 204, 205, empty body, JSON null -> Ok(Maybe.None). Invalid JSON throws JsonException (intentional). Always disposes the response. |
ReadJsonOrNoneOn404Async<T>(this Task<HttpResponseMessage> response, JsonTypeInfo<T> jsonTypeInfo, CancellationToken ct = default) where T : notnull |
Task<Result<Maybe<T>>> |
Terminal optional-resource helper. 404 -> Ok(Maybe.None); other non-2xx statuses use strict status mapping; 204, 205, empty body, and JSON null keep ReadJsonMaybeAsync semantics. Always disposes the response. |
Business API default. Bare
ToResultAsync()is now the safe default for domain-facing HTTP clients. UseHandleNotFoundAsync,HandleConflictAsync,HandleUnauthorizedAsync, or an explicitstatusMaponly when the endpoint needs domain-specific error payloads.
Disposal contract
The library owns the HttpResponseMessage lifecycle on terminal or transformative paths:
ToResultAsync(both overloads) dispose the response on theFailpath.HandleNotFoundAsync,HandleConflictAsync,HandleUnauthorizedAsyncdispose on the matched-statusFailpath.ReadJsonAsync,ReadJsonMaybeAsync, andReadJsonOrNoneOn404Asyncalways dispose after reading, success or failure (including whenJsonExceptionpropagates from theMaybeoverload).- Pass-through paths (success from bare
ToResultAsync, non-matchingHandle*, mapper returningnull) leave disposal to the caller.
In practice: once you call ReadJson*, you no longer need to dispose the response yourself.
Examples
Happy path: GET, map 404, deserialize
using System.Text.Json.Serialization;
using Trellis;
using Trellis.Http;
[JsonSerializable(typeof(TodoDto))]
internal partial class AppJsonContext : JsonSerializerContext { }
public sealed record TodoDto(Guid Id, string Title);
public Task<Result<TodoDto>> GetTodoAsync(HttpClient client, Guid id, CancellationToken ct) =>
client.GetAsync($"/todos/{id}", ct)
.HandleNotFoundAsync(new Error.NotFound(ResourceRef.For<TodoDto>(id)))
.ReadJsonAsync(AppJsonContext.Default.TodoDto, ct);
Multi-status mapping with ToResultAsync(statusMap)
public Task<Result<TodoDto>> GetTodoStrictAsync(HttpClient client, Guid id, CancellationToken ct) =>
client.GetAsync($"/todos/{id}", ct)
.ToResultAsync(status => status switch
{
HttpStatusCode.NotFound => new Error.NotFound(ResourceRef.For<TodoDto>(id)),
HttpStatusCode.Forbidden => new Error.Forbidden("todo.read"),
_ when (int)status >= 500 => new Error.InternalServerError(Guid.NewGuid().ToString("N")) { Detail = $"upstream {status}" },
_ => null,
})
.ReadJsonAsync(AppJsonContext.Default.TodoDto, ct);
Body-aware mapping (replaces HandleFailureAsync<TContext>)
public Task<Result<TodoDto>> GetTodoWithProblemDetailsAsync(HttpClient client, Guid id, CancellationToken ct) =>
client.GetAsync($"/todos/{id}", ct)
.ToResultAsync(async (response, token) =>
{
// Read RFC 9457 problem-details body to synthesize a richer error.
var problem = await response.Content
.ReadFromJsonAsync<ProblemDetails>(cancellationToken: token);
return problem is null
? null
: new Error.InternalServerError(Guid.NewGuid().ToString("N")) { Detail = problem.Detail ?? "upstream error" };
}, ct)
.ReadJsonAsync(AppJsonContext.Default.TodoDto, ct);
Optional resource with ReadJsonOrNoneOn404Async
public Task<Result<Maybe<TodoDto>>> FindTodoAsync(HttpClient client, Guid id, CancellationToken ct) =>
client.GetAsync($"/todos/{id}", ct)
.ReadJsonOrNoneOn404Async(AppJsonContext.Default.TodoDto, ct);
Breaking changes from v1
The v1 surface (60+ overloads across two static classes) has been collapsed into a small canonical method set. There are no shims or compatibility redirects: this is a clean cut, taken pre-GA.
| Previous API | Current replacement |
|---|---|
HandleNotFound, HandleNotFoundAsync (sync, Result<HRM>, Task<Result<HRM>> overloads) |
HandleNotFoundAsync(this Task<HttpResponseMessage>, Error.NotFound) |
HandleConflict* |
HandleConflictAsync(this Task<HttpResponseMessage>, Error.Conflict) |
HandleUnauthorized* |
HandleUnauthorizedAsync(this Task<HttpResponseMessage>, Error.Unauthorized) |
HandleForbidden* |
Deleted. Use ToResultAsync(status => status == HttpStatusCode.Forbidden ? new Error.Forbidden(...) : null). |
HandleClientError* (4xx range), HandleServerError* (5xx range) |
Deleted. Use ToResultAsync(statusMap) with a switch over HttpStatusCode. |
EnsureSuccess, EnsureSuccessAsync (all shapes) |
Deleted. Use ToResultAsync(status => (int)status >= 400 ? error : null) or the body-aware ToResultAsync(mapper, ct). |
HandleFailureAsync<TContext> (response-shape and Result<HRM>-shape) |
Deleted. Use the body-aware ToResultAsync(mapper, ct); capture additional state via closure. |
ReadResultFromJsonAsync<T> (sync, Result<HRM>, Task<HRM>, Task<Result<HRM>>) |
Renamed ReadJsonAsync<T>(this Task<Result<HttpResponseMessage>>, JsonTypeInfo<T>, CancellationToken). |
ReadResultMaybeFromJsonAsync<T> (all shapes) |
Renamed ReadJsonMaybeAsync<T>(this Task<Result<HttpResponseMessage>>, JsonTypeInfo<T>, CancellationToken). |
Sync receivers (HttpResponseMessage, Result<HRM>) |
Deleted. Wrap with Task.FromResult(...) if needed; in practice every HttpClient call is already async. |