Skip to main content

HTTP Mapping (Minimal API)

The UnambitiousFx.Functional.AspNetCore.Http namespace provides extension methods that turn Result, Result<T>, and Maybe<T> into ASP.NET Core IResult responses for Minimal APIs. Each extension returns an awaitable builder you can configure fluently, then await to produce the final IResult.

using UnambitiousFx.Functional.AspNetCore.Http;

The builder entry points

AsHttpBuilder is the single entry point. It is overloaded for synchronous and asynchronous (ValueTask) values, and accepts an optional IFailureHttpMapper (falls back to DefaultFailureHttpMapper when omitted):

// Result (no value) → ResultHttpBuilder
result.AsHttpBuilder();
valueTaskOfResult.AsHttpBuilder();

// Result<T> → ResultHttpBuilder<TValue>
resultOfT.AsHttpBuilder();
valueTaskOfResultOfT.AsHttpBuilder();

// Maybe<T> → MaybeHttpBuilder<TValue>
maybe.AsHttpBuilder();
valueTaskOfMaybe.AsHttpBuilder();

// Provide a specific mapper (e.g. from DI)
resultOfT.AsHttpBuilder(myFailureMapper);

Every builder exposes GetAwaiter(), so you can await it directly in an endpoint handler:

app.MapGet("/users/{id:guid}", async (Guid id, IUserService service) =>
await service.GetUserAsync(id).AsHttpBuilder());

Default success mapping

SourceDefault success response
Result (no value)204 No Content
Result<T>200 OK + value as JSON
Maybe<T>Some200 OK + value as JSON
Maybe<T>None404 Not Found

Failure branches are always routed through the failure mapper (see Failure mapping below).

Configuring the response

All builders are immutable — each method returns a new builder.

Override the success status code

app.MapPost("/jobs", async (CreateJob cmd, IJobService service) =>
await service.QueueAsync(cmd)
.AsHttpBuilder()
.WithStatusCode(202)); // 202 Accepted

Recognized success codes map to typed results: 200Ok, 201Created, 202Accepted, 204NoContent. Any other code falls back to Results.StatusCode(code).

201 Created with a Location

AsCreated sets the status to 201 and builds a Location header from the success value:

app.MapPost("/users", async (CreateUserRequest request, IUserService service) =>
await service.CreateAsync(request)
.AsHttpBuilder()
.AsCreated(user => $"/users/{user.Id}"));

Add response headers

await service.GetUserAsync(id)
.AsHttpBuilder()
.WithHeader("Cache-Control", "no-store");

Reshape the success body

WithResponseFormatter transforms the success value before serialization (for example, mapping a domain entity to a DTO):

await service.GetUserAsync(id)
.AsHttpBuilder()
.WithResponseFormatter(user => new UserDto(user.Id, user.Name));

Customize the None response (Maybe only)

By default Maybe<T>.None becomes 404. Override it with AsNone:

using HttpResults = Microsoft.AspNetCore.Http.Results;

await service.FindDraftAsync(id)
.AsHttpBuilder()
.AsNone(() => HttpResults.NoContent());

Minimal API patterns

Query endpoint (read)

app.MapGet("/users/{id:guid}", async (Guid id, IUserService service) =>
await service.GetUserAsync(id).AsHttpBuilder());
// Found → 200 OK + user
// NotFound → 404 Not Found (ProblemDetails)

Optional lookup with Maybe<T>

app.MapGet("/profiles/{id:guid}", async (Guid id, IProfileService service) =>
await service.FindProfileAsync(id).AsHttpBuilder());
// Some → 200 OK + profile
// None → 404 Not Found

Create endpoint (command)

app.MapPost("/users", async (CreateUserRequest request, IUserService service) =>
await service.CreateAsync(request)
.AsHttpBuilder()
.AsCreated(user => $"/users/{user.Id}"));
// Success → 201 Created + Location: /users/{id}
// Failure → mapped status (e.g. 400 for ValidationFailure)

Fire-and-forget command (no value)

app.MapDelete("/users/{id:guid}", async (Guid id, IUserService service) =>
await service.DeleteAsync(id).AsHttpBuilder());
// Success → 204 No Content
// Failure → mapped status (e.g. 404)

Endpoint design tips

  • Keep validation and business logic in services that return Result / Maybe.
  • Keep handlers thin — binding plus transport adaptation only.
  • Use WithResponseFormatter to keep domain types out of your wire contract.
  • Reuse the same IFailureHttpMapper across all endpoints for consistent error shapes.

Failure-to-status mapping

When the outcome is a failure, the builder calls IFailureHttpMapper.GetFailureResponse(failure) and converts the resulting FailureHttpResponse into an IResult. The built-in DefaultFailureHttpMapper maps the standard failure types from UnambitiousFx.Functional.Failures:

Failure typeHTTP status
ValidationFailure400 Bad Request
BadRequestFailure400 Bad Request
NotFoundFailure404 Not Found
UnauthorizedFailure401 Unauthorized
UnauthenticatedFailure403 Forbidden
ConflictFailure409 Conflict
ExceptionalFailure500 Internal Server Error
Any other failure500 Internal Server Error

To change these mappings, see Custom Mappers.

How a FailureHttpResponse becomes an IResult

The builder inspects the mapped FailureHttpResponse:

  • null response → Results.Problem(statusCode: 500).
  • No BodyTypedResults.StatusCode(statusCode).
  • Body is a ProblemDetailsTypedResults.Problem(problemDetails).
  • Otherwise, by status code: 400BadRequest(body), 401Unauthorized(), 403Forbid(), 404NotFound(body), 409Conflict(body), 500Problem(statusCode: 500), anything else → StatusCode(code).

Any Headers and ContentType on the FailureHttpResponse are applied to the response.

Problem Details shape

DefaultFailureHttpMapper produces an RFC ProblemDetails body. The fields are populated per failure type:

// 404 from a NotFoundFailure
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
"title": "Not Found",
"status": 404,
"detail": "Resource 'User' with id '42' was not found.",
"code": "NOT_FOUND",
"resource": "User",
"identifier": "42"
}
// 400 from a ValidationFailure
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "Validation Error",
"status": 400,
"detail": "Name is required; Email is invalid",
"code": "VALIDATION",
"failure[0]": "Name is required",
"failure[1]": "Email is invalid"
}

NotFoundFailure adds code, resource, and identifier extensions; ValidationFailure adds code plus one failure[i] extension per message. Conflict, BadRequest, Unauthorized, Unauthenticated, and Exceptional failures produce a ProblemDetails with title, detail, status, and type.

See also