Skip to content
Closed
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
25 changes: 18 additions & 7 deletions docs/guide/grpc/sagas.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,24 @@ Wolverine has two ways to resolve which saga state a message belongs to:
2. **Header-identified** — the message carries no identity member, and Wolverine falls back to the
envelope's `saga-id` header.

**Only message-identified sagas are supported over a gRPC service hop today.** The `saga-id`
envelope header does not cross the hop — neither the client nor server propagation interceptor
carries it, so a header-identified saga invoked through a gRPC service fails with an
`IndeterminateSagaStateIdException` (surfaced to the caller as a `StatusCode.Internal`
`RpcException`). This is tracked in
[GH-3385](https://github.com/JasperFx/wolverine/issues/3385); the practical guidance is simple and
is the better design anyway:
**Only message-identified sagas are supported over a gRPC service hop.** The `saga-id` envelope
header does not cross the hop — neither the client nor server propagation interceptor carries it —
so a header-identified saga invoked through a gRPC service cannot resolve an id at all.

As of 6.18.0 that fails with an explicit diagnostic rather than an opaque one: the caller gets a
`StatusCode.InvalidArgument` `RpcException` whose detail names both the cause and the fix.

> Could not determine a saga id for this request. A saga started or continued over a gRPC hop must
> carry its identity ON THE MESSAGE BODY: the 'saga-id' envelope header is not propagated across a
> gRPC call, so a header-identified saga cannot work over gRPC. Put the saga identity on the request
> message itself (a property Wolverine can match to the saga id, or one marked with
> `[SagaIdentity]`).

`InvalidArgument` rather than `Internal` is deliberate ([AIP-193](https://google.aip.dev/193)): the
request cannot succeed as sent, and no amount of retrying will change that — it is a contract
problem, not a transient server fault.

The practical guidance is simple, and is the better design anyway:

::: tip
Put the saga identity on the request DTO. It makes the contract self-describing for non-Wolverine
Expand Down
22 changes: 22 additions & 0 deletions docs/guide/http/as-parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,28 @@ member's default / initializer in both modes. The flag applies to query string b
`[AsParameters]` members and for endpoint method arguments bound from the query string alike.
Route argument binding is unaffected (an unparseable route value already returns `404`).

### Collections <Badge type="tip" text="6.18" />

The flag covers **collection** query string parameters (`T[]`, `List<T>`, `IList<T>`,
`IReadOnlyList<T>`, `IEnumerable<T>`) too. With the flag off, an element that fails to parse is
silently dropped, which is especially dangerous for an optional filter: the endpoint sees `null`
(or a partial collection) and quietly returns an *unfiltered* `200` while the caller believes the
results were filtered.

With the flag on, binding a collection is **all or nothing** — a single unparseable element
rejects the whole request with a `400` naming the parameter, rather than silently dropping the bad
element and keeping the good ones. For
`[FromQuery] public Colour[]? Colours { get; set; }` on `GET /widgets`:

| Request | Flag off (default) | Flag on |
| --- | --- | --- |
| `GET /widgets?Colours=Red&Colours=Blue` | `200` — binds `[Red, Blue]` | `200` — binds `[Red, Blue]` |
| `GET /widgets` (missing) | `200` — keeps the initializer (`null`) | `200` — keeps the initializer (`null`) |
| `GET /widgets?Colours=Purple` | `200` — `Colours` is `null` | `400` — ProblemDetails naming `Colours` |
| `GET /widgets?Colours=Red&Colours=Purple` | `200` — binds only `[Red]` | `400` — ProblemDetails naming `Colours` |

String collections are unaffected in both modes, as there is nothing to parse.

::: warning
`RejectUnparseableQueryValues` is opt-in (defaults to `false`) throughout Wolverine 6.x to preserve
the previous lenient behavior, but the default flips to `true` (strict) in Wolverine 7.0. If you
Expand Down
5 changes: 3 additions & 2 deletions docs/guide/http/querystring.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ cannot be parsed -- the value passed to your method will be the `default` for wh
type is. If you want a *present but unparseable* query string value to return a `400 Bad Request`
instead (matching ASP.NET Core minimal APIs), opt into
`WolverineHttpOptions.RejectUnparseableQueryValues` — see
[Strict Query String Binding](./as-parameters#strict-query-string-binding). The strict behavior
becomes the default in Wolverine 7.0.
[Strict Query String Binding](./as-parameters#strict-query-string-binding). That flag also covers
collection query string parameters, where an unparseable element otherwise gets silently dropped.
The strict behavior becomes the default in Wolverine 7.0.
:::

Wolverine supports passing query string values to your HTTP method arguments for
Expand Down
176 changes: 176 additions & 0 deletions src/Http/Wolverine.Http.Tests/strict_query_string_binding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,148 @@ public async Task missing_values_keep_initializers_when_flag_is_off()
response.PageSize.ShouldBe(5);
response.Token.ShouldBe(StrictQueryModel.DefaultToken);
}

// GH-3398: the collection case. An unparseable *element* of a multi-valued query string
// parameter was silently dropped, taking the whole collection to null/empty. That is worse
// than a plain 400: an optional filter predicate silently disappears and the endpoint answers
// 200 with an unfiltered result set.

private Task<IScenarioResult> expectCollection400(Action<Alba.Scenario> configure)
{
return _fixture.StrictHost.Scenario(x =>
{
configure(x);
x.StatusCodeShouldBe(400);
x.ContentTypeShouldBe("application/problem+json");
});
}

[Fact]
public async Task malformed_enum_array_element_returns_400()
{
var result = await expectCollection400(x =>
x.Get.Url("/strict/collections").QueryString("Colours", "Purple"));

var text = await result.ReadAsTextAsync();
text.ShouldContain("Colours");
text.ShouldContain("Purple");
}

[Fact]
public async Task one_bad_element_rejects_the_whole_collection()
{
// All-or-nothing: a valid element does not rescue the request. Silently keeping "Red" and
// dropping "Purple" would still be a silently wrong answer.
var result = await _fixture.StrictHost.Scenario(x =>
{
x.Get.Url("/strict/collections?Colours=Red&Colours=Purple");
x.StatusCodeShouldBe(400);
x.ContentTypeShouldBe("application/problem+json");
});

var text = await result.ReadAsTextAsync();
text.ShouldContain("Colours");
text.ShouldContain("Purple");
}

[Fact]
public async Task malformed_int_array_element_returns_400()
{
var result = await expectCollection400(x =>
x.Get.Url("/strict/collections").QueryString("Ids", "abc"));

(await result.ReadAsTextAsync()).ShouldContain("Ids");
}

[Fact]
public async Task malformed_guid_array_element_returns_400()
{
var result = await expectCollection400(x =>
x.Get.Url("/strict/collections").QueryString("Tokens", "not-a-guid"));

(await result.ReadAsTextAsync()).ShouldContain("Tokens");
}

[Fact]
public async Task malformed_generic_list_element_returns_400()
{
var result = await expectCollection400(x =>
x.Get.Url("/strict/collections").QueryString("Pages", "abc"));

(await result.ReadAsTextAsync()).ShouldContain("Pages");
}

[Fact]
public async Task malformed_collection_element_on_direct_method_argument_returns_400()
{
var result = await expectCollection400(x =>
x.Get.Url("/strict/collections/direct").QueryString("numbers", "abc"));

(await result.ReadAsTextAsync()).ShouldContain("numbers");
}

[Fact]
public async Task valid_collection_values_bind_in_strict_mode()
{
var token = Guid.NewGuid();

var result = await _fixture.StrictHost.Scenario(x =>
{
x.Get.Url($"/strict/collections?Colours=Red&Colours=Blue&Ids=1&Ids=2&Tokens={token}&Pages=3");
x.StatusCodeShouldBe(200);
});

var response = await result.ReadAsJsonAsync<StrictCollectionQueryModel>();
response.Colours.ShouldBe([StrictColour.Red, StrictColour.Blue]);
response.Ids.ShouldBe([1, 2]);
response.Tokens.ShouldBe([token]);
response.Pages.ShouldBe([3]);
}

[Fact]
public async Task omitted_collections_keep_initializers_in_strict_mode()
{
var result = await _fixture.StrictHost.Scenario(x =>
{
x.Get.Url("/strict/collections");
x.StatusCodeShouldBe(200);
});

var response = await result.ReadAsJsonAsync<StrictCollectionQueryModel>();
response.Colours.ShouldBeNull();
response.Ids.ShouldBeNull();
response.Tokens.ShouldBeNull();
response.Pages.ShouldBeEmpty();
}

[Fact]
public async Task malformed_collection_values_are_lenient_when_flag_is_off()
{
// The pre-3398 lenient behavior is preserved by default in 6.x: bad elements are dropped
var result = await _fixture.LenientHost.Scenario(x =>
{
x.Get.Url("/strict/collections?Colours=Purple&Ids=abc&Pages=abc");
x.StatusCodeShouldBe(200);
});

var response = await result.ReadAsJsonAsync<StrictCollectionQueryModel>();
response.Colours.ShouldBeNull();
response.Ids.ShouldBeNull();
response.Pages.ShouldBeEmpty();
}

[Fact]
public async Task partially_malformed_collection_values_are_lenient_when_flag_is_off()
{
var result = await _fixture.LenientHost.Scenario(x =>
{
x.Get.Url("/strict/collections?Colours=Red&Colours=Purple");
x.StatusCodeShouldBe(200);
});

var response = await result.ReadAsJsonAsync<StrictCollectionQueryModel>();
response.Colours.ShouldBe([StrictColour.Red]);
}
}

public enum StrictSortOrder
Expand All @@ -255,6 +397,28 @@ public enum StrictSortOrder
Date = 2
}

public enum StrictColour
{
Red,
Green,
Blue
}

public class StrictCollectionQueryModel
{
[FromQuery]
public StrictColour[]? Colours { get; set; }

[FromQuery]
public int[]? Ids { get; set; }

[FromQuery]
public Guid[]? Tokens { get; set; }

[FromQuery]
public List<int> Pages { get; set; } = [];
}

public class StrictQueryModel
{
public static readonly Guid DefaultToken = Guid.Parse("11111111-2222-3333-4444-555555555555");
Expand Down Expand Up @@ -291,4 +455,16 @@ public static string GetDirect(int number)
{
return number.ToString();
}

[WolverineGet("/strict/collections")]
public static StrictCollectionQueryModel GetCollections([AsParameters] StrictCollectionQueryModel query)
{
return query;
}

[WolverineGet("/strict/collections/direct")]
public static string GetCollectionDirect(int[] numbers)
{
return numbers.Length.ToString();
}
}
27 changes: 25 additions & 2 deletions src/Http/Wolverine.Http/CodeGen/ParsedArrayQueryStringValue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,20 @@ namespace Wolverine.Http.CodeGen;
internal class ParsedArrayQueryStringValue : SyncFrame, IReadHttpFrame
{
private string? _property;
private readonly bool _rejectUnparseableValue;

public ParsedArrayQueryStringValue(Type parameterType, string parameterName)
public ParsedArrayQueryStringValue(Type parameterType, string parameterName, bool rejectUnparseableValue = false)
{
Variable = new HttpElementVariable(parameterType, parameterName!, this);

// GH-3398 strict query string binding for collections: only meaningful for parsed
// (non-string) elements. The 400 + ProblemDetails short circuit writes the response
// asynchronously, so the frame has to force the generated method into async mode.
_rejectUnparseableValue = rejectUnparseableValue && parameterType.GetElementType() != typeof(string);
if (_rejectUnparseableValue)
{
IsAsync = true;
}
}

public void AssignToProperty(string usage)
Expand Down Expand Up @@ -63,8 +73,10 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
writer.Write($"{Variable.Usage}_List.Add({Variable.Usage}ValueParsed);");
writer.FinishBlock(); // parsing block

writeRejectionBlock(method, writer, $"{Variable.Usage}Value", elementAlias);

writer.FinishBlock(); // foreach blobck

if (Mode == AssignMode.WriteToVariable)
{
writer.Write($"var {Variable.Usage} = {Variable.Usage}_List.ToArray();");
Expand All @@ -77,4 +89,15 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)

Next?.GenerateCode(method, writer);
}

private void writeRejectionBlock(GeneratedMethod method, ISourceWriter writer, string valueUsage,
string elementAlias)
{
if (!_rejectUnparseableValue)
{
return;
}

StrictQueryBinding.WriteRejectionBlock(method, writer, Variable.Name, valueUsage, elementAlias);
}
}
42 changes: 41 additions & 1 deletion src/Http/Wolverine.Http/CodeGen/QueryStringHandling.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,48 @@ public interface IGeneratesCode
void GenerateCode(GeneratedMethod method, ISourceWriter writer);
}

/// <summary>
/// Shared emission for the GH-3372 / GH-3398 opt-in strict query string binding. A query string
/// value that is present but cannot be parsed short circuits the request with a 400 ProblemDetails
/// naming the offending parameter, matching ASP.NET Core minimal API binding.
/// </summary>
internal static class StrictQueryBinding
{
/// <summary>
/// Emits the "else" arm of a collection element's TryParse: a present but unparseable element
/// rejects the entire binding. All-or-nothing is deliberate -- keeping the parseable elements
/// and dropping the bad one would still hand the endpoint a silently wrong filter (GH-3398).
/// </summary>
public static void WriteRejectionBlock(GeneratedMethod method, ISourceWriter writer, string parameterName,
string valueUsage, string elementAlias)
{
writer.Write($"BLOCK:else if (!string.IsNullOrEmpty({valueUsage}))");
writer.Write(
$"await {nameof(HttpHandler.WriteQueryValueParsingProblem)}(httpContext, \"{parameterName}\", {valueUsage}, \"{elementAlias}\").ConfigureAwait(false);");
writer.Write(method.ToExitStatement());
writer.FinishBlock();
}
}

internal class ParsedCollectionQueryStringValue : SyncFrame, IReadHttpFrame
{
private readonly Type _collectionElementType;
private readonly bool _rejectUnparseableValue;

public ParsedCollectionQueryStringValue(Type parameterType, string parameterName)
public ParsedCollectionQueryStringValue(Type parameterType, string parameterName,
bool rejectUnparseableValue = false)
{
Variable = new HttpElementVariable(parameterType, parameterName!, this);
_collectionElementType = GetCollectionElementType(parameterType);

// GH-3398 strict query string binding for collections: only meaningful for parsed
// (non-string) elements. The 400 + ProblemDetails short circuit writes the response
// asynchronously, so the frame has to force the generated method into async mode.
_rejectUnparseableValue = rejectUnparseableValue && _collectionElementType != typeof(string);
if (_rejectUnparseableValue)
{
IsAsync = true;
}
}

public HttpElementVariable Variable { get; }
Expand Down Expand Up @@ -72,6 +106,12 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
writer.Write($"{Variable.Usage}.Add({Variable.Name}ValueParsed);");
writer.FinishBlock(); // parsing block

if (_rejectUnparseableValue)
{
StrictQueryBinding.WriteRejectionBlock(method, writer, Variable.Name, $"{Variable.Name}Value",
elementAlias);
}

writer.FinishBlock(); // foreach blobck
}

Expand Down
Loading
Loading