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
14 changes: 7 additions & 7 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ dotnet_diagnostic.CA1859.severity = none
# --- Sonar rules not yet enforced: the backlog -------------------------------------------------
#
# build/sonar-profile.globalconfig is generated from the SonarCloud quality profile and puts every
# rule it activates at `warning`, so the default is ENFORCE. The 33 rules below are the
# rule it activates at `warning`, so the default is ENFORCE. The 29 rules below are the
# exceptions: each still has violations in this tree, and promoting it now would turn unrelated
# pull requests red. They are demoted to `suggestion` — active, non-blocking — with the number of
# sites measured when this landed.
Expand All @@ -79,22 +79,23 @@ dotnet_diagnostic.CA1859.severity = none
# this codebase means to refuse outright does not belong here — it belongs with the declines
# above, at `none`, with its reason (ADR-0060). `suggestion` means "not yet", never "no".
#
# The other 342 rules the profile activates have zero violations here and are
# enforced as of this commit. Total outstanding: 135 sites. Decision: ADR-0062.
# The other 348 rules the profile activates have zero violations here and are
# enforced as of this commit. Total outstanding: 104 sites. Decision: ADR-0062.
#
# A rule leaves this block by one of two doors, and both are visible in the tree: its sites are
# cleared, or the few that remain are deliberate and carry a [SuppressMessage] with the reason at
# the site. The second door keeps the rule enforced everywhere else, which parking it never did.

dotnet_diagnostic.S3776.severity = suggestion # 19 — Cognitive Complexity of methods should not be too high
dotnet_diagnostic.S1244.severity = suggestion # 15 — Floating point numbers should not be tested for equality
dotnet_diagnostic.S3267.severity = suggestion # 14 — Loops should be simplified with "LINQ" expressions
dotnet_diagnostic.S3878.severity = suggestion # 14 — Arrays should not be created for params parameters
dotnet_diagnostic.S8969.severity = suggestion # 14 — Null-forgiving operators should not be redundant
dotnet_diagnostic.S3218.severity = suggestion # 8 — Inner class members should not shadow outer class "static" or type members
dotnet_diagnostic.S107.severity = suggestion # 6 — Methods should not have too many parameters
dotnet_diagnostic.S1481.severity = suggestion # 5 — Unused local variables should be removed
dotnet_diagnostic.S1854.severity = suggestion # 4 — Unused assignments should be removed
dotnet_diagnostic.S4144.severity = suggestion # 3 — Methods should not have identical implementations
dotnet_diagnostic.S1066.severity = suggestion # 2 — Mergeable "if" statements should be combined
dotnet_diagnostic.S108.severity = suggestion # 2 — Nested blocks of code should not be left empty
dotnet_diagnostic.S1192.severity = suggestion # 2 — String literals should not be duplicated
dotnet_diagnostic.S125.severity = suggestion # 2 — Sections of code should not be commented out
dotnet_diagnostic.S1905.severity = suggestion # 2 — Redundant casts should not be used
dotnet_diagnostic.S2326.severity = suggestion # 2 — Unused type parameters should be removed
Expand All @@ -103,7 +104,6 @@ dotnet_diagnostic.S3358.severity = suggestion # 2 — Ternary operators shoul
dotnet_diagnostic.S6966.severity = suggestion # 2 — Awaitable method should be used
dotnet_diagnostic.S927.severity = suggestion # 2 — Parameter names should match base declaration and other partial definitions
dotnet_diagnostic.S1144.severity = suggestion # 1 — Unused private types or members should be removed
dotnet_diagnostic.S1172.severity = suggestion # 1 — Unused method parameters should be removed
dotnet_diagnostic.S2219.severity = suggestion # 1 — Runtime type checking should be simplified
dotnet_diagnostic.S2342.severity = suggestion # 1 — Enumeration types should comply with a naming convention
dotnet_diagnostic.S2692.severity = suggestion # 1 — "IndexOf" checks should not be for positive numbers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ private static void AddCore(List<MetadataReference> references) {
private static string Net472ReferenceDirectory() {
foreach (AssemblyMetadataAttribute attribute in typeof(AnalyzerTestHarness).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()) {
if (attribute.Key == Net472ReferenceAssembliesMetadataKey && !string.IsNullOrWhiteSpace(attribute.Value)) {
return attribute.Value!;
return attribute.Value;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,11 @@ public void NullElementAsFirstFailureOfASimpleList() {
body.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse);

Outcome<string> outcome = bind.New(_ => "never");
Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString()))

Error envelope = outcome.Error!;
Check.That(envelope.InnerErrors.Select(e => e.Code.ToString()))
.ContainsExactly("REQUEST_ARGUMENT_REQUIRED", "REQUEST_ARGUMENT_INVALID");
Check.That(outcome.Error!.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
Check.That(envelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
.ContainsExactly("Tags[1]", "Tags[2]");
}

Expand Down
25 changes: 15 additions & 10 deletions FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,13 @@ public void EveryFailingElementIsCollected() {
body.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse);

Outcome<string> outcome = bind.New(_ => "never");
Check.That(outcome.Error!.InnerErrors).HasSize(2);
Check.That(outcome.Error!.InnerErrors.Select(BindingAssertions.ArgumentPathOf))

Error envelope = outcome.Error!;
Check.That(envelope.InnerErrors).HasSize(2);
Check.That(envelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
.ContainsExactly("Tags[1]", "Tags[2]");
Check.That(outcome.Error!.InnerErrors[0].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
Check.That(outcome.Error!.InnerErrors[1].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
Check.That(envelope.InnerErrors[0].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
Check.That(envelope.InnerErrors[1].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
}

[Fact(DisplayName = "An optional list that is absent binds an empty list — never null — and records nothing.")]
Expand Down Expand Up @@ -118,14 +120,16 @@ public void FailingComplexElementsRecordTheirEnvelopes() {
body.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired<Guest>(BindGuest);

Outcome<string> outcome = bind.New(_ => "never");
Check.That(outcome.Error!.InnerErrors).HasSize(2);

Error second = outcome.Error!.InnerErrors[0];
Error envelope = outcome.Error!;
Check.That(envelope.InnerErrors).HasSize(2);

Error second = envelope.InnerErrors[0];
Check.That(second.Code.ToString()).IsEqualTo("TEST_GUEST_INVALID");
Check.That(second.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
.ContainsExactly("Guests[1].FirstName", "Guests[1].Email");

Error third = outcome.Error!.InnerErrors[1];
Error third = envelope.InnerErrors[1];
Check.That(third.InnerErrors.Select(BindingAssertions.ArgumentPathOf)).ContainsExactly("Guests[2].FirstName");
}

Expand All @@ -152,13 +156,14 @@ public void NullComplexElementDoesNotHideLaterFailures() {
Outcome<string> outcome = bind.New(_ => "never");

// Both the null element AND the invalid element after it must be collected — the null must not short-circuit.
Check.That(outcome.Error!.InnerErrors).HasSize(2);
Error envelope = outcome.Error!;
Check.That(envelope.InnerErrors).HasSize(2);

Error nullElement = outcome.Error!.InnerErrors[0];
Error nullElement = envelope.InnerErrors[0];
Check.That(nullElement.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
Check.That(BindingAssertions.ArgumentPathOf(nullElement)).IsEqualTo("Guests[0]");

Error secondEnvelope = outcome.Error!.InnerErrors[1];
Error secondEnvelope = envelope.InnerErrors[1];
Check.That(secondEnvelope.Code.ToString()).IsEqualTo("TEST_GUEST_INVALID");
Check.That(secondEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
.ContainsExactly("Guests[1].FirstName", "Guests[1].Email");
Expand Down
14 changes: 9 additions & 5 deletions FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,10 @@ public void CreateNeverCallsFactoryOnBindingFailure() {

Check.That(outcome.IsFailure).IsTrue();
Check.That(factoryCalled).IsFalse();
Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_STAY_INVALID");
Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString())).ContainsExactly("REQUEST_ARGUMENT_REQUIRED");

Error envelope = outcome.Error!;
Check.That(envelope.Code.ToString()).IsEqualTo("TEST_STAY_INVALID");
Check.That(envelope.InnerErrors.Select(e => e.Code.ToString())).ContainsExactly("REQUEST_ARGUMENT_REQUIRED");
}

[Fact(DisplayName = "Every failing property is collected into the envelope, in declaration order — collect-all, not first-failure.")]
Expand All @@ -123,10 +125,12 @@ public void CollectsEveryFailure() {
body.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR");

Outcome<string> outcome = bind.New(_ => "never");
Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID");
Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString()))

Error envelope = outcome.Error!;
Check.That(envelope.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID");
Check.That(envelope.InnerErrors.Select(e => e.Code.ToString()))
.ContainsExactly("REQUEST_ARGUMENT_INVALID", "REQUEST_ARGUMENT_REQUIRED", "REQUEST_ARGUMENT_INVALID");
Check.That(outcome.Error!.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
Check.That(envelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
.ContainsExactly("GuestEmail", "Reference", "Currency");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ public void RequiredMissingFails() {

Outcome<string> outcome = bind.New(_ => "never");
Check.That(outcome.IsFailure).IsTrue();
Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID");

Error required = outcome.Error!.InnerErrors.Single();
Error envelope = outcome.Error!;
Check.That(envelope.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID");

Error required = envelope.InnerErrors.Single();
Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("GuestEmail");
}
Expand Down
42 changes: 26 additions & 16 deletions FirstClassErrors.RequestBinder/ArgumentSourceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,24 @@ public static class ArgumentSourceExtensions {

#region Statics members declarations

// The provenance labels, named once. Each is passed by four overloads — scalar, scalar value-type, list, list
// value-type — and the label is what reaches the caller in the argument path, so a typo in one of the four would
// split a source in two and only surface in a consumer's error report. The XML docs below still spell the literal
// out, so nothing is hidden from a reader of any single method.
private const string Route = "route";
private const string Query = "query";
private const string Header = "header";
private const string Body = "body";
private const string Form = "form";

/// <summary>Binds an argument sourced from the route (<c>From("route", value)</c>).</summary>
public static SimplePropertyConverter<TArgument> FromRoute<TArgument>(this ArgumentSource argument, TArgument? value) {
return Guarded(argument).From("route", value);
return Guarded(argument).From(Route, value);
}

/// <summary>Binds a value-type argument sourced from the route (<c>From("route", value)</c>).</summary>
public static SimplePropertyConverter<TArgument> FromRoute<TArgument>(this ArgumentSource argument, TArgument? value) where TArgument : struct {
return Guarded(argument).From("route", value);
return Guarded(argument).From(Route, value);
}

/// <summary>Binds an argument sourced from the query string (<c>From("query", value)</c>).</summary>
Expand All @@ -29,12 +39,12 @@ public static SimplePropertyConverter<TArgument> FromRoute<TArgument>(this Argum
"from itself is the list block below. This shape-first grouping is deliberate, and matches how OutcomeTaskExtensions groups by " +
"receiver type.")]
public static SimplePropertyConverter<TArgument> FromQuery<TArgument>(this ArgumentSource argument, TArgument? value) {
return Guarded(argument).From("query", value);
return Guarded(argument).From(Query, value);
}

/// <summary>Binds a value-type argument sourced from the query string (<c>From("query", value)</c>).</summary>
public static SimplePropertyConverter<TArgument> FromQuery<TArgument>(this ArgumentSource argument, TArgument? value) where TArgument : struct {
return Guarded(argument).From("query", value);
return Guarded(argument).From(Query, value);
}

/// <summary>Binds an argument sourced from a request header (<c>From("header", value)</c>).</summary>
Expand All @@ -45,22 +55,22 @@ public static SimplePropertyConverter<TArgument> FromQuery<TArgument>(this Argum
"from itself is the list block below. This shape-first grouping is deliberate, and matches how OutcomeTaskExtensions groups by " +
"receiver type.")]
public static SimplePropertyConverter<TArgument> FromHeader<TArgument>(this ArgumentSource argument, TArgument? value) {
return Guarded(argument).From("header", value);
return Guarded(argument).From(Header, value);
}

/// <summary>Binds a value-type argument sourced from a request header (<c>From("header", value)</c>).</summary>
public static SimplePropertyConverter<TArgument> FromHeader<TArgument>(this ArgumentSource argument, TArgument? value) where TArgument : struct {
return Guarded(argument).From("header", value);
return Guarded(argument).From(Header, value);
}

/// <summary>Binds an argument sourced from the request body (<c>From("body", value)</c>).</summary>
public static SimplePropertyConverter<TArgument> FromBody<TArgument>(this ArgumentSource argument, TArgument? value) {
return Guarded(argument).From("body", value);
return Guarded(argument).From(Body, value);
}

/// <summary>Binds a value-type argument sourced from the request body (<c>From("body", value)</c>).</summary>
public static SimplePropertyConverter<TArgument> FromBody<TArgument>(this ArgumentSource argument, TArgument? value) where TArgument : struct {
return Guarded(argument).From("body", value);
return Guarded(argument).From(Body, value);
}

/// <summary>Binds an argument sourced from a form field (<c>From("form", value)</c>).</summary>
Expand All @@ -71,42 +81,42 @@ public static SimplePropertyConverter<TArgument> FromBody<TArgument>(this Argume
"from itself is the list block below. This shape-first grouping is deliberate, and matches how OutcomeTaskExtensions groups by " +
"receiver type.")]
public static SimplePropertyConverter<TArgument> FromForm<TArgument>(this ArgumentSource argument, TArgument? value) {
return Guarded(argument).From("form", value);
return Guarded(argument).From(Form, value);
}

/// <summary>Binds a value-type argument sourced from a form field (<c>From("form", value)</c>).</summary>
public static SimplePropertyConverter<TArgument> FromForm<TArgument>(this ArgumentSource argument, TArgument? value) where TArgument : struct {
return Guarded(argument).From("form", value);
return Guarded(argument).From(Form, value);
}

/// <summary>Binds a list argument sourced from the query string (<c>From("query", values)</c>).</summary>
public static ListOfSimplePropertiesConverter<TArgument> FromQuery<TArgument>(this ArgumentListSource argument, IEnumerable<TArgument?>? values) {
return Guarded(argument).From("query", values);
return Guarded(argument).From(Query, values);
}

/// <summary>Binds a value-type list argument sourced from the query string (<c>From("query", values)</c>).</summary>
public static ListOfSimpleValuePropertiesConverter<TArgument> FromQuery<TArgument>(this ArgumentListSource argument, IEnumerable<TArgument?>? values) where TArgument : struct {
return Guarded(argument).From("query", values);
return Guarded(argument).From(Query, values);
}

/// <summary>Binds a list argument sourced from repeated request headers (<c>From("header", values)</c>).</summary>
public static ListOfSimplePropertiesConverter<TArgument> FromHeader<TArgument>(this ArgumentListSource argument, IEnumerable<TArgument?>? values) {
return Guarded(argument).From("header", values);
return Guarded(argument).From(Header, values);
}

/// <summary>Binds a value-type list argument sourced from repeated request headers (<c>From("header", values)</c>).</summary>
public static ListOfSimpleValuePropertiesConverter<TArgument> FromHeader<TArgument>(this ArgumentListSource argument, IEnumerable<TArgument?>? values) where TArgument : struct {
return Guarded(argument).From("header", values);
return Guarded(argument).From(Header, values);
}

/// <summary>Binds a list argument sourced from repeated form fields (<c>From("form", values)</c>).</summary>
public static ListOfSimplePropertiesConverter<TArgument> FromForm<TArgument>(this ArgumentListSource argument, IEnumerable<TArgument?>? values) {
return Guarded(argument).From("form", values);
return Guarded(argument).From(Form, values);
}

/// <summary>Binds a value-type list argument sourced from repeated form fields (<c>From("form", values)</c>).</summary>
public static ListOfSimpleValuePropertiesConverter<TArgument> FromForm<TArgument>(this ArgumentListSource argument, IEnumerable<TArgument?>? values) where TArgument : struct {
return Guarded(argument).From("form", values);
return Guarded(argument).From(Form, values);
}

private static ArgumentSource Guarded(ArgumentSource argument) {
Expand Down
Loading
Loading