1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-04 20:50:21 -05:00

Partial<T> for CommandResult<T> (#5482)

* Example of how a partial success/failure command result would look.

* Fixed code.

* Added Validator and ValidationResult

* Moved errors into their own files.

* Fixing tests

* fixed import.

* Forgot mock error.
This commit is contained in:
Jared McCannon 2025-03-14 12:00:58 -05:00 committed by GitHub
parent 7daf6cfad4
commit 488a9847ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 176 additions and 10 deletions

View File

@ -12,7 +12,7 @@ public static class CommandResultExtensions
NoRecordFoundFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status404NotFound }, NoRecordFoundFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status404NotFound },
BadRequestFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, BadRequestFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest },
Failure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, Failure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest },
Success<T> success => new ObjectResult(success.Data) { StatusCode = StatusCodes.Status200OK }, Success<T> success => new ObjectResult(success.Value) { StatusCode = StatusCodes.Status200OK },
_ => throw new InvalidOperationException($"Unhandled commandResult type: {commandResult.GetType().Name}") _ => throw new InvalidOperationException($"Unhandled commandResult type: {commandResult.GetType().Name}")
}; };
} }

View File

@ -0,0 +1,3 @@
namespace Bit.Core.AdminConsole.Errors;
public record Error<T>(string Message, T ErroredValue);

View File

@ -0,0 +1,11 @@
namespace Bit.Core.AdminConsole.Errors;
public record InsufficientPermissionsError<T>(string Message, T ErroredValue) : Error<T>(Message, ErroredValue)
{
public const string Code = "Insufficient Permissions";
public InsufficientPermissionsError(T ErroredValue) : this(Code, ErroredValue)
{
}
}

View File

@ -0,0 +1,11 @@
namespace Bit.Core.AdminConsole.Errors;
public record RecordNotFoundError<T>(string Message, T ErroredValue) : Error<T>(Message, ErroredValue)
{
public const string Code = "Record Not Found";
public RecordNotFoundError(T ErroredValue) : this(Code, ErroredValue)
{
}
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.AdminConsole.Shared.Validation;
public interface IValidator<T>
{
public Task<ValidationResult<T>> ValidateAsync(T value);
}

View File

@ -0,0 +1,15 @@
using Bit.Core.AdminConsole.Errors;
namespace Bit.Core.AdminConsole.Shared.Validation;
public abstract record ValidationResult<T>;
public record Valid<T> : ValidationResult<T>
{
public T Value { get; init; }
}
public record Invalid<T> : ValidationResult<T>
{
public IEnumerable<Error<T>> Errors { get; init; }
}

View File

@ -1,5 +1,7 @@
#nullable enable #nullable enable
using Bit.Core.AdminConsole.Errors;
namespace Bit.Core.Models.Commands; namespace Bit.Core.Models.Commands;
public class CommandResult(IEnumerable<string> errors) public class CommandResult(IEnumerable<string> errors)
@ -9,7 +11,6 @@ public class CommandResult(IEnumerable<string> errors)
public bool Success => ErrorMessages.Count == 0; public bool Success => ErrorMessages.Count == 0;
public bool HasErrors => ErrorMessages.Count > 0; public bool HasErrors => ErrorMessages.Count > 0;
public List<string> ErrorMessages { get; } = errors.ToList(); public List<string> ErrorMessages { get; } = errors.ToList();
public CommandResult() : this(Array.Empty<string>()) { } public CommandResult() : this(Array.Empty<string>()) { }
} }
@ -29,22 +30,30 @@ public class Success : CommandResult
{ {
} }
public abstract class CommandResult<T> public abstract class CommandResult<T>;
{
public class Success<T>(T value) : CommandResult<T>
{
public T Value { get; } = value;
} }
public class Success<T>(T data) : CommandResult<T> public class Failure<T>(IEnumerable<string> errorMessages) : CommandResult<T>
{ {
public T? Data { get; init; } = data; public List<string> ErrorMessages { get; } = errorMessages.ToList();
public string ErrorMessage => string.Join(" ", ErrorMessages);
public Failure(string error) : this([error]) { }
} }
public class Failure<T>(IEnumerable<string> errorMessage) : CommandResult<T> public class Partial<T> : CommandResult<T>
{ {
public IEnumerable<string> ErrorMessages { get; init; } = errorMessage; public T[] Successes { get; set; } = [];
public Error<T>[] Failures { get; set; } = [];
public Failure(string errorMessage) : this(new[] { errorMessage }) public Partial(IEnumerable<T> successfulItems, IEnumerable<Error<T>> failedItems)
{ {
Successes = successfulItems.ToArray();
Failures = failedItems.ToArray();
} }
} }

View File

@ -0,0 +1,58 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.Shared.Validation;
using Xunit;
namespace Bit.Core.Test.AdminConsole.Shared;
public class IValidatorTests
{
public class TestClass
{
public string Name { get; set; } = string.Empty;
}
public record InvalidRequestError<T>(T ErroredValue) : Error<T>(Code, ErroredValue)
{
public const string Code = "InvalidRequest";
}
public class TestClassValidator : IValidator<TestClass>
{
public Task<ValidationResult<TestClass>> ValidateAsync(TestClass value)
{
if (string.IsNullOrWhiteSpace(value.Name))
{
return Task.FromResult<ValidationResult<TestClass>>(new Invalid<TestClass>
{
Errors = [new InvalidRequestError<TestClass>(value)]
});
}
return Task.FromResult<ValidationResult<TestClass>>(new Valid<TestClass> { Value = value });
}
}
[Fact]
public async Task ValidateAsync_WhenSomethingIsInvalid_ReturnsInvalidWithError()
{
var example = new TestClass();
var result = await new TestClassValidator().ValidateAsync(example);
Assert.IsType<Invalid<TestClass>>(result);
var invalidResult = result as Invalid<TestClass>;
Assert.Equal(InvalidRequestError<TestClass>.Code, invalidResult.Errors.First().Message);
}
[Fact]
public async Task ValidateAsync_WhenIsValid_ReturnsValid()
{
var example = new TestClass { Name = "Valid" };
var result = await new TestClassValidator().ValidateAsync(example);
Assert.IsType<Valid<TestClass>>(result);
var validResult = result as Valid<TestClass>;
Assert.Equal(example.Name, validResult.Value.Name);
}
}

View File

@ -0,0 +1,53 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.Models.Commands;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.Models.Commands;
public class CommandResultTests
{
public class TestItem
{
public Guid Id { get; set; }
public string Value { get; set; }
}
public CommandResult<TestItem> BulkAction(IEnumerable<TestItem> items)
{
var itemList = items.ToList();
var successfulItems = items.Where(x => x.Value == "SuccessfulRequest").ToArray();
var failedItems = itemList.Except(successfulItems).ToArray();
var notFound = failedItems.First(x => x.Value == "Failed due to not found");
var invalidPermissions = failedItems.First(x => x.Value == "Failed due to invalid permissions");
var notFoundError = new RecordNotFoundError<TestItem>(notFound);
var insufficientPermissionsError = new InsufficientPermissionsError<TestItem>(invalidPermissions);
return new Partial<TestItem>(successfulItems.ToArray(), [notFoundError, insufficientPermissionsError]);
}
[Theory]
[BitAutoData]
public void Partial_CommandResult_BulkRequestWithSuccessAndFailures(Guid successId1, Guid failureId1, Guid failureId2)
{
var listOfRecords = new List<TestItem>
{
new TestItem() { Id = successId1, Value = "SuccessfulRequest" },
new TestItem() { Id = failureId1, Value = "Failed due to not found" },
new TestItem() { Id = failureId2, Value = "Failed due to invalid permissions" }
};
var result = BulkAction(listOfRecords);
Assert.IsType<Partial<TestItem>>(result);
var failures = (result as Partial<TestItem>).Failures.ToArray();
var success = (result as Partial<TestItem>).Successes.First();
Assert.Equal(listOfRecords.First(), success);
Assert.Equal(2, failures.Length);
}
}