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
24 changes: 3 additions & 21 deletions FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,33 +78,15 @@ public RequiredField<IReadOnlyList<TProperty>> AsOptional<TProperty>(Func<Reques
}

private RequiredField<IReadOnlyList<TProperty>> BindElements<TProperty>(Func<RequestBinder<TArgument>, Outcome<TProperty>> bindElement) where TProperty : notnull {
List<TProperty> bound = new();
int index = 0;

foreach (TArgument? element in _values!) {
string elementPath = $"{_argumentPath}[{index}]";
index++;

if (element is null) {
_binder.RecordArgumentRequired(elementPath);

continue;
}

return _binder.ConvertEachElement<TArgument?, TProperty>(_argumentPath, _values!, (element, elementPath) => {
RequestBinder<TArgument> nested = new(element!, _envelope, _binder.Options, elementPath);
Outcome<TProperty> outcome = bindElement(nested);
if (outcome.IsFailure) {
_binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, elementPath, _binder.Options.ArgumentInvalidCode));

continue;
}

bound.Add(outcome.GetResultOrThrow());
}

// The value is read only through a BindingScope, which a build terminal creates solely when no failure was recorded —
// i.e. only when every element bound — so `bound` is the complete list exactly when it is observed.
return new RequiredField<IReadOnlyList<TProperty>>(_binder, bound);
return outcome;
});
}

}
28 changes: 4 additions & 24 deletions FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,32 +72,12 @@ public RequiredField<IReadOnlyList<TProperty>> AsOptional<TProperty>(Func<TArgum
}

private RequiredField<IReadOnlyList<TProperty>> ConvertElements<TProperty>(Func<TArgument, Outcome<TProperty>> convertElement) where TProperty : notnull {
List<TProperty> converted = new();
int index = 0;

foreach (TArgument? element in _values!) {
string elementPath = $"{_argumentPath}[{index}]";
index++;

if (element is null) {
_binder.RecordArgumentRequired(elementPath);

continue;
}

return _binder.ConvertEachElement<TArgument?, TProperty>(_argumentPath, _values!, (element, elementPath) => {
Outcome<TProperty> outcome = convertElement(element!);
if (outcome.IsFailure) {
_binder.RecordArgumentInvalid(elementPath, outcome.Error!);

continue;
}

converted.Add(outcome.GetResultOrThrow());
}
if (outcome.IsFailure) { _binder.RecordArgumentInvalid(elementPath, outcome.Error!); }

// The value is read only through a BindingScope, which a build terminal creates solely when no failure was recorded —
// i.e. only when every element converted — so `converted` is the complete list exactly when it is observed.
return new RequiredField<IReadOnlyList<TProperty>>(_binder, converted);
return outcome;
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -79,32 +79,12 @@ public RequiredField<IReadOnlyList<TProperty>> AsOptional<TProperty>(Func<TArgum
}

private RequiredField<IReadOnlyList<TProperty>> ConvertElements<TProperty>(Func<TArgument, Outcome<TProperty>> convertElement) where TProperty : notnull {
List<TProperty> converted = new();
int index = 0;
return _binder.ConvertEachElement<TArgument?, TProperty>(_argumentPath, _values!, (element, elementPath) => {
Outcome<TProperty> outcome = convertElement(element!.Value);
if (outcome.IsFailure) { _binder.RecordArgumentInvalid(elementPath, outcome.Error!); }

foreach (TArgument? element in _values!) {
string elementPath = $"{_argumentPath}[{index}]";
index++;

if (element is null) {
_binder.RecordArgumentRequired(elementPath);

continue;
}

Outcome<TProperty> outcome = convertElement(element.Value);
if (outcome.IsFailure) {
_binder.RecordArgumentInvalid(elementPath, outcome.Error!);

continue;
}

converted.Add(outcome.GetResultOrThrow());
}

// The value is read only through a BindingScope, which a build terminal creates solely when no failure was recorded —
// i.e. only when every element converted — so `converted` is the complete list exactly when it is observed.
return new RequiredField<IReadOnlyList<TProperty>>(_binder, converted);
return outcome;
});
}

}
40 changes: 40 additions & 0 deletions FirstClassErrors.RequestBinder/RequestBinder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,46 @@ internal void RecordArgumentInvalid(string argumentPath, Error cause) {
Record(RequestBindingError.ArgumentInvalid(Options.ArgumentInvalidCode, argumentPath, cause));
}

/// <summary>
/// Binds every element of a list property at its indexed path (<c>Tags[2]</c>), collecting <b>every</b>
/// failure so one bad element never hides the others: a <c>null</c> element records
/// <c>REQUEST_ARGUMENT_REQUIRED</c>, and <paramref name="convertElementAt" /> binds each non-null element —
/// recording its own failure (a converter error, or a nested envelope wrapped under the path) and yielding
/// its value on success. The list converters share this single iteration and null-element rule, so a change
/// to either is made in one place.
/// </summary>
/// <typeparam name="TStored">The element type as stored in the DTO list (a reference or a <see cref="Nullable{T}" />).</typeparam>
/// <typeparam name="TProperty">The type each element binds to.</typeparam>
/// <param name="argumentPath">The list's argument path; each element is reported under <c>argumentPath[index]</c>.</param>
/// <param name="values">The list elements.</param>
/// <param name="convertElementAt">Binds a non-null element at its indexed path, recording its own failure.</param>
/// <returns>The bound field token carrying the successfully bound elements.</returns>
internal RequiredField<IReadOnlyList<TProperty>> ConvertEachElement<TStored, TProperty>(
string argumentPath, IEnumerable<TStored> values, Func<TStored, string, Outcome<TProperty>> convertElementAt) where TProperty : notnull {
List<TProperty> converted = new();
int index = 0;

foreach (TStored element in values) {
string elementPath = $"{argumentPath}[{index}]";
index++;

if (element is null) {
RecordArgumentRequired(elementPath);

continue;
}

Outcome<TProperty> outcome = convertElementAt(element, elementPath);
if (outcome.IsSuccess) { converted.Add(outcome.GetResultOrThrow()); }
// A failing element was already recorded by convertElementAt (REQUEST_ARGUMENT_INVALID, or the nested
// envelope wrapped under the indexed path), so it is simply skipped here.
}

// The value is read only through a BindingScope, which a build terminal creates solely when no failure was
// recorded — i.e. only when every element bound — so `converted` is the complete list when it is observed.
return new RequiredField<IReadOnlyList<TProperty>>(this, converted);
}

/// <summary>Prepends this binder's argument prefix to a path segment ("CheckIn" -&gt; "Stay.CheckIn").</summary>
internal string PathOf(string argumentName) {
return _argumentPrefix is null ? argumentName : $"{_argumentPrefix}.{argumentName}";
Expand Down