Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion docs/guide/handlers/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,48 @@ By default, Wolverine is assuming that any parameter value marked with `[Entity]
* As a message handler, it will just log that the entity could not be found and otherwise exit cleanly without doing any further processing
* As an HTTP endpoint, the handler would write out a status code of 404 (not found) and exit otherwise

You can choose a different answer with the `OnMissing` property:

| `OnMissing` | Message handler | HTTP endpoint |
|---|---|---|
| `Simple404` (default) | Log it and stop | Empty **404** |
| `ProblemDetailsWith400` | Log it and stop | **400** with a `ProblemDetails` body |
| `ProblemDetailsWith404` | Log it and stop | **404** with a `ProblemDetails` body |
| `EmptyContentWith204` <Badge type="tip" text="6.28" /> | Log it and stop | Empty **204** |
| `ThrowException` | Throws `RequiredDataMissingException` | Throws `RequiredDataMissingException` |

If you need or want any other kind of failure handling on the entity not being found, you'll need to
use explicit code instead, maybe with a `LoadAsync()` "before" method to still keep your main
handler or endpoint method a *pure function*.
handler or endpoint method a *pure function*.

### Answering 204 instead of 404 <Badge type="tip" text="6.28" />

A bare 404 is indistinguishable from "you called a Url that does not exist." If you would rather say
"the Url is correct, but there is no body," use `OnMissing.EmptyContentWith204`:

```cs
[WolverineGet("/api/alerts/config/services/{serviceName}")]
public static ServiceAlertOverrides Get(
[Entity(OnMissing = OnMissing.EmptyContentWith204)] ServiceAlertOverrides overrides)
=> overrides;
```

A request for a `serviceName` that has no overrides answers `204` with an empty body, and the generated
OpenAPI advertises `200` and `204` rather than `200` and `404`.

::: warning
Think about your clients before you reach for this. A 404 puts a miss on the failure branch of every
HTTP client; a 204 puts it on the success branch. Code that does `response.EnsureSuccessStatusCode()`
will start passing, and generated typed clients (NSwag, Kiota, Refit) will map the 204 onto their
success path — so a miss can surface later as a null dereference instead of at the call site. If what
you actually want is a *distinguishable* 404, `OnMissing.ProblemDetailsWith404` names the type and the
identity in a `application/problem+json` body and keeps the failure on the failure branch.
:::

On a `GET` or `QUERY` endpoint, `EmptyContentWith204` also forces the entity to be treated as required
even if you wrote `Required = false`. Running the endpoint body with a null entity so it can return an
empty body anyway buys nothing, and it is the one combination where "not required" and "answer 204"
contradict each other. This does not apply to message handlers or to other HTTP methods.

If you genuinely don't need the `[Entity]` value to be required, you can do this instead:

Expand Down Expand Up @@ -401,6 +440,14 @@ public static class MyHandler

The resolution order is: **Explicit attribute value > Global default > Built-in default** (`Simple404` / `true`).

::: tip
`EntityDefaults.OnMissing` reaches every attribute that loads data this way — `[Entity]`, `[Document]`,
`[Aggregate]`, `[ReadAggregate]`, `[WriteAggregate]`, `[ReadModel]`, `[WriteModel]`, and the DCB
attributes. Setting it to `OnMissing.EmptyContentWith204` changes the answer for *all* of them, including
aggregate endpoints you may not have been thinking about. Set it on the individual attributes instead if
you only meant a subset.
:::

Some other facts to know about `[Entity]` usage:

* Supported by the Marten, EF Core, and RavenDb integration
Expand Down
61 changes: 61 additions & 0 deletions docs/guide/http/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,67 @@ the underlying OpenAPI stack emits 3.2.

See [JSON serialization for more information](/guide/http/json)

## When the Response Body is Null <Badge type="tip" text="6.28" />

When an endpoint's resource comes back `null`, Wolverine writes an empty **404**:

```cs
[WolverineGet("/api/alerts/config/services/{serviceName}")]
public static async Task<ServiceAlertOverrides?> Get(string serviceName, IDocumentSession session)
// A miss here answers 404 with an empty body
=> await session.LoadAsync<ServiceAlertOverrides>(serviceName);
```

A bare 404 is indistinguishable from "you called a Url that does not exist." If you would rather say
"the Url is correct, but there is no body," decorate the endpoint method with `[NoContentIfMissing]` to
get an empty **204** instead:

```cs
[WolverineGet("/api/alerts/config/services/{serviceName}"), NoContentIfMissing]
public static async Task<ServiceAlertOverrides?> Get(string serviceName, IDocumentSession session)
// A miss now answers 204 with an empty body
=> await session.LoadAsync<ServiceAlertOverrides>(serviceName);
```

The generated OpenAPI follows: the endpoint advertises `200` and `204` rather than `200` and `404`.

`[NoContentIfMissing]` can also go on the endpoint *class*, where it applies to every endpoint method in
that class. A method level declaration always wins over a class level one, and `[NotFoundIfMissing]` is
how a single method opts back out.

### An Application Wide Default

Set `WolverineHttpOptions.OnMissingResponseBody` to change the default for the whole application:

```cs
app.MapWolverineEndpoints(opts =>
{
opts.OnMissingResponseBody = OnMissingResponseBody.NoContent204;
});
```

Use `[NotFoundIfMissing]` on any endpoint or endpoint class that should keep answering 404.

::: warning
`[NoContentIfMissing]` is only legal on `GET` and `QUERY` endpoints, and the application wide setting
only reaches those methods. Those are the safe, side effect free reads where an empty answer is a benign
outcome. On a `POST` or `PUT`, a 204 in place of a resource would turn a failed command into an apparent
success for the caller, so Wolverine fails fast at bootstrapping time rather than let you ship it.

Be deliberate about this even on reads. A 404 puts a miss on the failure branch of every HTTP client; a
204 puts it on the success branch. Code that calls `response.EnsureSuccessStatusCode()` will start
passing, and generated typed clients (NSwag, Kiota, Refit) will map the 204 onto their success path.
:::

::: tip
This setting is strictly about the response *body*. What happens when a required entity cannot be loaded
in the first place is a separate question, answered by
[`OnMissing` on `[Entity]`](/guide/handlers/persistence#answering-204-instead-of-404) — including its own
`OnMissing.EmptyContentWith204`. In an endpoint like
`Get([Entity] Thing thing) => thing`, the entity guard runs *before* the endpoint body, so it is
`OnMissing` — not `[NoContentIfMissing]` — that decides the answer.
:::

## Returning Strings

To create an endpoint that writes a string with `content-type` = "text/plain", just return a string as your resource type, so `string`, `Task<string>`, or `ValueTask<string>`
Expand Down
4 changes: 4 additions & 0 deletions docs/guide/http/marten.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ In the code above, if the `Invoice` document does not exist, the route will stop

If you, for whatever reason, want your handler executed even if the document does not exist, then you can set the `DocumentAttribute.Required` property to `false`.

Use the `OnMissing` property for any other answer — a `ProblemDetails` body, a thrown exception, or an
empty `204` with `OnMissing.EmptyContentWith204` <Badge type="tip" text="6.28" />. See
[the full `OnMissing` table](/guide/handlers/persistence#using-entity-for-message-handlers-and-http-endpoints).

:::info
Starting with Wolverine 3 `DocumentAttribute.Required = true` is the default behavior.
In previous versions the default value was `false`.
Expand Down
4 changes: 4 additions & 0 deletions docs/guide/http/polecat.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ In the code above, if the `Invoice` document does not exist, the route will stop

If you want your handler executed even if the document does not exist, set `Required` to `false`.

Use the `OnMissing` property for any other answer — a `ProblemDetails` body, a thrown exception, or an
empty `204` with `OnMissing.EmptyContentWith204` <Badge type="tip" text="6.28" />. See
[the full `OnMissing` table](/guide/handlers/persistence#using-entity-for-message-handlers-and-http-endpoints).

## Polecat Aggregate Workflow

The HTTP endpoints can play inside the full Wolverine + Polecat combination with Wolverine's [specific
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ public Variable CreateReadJsonBodyVariable(Type requestType)

[UnconditionalSuppressMessage("Trimming", "IL2026",
Justification = "MethodCall reflects NewtonsoftHttpSerialization.GetMethod(nameof(WriteJsonAsync)) at codegen time. The target method is statically referenced via nameof; the closed-generic type is rooted at codegen time per the AOT guide.")]
public Frame CreateWriteJsonFrame(Variable resourceVariable)
public Frame CreateWriteJsonFrame(Variable resourceVariable, int missingStatusCode = 404)
{
var frame = new MethodCall(typeof(NewtonsoftHttpSerialization),
nameof(NewtonsoftHttpSerialization.WriteJsonAsync));
frame.Arguments[1] = resourceVariable;
frame.Arguments[2] = Constant.For(missingStatusCode);
return frame;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ public NewtonsoftHttpSerialization(JsonSerializerSettings settings)
public JsonSerializerSettings Settings { get; set; }

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public async Task WriteJsonAsync(HttpContext context, object? body)
public async Task WriteJsonAsync(HttpContext context, object? body, int missingStatusCode = 404)
{
if (body == null)
{
context.Response.StatusCode = 404;
context.Response.StatusCode = missingStatusCode;
return;
}

Expand Down
6 changes: 4 additions & 2 deletions src/Http/Wolverine.Http.Marten/CompiledQueryWriterPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ public bool TryApply(HttpChain chain)
typeof(MartenQueryMethodCall<,>).CloseAndBuildAs<MethodCall>(result!, arguments);
chain.Postprocessors.Add(queryCall);

// This call writes the response directly to the HttpContext as a string
var writeStringCall = MethodCall.For<HttpHandler>(handler => HttpHandler.WriteString(null!, ""));
// This call writes the response directly to the HttpContext as a string. The status code has to be
// spelled out in the expression tree -- an expression tree cannot elide an optional argument.
var writeStringCall = MethodCall.For<HttpHandler>(handler => HttpHandler.WriteString(null!, "", 404));
writeStringCall.Arguments[1] = new Variable(queryCall.ReturnVariable!.VariableType,
$"{queryCall.ReturnVariable.Usage}.ToString()", queryCall);
writeStringCall.Arguments[2] = Constant.For(chain.MissingResponseBodyStatusCode);
chain.Postprocessors.Add(writeStringCall);
}
else
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using Wolverine.Persistence;
using WolverineWebApi.Todos;

namespace Wolverine.Http.Tests.Persistence;

/// <summary>
/// Endpoints for the two independent "there is nothing to send back" paths:
/// <see cref="OnMissing.EmptyContentWith204" /> covers a required entity that could not be loaded, while
/// <see cref="NoContentIfMissingAttribute" /> covers an endpoint whose response body is simply null.
/// </summary>
// Deliberately not a static class -- HttpChain.ChainFor<T>() needs a usable type argument
public class EmptyContentWith204Endpoints
{
// The entity guard answers 204 instead of the default 404
[WolverineGet("/no-content/entity/{id}")]
public static Todo2 GetEntity([Entity(OnMissing = OnMissing.EmptyContentWith204)] Todo2 todo) => todo;

// Required = false is deliberately ignored here: on a GET, EmptyContentWith204 forces the entity to be
// treated as required, because running the endpoint with a null entity to return an empty body anyway
// buys nothing. Without that, this endpoint would NRE on todo.Name.
[WolverineGet("/no-content/entity-not-required/{id}")]
public static string GetEntityNotRequired(
[Entity(OnMissing = OnMissing.EmptyContentWith204, Required = false)] Todo2 todo) => todo.Name!;

// No entity attribute at all -- just an endpoint whose resource comes back null
[WolverineGet("/no-content/body/{id}"), NoContentIfMissing]
public static Todo2? GetBody(string id) => id == "found" ? new Todo2 { Id = id, Name = "Found" } : null;

// The unannotated control for GetBody
[WolverineGet("/no-content/body-default/{id}")]
public static Todo2? GetBodyDefault(string id) => id == "found" ? new Todo2 { Id = id, Name = "Found" } : null;

// Same, but the resource type is a string, which goes through a different response writer
[WolverineGet("/no-content/string/{id}"), NoContentIfMissing]
public static string? GetString(string id) => id == "found" ? "Found" : null;

// The unannotated control for the string writer: a null string used to throw a NullReferenceException
// and answer 500 rather than the 404 every other resource type answered
[WolverineGet("/no-content/string-default/{id}")]
public static string? GetStringDefault(string id) => id == "found" ? "Found" : null;
}

/// <summary>
/// A class level [NoContentIfMissing] applies to every endpoint method in the class.
/// </summary>
[NoContentIfMissing]
public static class ClassLevelNoContentEndpoints
{
[WolverineGet("/no-content/class-level/{id}")]
public static Todo2? Get(string id) => id == "found" ? new Todo2 { Id = id, Name = "Found" } : null;

// ... unless the method opts back out
[WolverineGet("/no-content/class-level-opt-out/{id}"), NotFoundIfMissing]
public static Todo2? GetOptOut(string id) => id == "found" ? new Todo2 { Id = id, Name = "Found" } : null;
}

/// <summary>
/// Endpoints used to prove the global <c>WolverineHttpOptions.OnMissingResponseBody</c> setting, which only
/// reaches GET and QUERY endpoints.
/// </summary>
public static class GlobalMissingResponseBodyEndpoints
{
[WolverineGet("/global-no-content/get/{id}")]
public static Todo2? Get(string id) => id == "found" ? new Todo2 { Id = id, Name = "Found" } : null;

[WolverineGet("/global-no-content/opt-out/{id}"), NotFoundIfMissing]
public static Todo2? GetOptOut(string id) => id == "found" ? new Todo2 { Id = id, Name = "Found" } : null;

[WolverinePost("/global-no-content/post")]
public static Todo2? Post(CreateTodo2 command) => command.Id == "found" ? new Todo2 { Id = command.Id } : null;
}

/// <summary>
/// Uses a plain [Entity], so it picks up whatever <c>WolverineOptions.EntityDefaults.OnMissing</c> is set to.
/// </summary>
public static class GlobalEmptyContentEntityEndpoint
{
[WolverineGet("/global-no-content/entity/{id}")]
public static Todo2 Get([Entity] Todo2 todo) => todo;
}
Loading
Loading