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
46 changes: 46 additions & 0 deletions docs/guide/handlers/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,3 +641,49 @@ public static class MaybeBadThing4Handler
And any objects in the `OutgoingMessages` return value from the middleware method will be sent as cascaded
messages. Wolverine will also apply a "maybe stop" frame from the `IHandlerContinuation` as well.

## Parameter Value Sources <Badge type="tip" text="5.25" />

When using `WolverineParameterAttribute` subclasses (like `[Aggregate]`, `[WriteAggregate]`), you can control
where parameter values are resolved from using the `ValueSource` property or the convenience shorthand properties.

### From an Envelope Header

Use `FromHeader` to resolve a value from the message envelope's headers:

```cs
public void Handle(
ProcessOrder command,
[WriteAggregate(FromHeader = "X-Tenant-Id")] TenantAggregate tenant)
{
// tenant loaded using the value from the "X-Tenant-Id" envelope header
}
```

### From a Static Method

Use `FromMethod` to resolve a value from a static method on the handler class. The method's parameters
are resolved via method injection:

```cs
public class ProcessOrderHandler
{
public static Guid ResolveTenantId(IMessageContext context)
{
context.Envelope!.TryGetHeader("X-Tenant-Id", out var tenantId);
return Guid.Parse(tenantId!);
}

public void Handle(
ProcessOrder command,
[WriteAggregate(FromMethod = "ResolveTenantId")] TenantAggregate tenant)
{
// tenant loaded using the Guid returned by ResolveTenantId()
}
}
```

::: warning
`FromClaim` is only supported in HTTP endpoints and will throw an `InvalidOperationException` if
used in a message handler.
:::

80 changes: 80 additions & 0 deletions docs/guide/http/marten.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,86 @@ public static OrderShipped Ship(
See [Overriding Version Discovery](/guide/durability/marten/event-sourcing.html#overriding-version-discovery) in the
aggregate handler workflow documentation for more details and multi-stream examples.

## Custom Identity Resolution <Badge type="tip" text="5.25" />

By default, the `[Aggregate]` attribute resolves the stream identity from route arguments, query string parameters,
or request body properties. Starting in 5.25, you can use additional value sources to resolve the aggregate identity from
headers, claims, or computed methods. These same properties are available on all `WolverineParameterAttribute` subclasses
(`[Aggregate]`, `[WriteAggregate]`, `[ReadAggregate]`, etc.).

### From a Request Header

Use `FromHeader` to resolve the identity from an HTTP request header:

```cs
[WolverinePost("/orders/ship")]
[EmptyResponse]
public static OrderShipped Ship(
ShipOrder command,
[Aggregate(FromHeader = "X-Order-Id")] Order order)
{
return new OrderShipped();
}
```

In message handlers, `FromHeader` reads from `Envelope.Headers` instead.

### From a Claim

Use `FromClaim` to resolve the identity from the authenticated user's claims. This is only
supported in HTTP endpoints:

```cs
[WolverinePost("/profile/update")]
[EmptyResponse]
public static ProfileUpdated UpdateProfile(
UpdateProfile command,
[Aggregate(FromClaim = "profile-id")] UserProfile profile)
{
return new ProfileUpdated();
}
```

### From a Static Method

Use `FromMethod` to resolve the identity from a static method on the endpoint class. The method's
parameters are resolved via method injection (services, `ClaimsPrincipal`, etc.):

```cs
public static class UpdateAccountConfigEndpoint
{
// Wolverine discovers this method and calls it to resolve the aggregate ID
public static Guid ResolveId(ClaimsPrincipal user)
{
return AccountConfig.CompositeId(user.FindFirst("tenant")?.Value);
}

[WolverinePost("/account/config/update")]
[EmptyResponse]
public static AccountConfigUpdated Handle(
UpdateAccountConfig command,
[Aggregate(FromMethod = "ResolveId")] AccountConfig config)
{
return new AccountConfigUpdated();
}
}
```

### From a Route Argument

Use `FromRoute` as a more explicit alternative to the constructor parameter:

```cs
[WolverinePost("/orders/{orderId}/ship")]
[EmptyResponse]
public static OrderShipped Ship(
ShipOrder command,
[Aggregate(FromRoute = "orderId")] Order order)
{
return new OrderShipped();
}
```

## Reading the Latest Version of an Aggregate

::: info
Expand Down
184 changes: 184 additions & 0 deletions src/Http/Wolverine.Http.Tests/value_source_resolution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
using System.Security.Claims;
using Alba;
using Shouldly;

namespace Wolverine.Http.Tests;

public class value_source_resolution : IntegrationContext
{
public value_source_resolution(AppFixture fixture) : base(fixture)
{
}

private static ClaimsPrincipal UserWithClaims(params Claim[] claims)
{
var identity = new ClaimsIdentity(claims, "TestAuth");
return new ClaimsPrincipal(identity);
}

#region Header tests

[Fact]
public async Task from_header_resolves_string_value()
{
var result = await Scenario(x =>
{
x.Get.Url("/test/from-header/string");
x.WithRequestHeader("X-Custom-Value", "hello-world");
});

result.ReadAsText().ShouldBe("hello-world");
}

[Fact]
public async Task from_header_missing_string_returns_default()
{
var result = await Scenario(x =>
{
x.Get.Url("/test/from-header/string");
});

result.ReadAsText().ShouldBe("no-value");
}

[Fact]
public async Task from_header_resolves_int_value()
{
var result = await Scenario(x =>
{
x.Get.Url("/test/from-header/int");
x.WithRequestHeader("X-Count", "42");
});

result.ReadAsText().ShouldBe("count:42");
}

[Fact]
public async Task from_header_resolves_guid_value()
{
var id = Guid.NewGuid();
var result = await Scenario(x =>
{
x.Get.Url("/test/from-header/guid");
x.WithRequestHeader("X-Correlation-Id", id.ToString());
});

result.ReadAsText().ShouldBe($"id:{id}");
}

[Fact]
public async Task from_header_int_missing_returns_default()
{
var result = await Scenario(x =>
{
x.Get.Url("/test/from-header/int");
});

result.ReadAsText().ShouldBe("count:0");
}

#endregion

#region Claim tests

[Fact]
public async Task from_claim_resolves_string_value()
{
var result = await Scenario(x =>
{
x.Get.Url("/test/from-claim/string");
x.ConfigureHttpContext(c => c.User = UserWithClaims(new Claim("sub", "user-123")));
});

result.ReadAsText().ShouldBe("user-123");
}

[Fact]
public async Task from_claim_missing_string_returns_default()
{
var result = await Scenario(x =>
{
x.Get.Url("/test/from-claim/string");
});

result.ReadAsText().ShouldBe("no-user");
}

[Fact]
public async Task from_claim_resolves_int_value()
{
var result = await Scenario(x =>
{
x.Get.Url("/test/from-claim/int");
x.ConfigureHttpContext(c => c.User = UserWithClaims(new Claim("tenant-id", "42")));
});

result.ReadAsText().ShouldBe("tenant:42");
}

[Fact]
public async Task from_claim_resolves_guid_value()
{
var id = Guid.NewGuid();
var result = await Scenario(x =>
{
x.Get.Url("/test/from-claim/guid");
x.ConfigureHttpContext(c => c.User = UserWithClaims(new Claim("organization-id", id.ToString())));
});

result.ReadAsText().ShouldBe($"org:{id}");
}

[Fact]
public async Task from_claim_int_missing_returns_default()
{
var result = await Scenario(x =>
{
x.Get.Url("/test/from-claim/int");
});

result.ReadAsText().ShouldBe("tenant:0");
}

#endregion

#region Method tests

[Fact]
public async Task from_method_resolves_guid_value()
{
var id = Guid.NewGuid();
var result = await Scenario(x =>
{
x.Get.Url("/test/from-method/guid");
x.ConfigureHttpContext(c => c.User = UserWithClaims(new Claim("computed-id", id.ToString())));
});

result.ReadAsText().ShouldBe($"resolved:{id}");
}

[Fact]
public async Task from_method_resolves_string_value()
{
var result = await Scenario(x =>
{
x.Get.Url("/test/from-method/string");
x.ConfigureHttpContext(c => c.User = UserWithClaims(new Claim("display-name", "Jeremy")));
});

result.ReadAsText().ShouldBe("name:Jeremy");
}

[Fact]
public async Task from_method_with_no_claim_returns_default()
{
var result = await Scenario(x =>
{
x.Get.Url("/test/from-method/string");
});

result.ReadAsText().ShouldBe("name:anonymous");
}

#endregion
}
Loading
Loading