diff --git a/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChain.Payloads.cs b/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChain.Payloads.cs index 2eced71..b5c47c3 100644 --- a/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChain.Payloads.cs +++ b/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChain.Payloads.cs @@ -22,6 +22,19 @@ public static class SampleCommandOutcomes Minimum = 7ms, Maximum = 8ms, Average = 7ms """; + + internal const string PingTimeout = """ + + Pinging 1.1.1.1 with 32 bytes of data: + PING: transmit failed. General failure. + PING: transmit failed. General failure. + PING: transmit failed. General failure. + PING: transmit failed. General failure. + + Ping statistics for 2a00:1450:4001:806::200e: + Packets: Sent = 4, Received = 0, Lost = 4 (100% loss), + + """; } public static class Payloads @@ -47,7 +60,8 @@ public static class Payloads "Regex.Match($'time=(\\d+)ms');", "Regex.YieldGroup(1);", "Values.Average();" - ] + ], + "valueType": "decimal" } ] } @@ -62,19 +76,22 @@ public static class Payloads "type": "parser", "statements": [ "Regex.Match($'time=(\\d+)ms');" - ] + ], + "cardinality": "multiple" }, { "type": "parser", "statements": [ "Regex.YieldGroup(1);" - ] + ], + "cardinality": "multiple" }, { "type": "parser", "statements": [ "Values.Average();" - ] + ], + "valueType": "decimal" } ] } @@ -86,6 +103,14 @@ public static class Payloads internal static string PingResultMultipleTransformations => InjectMultilineArray(PingResultMultipleTransformationsTemplate, SampleCommandOutcomes.Ping); + /// + /// Simulates a ping cli result when there is, for example, no internet connection. + /// The transformation relies on the presence of at least one value to aggregate, but there is none. + /// Refer to GitHub Issue #35 + /// + internal static string PingTimeoutResultOneTransformation => + InjectMultilineArray(PingResultOneTransformationTemplate, SampleCommandOutcomes.PingTimeout); + private static string InjectMultilineArray(string template, string toInject) { string[] lines = toInject.Split(Environment.NewLine); diff --git a/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChainTests.cs b/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChainTests.cs index badf744..03e89a6 100644 --- a/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChainTests.cs +++ b/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChainTests.cs @@ -1,11 +1,18 @@ -using System.Text.Json; using FluentAssertions; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc; using Oma.WndwCtrl.Abstractions.Model; using Oma.WndwCtrl.Api.IntegrationTests.TestFramework; -using Oma.WndwCtrl.CliOutputParser.Interfaces; namespace Oma.WndwCtrl.Api.IntegrationTests.Endpoints.TestController; +[UsedImplicitly] +[PublicAPI] +internal class NestedProblemDetails : ProblemDetails +{ + public List InnerErrors { get; init; } = []; +} + public sealed partial class TransformationChainTests( MockedCommandExecutorApiFixture mockedCommandExecutorApiFixture ) @@ -32,10 +39,10 @@ public async Task ShouldProcessPingResultInOneTransformation() using HttpRequestMessage httpRequestMessage = ConstructCommandHttpRequestMessage(payload, isJson: true); using HttpResponseMessage httpResponse = await _httpClient.SendAsync(httpRequestMessage, _cancelToken); - const double expected = 7.5; + const decimal expected = 7.5m; - httpResponse.Should().Be200Ok().And.Satisfy>( - response => { AssertParserResultOneNumber(response, expected); } + httpResponse.Should().Be200Ok().And.Satisfy>( + response => { response.Outcome.Should().Be(expected); } ); } @@ -46,23 +53,56 @@ public async Task ShouldProcessPingResultInMultipleTransformations() using HttpRequestMessage httpRequestMessage = ConstructCommandHttpRequestMessage(payload, isJson: true); using HttpResponseMessage httpResponse = await _httpClient.SendAsync(httpRequestMessage, _cancelToken); - const double expected = 7.5; + const decimal expected = 7.5m; + + httpResponse.Should().Be200Ok().And.Satisfy>( + response => { response.Outcome.Should().Be(expected); } + ); + } + + [Fact] + public async Task ShouldReturnProblemDetailsOnEmptyValueAggregation() + { + string payload = Payloads.PingTimeoutResultOneTransformation; + using HttpRequestMessage httpRequestMessage = ConstructCommandHttpRequestMessage(payload, isJson: true); + using HttpResponseMessage httpResponse = await _httpClient.SendAsync(httpRequestMessage, _cancelToken); + + httpResponse.Should().Be500InternalServerError().And.Satisfy( + response => + { + response.Detail.Should() + .Be( + "The CLI parser was in an invalid state during the transformation and could not proceed." + ); - httpResponse.Should().Be200Ok().And.Satisfy>( - response => { AssertParserResultOneNumber(response, expected); } + response.InnerErrors.Should().HaveCount(expected: 1).And.Satisfy( + pd => pd.Title == + "The CLI parser was in an invalid state during the transformation and could not proceed." + ).And.Satisfy( + pd => pd.Detail == + "Strict aggregation (function: 'Average') requires at least one value to be present, but the collection was empty. This could be caused by an invalid transformation or the input text was in an invalid/irregular format." + ); + } ); } - private static void AssertParserResultOneNumber( - TransformationOutcome response, - double expectedValue - ) + [Fact] + public async Task ShouldReturn500OnEmptyValueAggregation() { - response.Success.Should().BeTrue(); - response.Outcome.Should().NotBeNull(); - response.Outcome!.First().Should().BeAssignableTo(); - JsonElement jsonElement = (JsonElement)response.Outcome!.First(); - double num = jsonElement.GetDouble(); - num.Should().Be(expectedValue); + string payload = Payloads.PingTimeoutResultOneTransformation; + using HttpRequestMessage httpRequestMessage = ConstructCommandHttpRequestMessage(payload, isJson: true); + using HttpResponseMessage httpResponse = await _httpClient.SendAsync(httpRequestMessage, _cancelToken); + + httpResponse.Should().Be500InternalServerError(); + } + + [Fact] + public async Task ShouldReturnJsonOnEmptyValueAggregation() + { + string payload = Payloads.PingTimeoutResultOneTransformation; + using HttpRequestMessage httpRequestMessage = ConstructCommandHttpRequestMessage(payload, isJson: true); + using HttpResponseMessage httpResponse = await _httpClient.SendAsync(httpRequestMessage, _cancelToken); + + httpResponse.Should().HaveHeader("Content-Type").And.Match("application/json*"); } } \ No newline at end of file diff --git a/.unitTests/Oma.WndwCtrl.Core.Tests/Executors/Transformers/ParserTransformerTests.cs b/.unitTests/Oma.WndwCtrl.Core.Tests/Executors/Transformers/ParserTransformerTests.cs new file mode 100644 index 0000000..7f03812 --- /dev/null +++ b/.unitTests/Oma.WndwCtrl.Core.Tests/Executors/Transformers/ParserTransformerTests.cs @@ -0,0 +1,157 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using LanguageExt; +using LanguageExt.Common; +using NSubstitute; +using Oma.WndwCtrl.Abstractions.Errors; +using Oma.WndwCtrl.Abstractions.Extensions; +using Oma.WndwCtrl.Abstractions.Model; +using Oma.WndwCtrl.CliOutputParser.Interfaces; +using Oma.WndwCtrl.Core.Executors.Transformers; +using Oma.WndwCtrl.Core.Model.Transformations; +using static LanguageExt.Prelude; +using ValueType = Oma.WndwCtrl.Abstractions.Model.ValueType; + +namespace Oma.WndwCtrl.Core.Tests.Executors.Transformers; + +public sealed class ParserTransformerTests : IDisposable +{ + private readonly CancellationToken _cancelToken; + private readonly ICliOutputParser _cliOutputParserMock = Substitute.For(); + + private readonly ParserTransformer _instance; + + private Func> _mockedParseResultFunc; + + private Either? _result; + + private ParserTransformation _transformationInput = new() + { + Statements = ["not used - it's mocked",], + Cardinality = Cardinality.Single, + ValueType = ValueType.String, + }; + + public ParserTransformerTests() + { + _cancelToken = TestContext.Current.CancellationToken; + + _mockedParseResultFunc = () => Right(["Default Text Value",]); + + _cliOutputParserMock.Parse(Arg.Any(), Arg.Any()) + .Returns(_ => _mockedParseResultFunc()); + + _instance = new ParserTransformer(_cliOutputParserMock); + } + + public void Dispose() + { + _result?.Dispose(); + } + + [Fact] + public async Task SanityCheck() + { + await ExecuteTransformerAsync(); + SatisfiesRight(to => to.Outcome.Should().Be("Default Text Value")); + } + + [Fact] + public async Task ShouldSuccessfullyParseSingleBool() + { + _transformationInput = _transformationInput with + { + ValueType = ValueType.Boolean, + }; + + _mockedParseResultFunc = () => Right([true,]); + + await ExecuteTransformerAsync(); + + SatisfiesRight(to => to.Outcome.Should().BeTrue()); + } + + [Fact] + public async Task ShouldSuccessfullyParseSingleString() + { + const string expected = "this is a string override"; + + _transformationInput = _transformationInput with + { + ValueType = ValueType.String, + }; + + _mockedParseResultFunc = () => Right([expected,]); + + await ExecuteTransformerAsync(); + + SatisfiesRight(to => to.Outcome.Should().Be(expected)); + } + + [Fact] + public async Task ShouldSuccessfullyParseSingleLong() + { + const long expected = (long)int.MaxValue + 1; + + _transformationInput = _transformationInput with + { + ValueType = ValueType.Long, + }; + + _mockedParseResultFunc = () => Right([expected,]); + + await ExecuteTransformerAsync(); + + SatisfiesRight(to => to.Outcome.Should().Be(expected)); + } + + [Fact] + public async Task ShouldSuccessfullyParseSingleDouble() + { + const double expected = 12345.6789d; + + _transformationInput = _transformationInput with + { + ValueType = ValueType.Decimal, + }; + + _mockedParseResultFunc = () => Right([expected,]); + + await ExecuteTransformerAsync(); + + SatisfiesRight(to => to.Outcome.Should().Be((decimal)expected)); + } + + private async Task ExecuteTransformerAsync() + { + using TransformationOutcome emptyOutcome = new(); + + _result = await _instance.TransformCommandOutcomeAsync( + _transformationInput, + Right(emptyOutcome), + _cancelToken + ); + } + + private void SatisfiesRight(params Action>[] assertions) + { + using AssertionScope scope = new(); + + _result.Should().NotBeNull(); + + _result?.Match( + Right: val => + { + if (val is not TransformationOutcome casted) + { + throw new InvalidOperationException( + $"Expected outcome to be of type {typeof(T).Name}, but it was {val.GetType()}." + ); + } + + foreach (Action> assertion in assertions) assertion(casted); + }, + Left: val => val.Should().BeNull() + ); + } +} \ No newline at end of file diff --git a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputLexer.g4 b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputLexer.g4 index 4713300..a3ce6b0 100644 --- a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputLexer.g4 +++ b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputLexer.g4 @@ -25,6 +25,7 @@ MIN : 'Min'; SUM : 'Sum'; AT : 'At'; INDEX : 'Index'; +COUNT : 'Count'; DOT : '.'; LPAREN : '('; diff --git a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputParser.g4 b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputParser.g4 index e0bf4b9..dd87a02 100644 --- a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputParser.g4 +++ b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputParser.g4 @@ -16,9 +16,15 @@ map : anchorFrom multiply : regexMatch ; reduce : regexYield - | valuesAvg | valuesSum | valuesMin | valuesMax - | valuesFirst | valuesLast - | valuesAt; + | strictValueAggregation + | forgivingValueAggregation + ; + +// Requires at least one value to be populated, otherwise returns an error +strictValueAggregation : valuesAvg | valuesMin | valuesMax | valuesFirst | valuesLast; + +// Returns 0 if no values are populated +forgivingValueAggregation : valuesSum | valuesCount | valuesAt; anchorFrom : ANCHOR DOT FROM LPAREN STRING_LITERAL RPAREN SEMI; anchorTo : ANCHOR DOT TO LPAREN STRING_LITERAL RPAREN SEMI; @@ -28,10 +34,13 @@ regexYield : REGEX DOT YIELD_GROUP LPAREN INT RPAREN SEMI; // Would be cleaner to combine those.. valuesAvg : VALUES DOT AVERAGE LPAREN RPAREN SEMI; -valuesSum : VALUES DOT SUM LPAREN RPAREN SEMI; valuesMin : VALUES DOT MIN LPAREN RPAREN SEMI; valuesMax : VALUES DOT MAX LPAREN RPAREN SEMI; valuesFirst : VALUES DOT FIRST LPAREN RPAREN SEMI; valuesLast : VALUES DOT LAST LPAREN RPAREN SEMI; + +// Forgiving, i.e. falling back (not raising an error) when there are no values in the enumeration +valuesSum : VALUES DOT SUM LPAREN RPAREN SEMI; +valuesCount : VALUES DOT COUNT LPAREN RPAREN SEMI; valuesAt : VALUES DOT (AT | INDEX) LPAREN INT RPAREN SEMI; \ No newline at end of file diff --git a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/CliOutputParserImpl.cs b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/CliOutputParserImpl.cs index 90b3abf..9f54bf7 100644 --- a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/CliOutputParserImpl.cs +++ b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/CliOutputParserImpl.cs @@ -1,8 +1,10 @@ using Antlr4.Runtime.Tree; using LanguageExt; using LanguageExt.Common; +using Oma.WndwCtrl.Abstractions.Errors; using Oma.WndwCtrl.CliOutputParser.Interfaces; using Oma.WndwCtrl.CliOutputParser.Visitors; +using static LanguageExt.Prelude; namespace Oma.WndwCtrl.CliOutputParser; @@ -39,12 +41,12 @@ Func transformationListenerFactory Either treeOrError = treeCache.GetOrCreateTree(transformation); - return treeOrError.Map( + return treeOrError.Bind( tree => ExecuteParser(transformationListenerFactory, tree) ); } - private static ParserResult ExecuteParser( + private static Either ExecuteParser( Func transformationListenerFactory, Grammar.CliOutputParser.TransformationContext tree ) @@ -52,13 +54,35 @@ Grammar.CliOutputParser.TransformationContext tree TransformationListener listener = transformationListenerFactory(); ParseTreeWalker walker = new(); - walker.Walk(listener, tree); + Exception? thrownException = null; + + try + { + walker.Walk(listener, tree); + } + catch (Exception ex) + { + thrownException = ex; + } + + if (listener.Error is not null) + { + return listener.Error with + { + ThrownException = thrownException, + }; + } + + if (thrownException is not null) + { + return new FlowError(new TechnicalError("An unexpected error occurred.", Code: 5_000, thrownException)); + } List enumeratedList = listener.CurrentValues.ToList(); if (enumeratedList.Count == 1) { - return [enumeratedList.Single(),]; + return Right([enumeratedList.Single(),]); } ParserResult result = []; diff --git a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/EmptyEnumerationAggregationError.cs b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/EmptyEnumerationAggregationError.cs new file mode 100644 index 0000000..8800aac --- /dev/null +++ b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/EmptyEnumerationAggregationError.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; + +namespace Oma.WndwCtrl.CliOutputParser.Errors; + +[PublicAPI] +public record EmptyEnumerationAggregationError : ValueAggregationError +{ + private readonly string _aggregationFunction; + + public EmptyEnumerationAggregationError(string aggregationFunction) + { + _aggregationFunction = aggregationFunction; + } + + public override string Detail => + $"Strict aggregation (function: '{_aggregationFunction}') requires at least one value to be present, but the collection was empty. This could be caused by an invalid transformation or the input text was in an invalid/irregular format."; +} \ No newline at end of file diff --git a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/ParserStateError.cs b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/ParserStateError.cs new file mode 100644 index 0000000..60685b0 --- /dev/null +++ b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/ParserStateError.cs @@ -0,0 +1,17 @@ +using Oma.WndwCtrl.Abstractions.Errors; + +namespace Oma.WndwCtrl.CliOutputParser.Errors; + +public abstract record ParserStateError : FlowError +{ + protected ParserStateError(bool isExceptional) : base(isExceptional) + { + } + + public override string Message => + "The CLI parser was in an invalid state during the transformation and could not proceed."; + + public Exception? ThrownException { get; init; } + + public override Exception ToException() => ThrownException ?? ToErrorException(); +} \ No newline at end of file diff --git a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/ProcessingError.cs b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/ProcessingError.cs index 20636ab..c17b450 100644 --- a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/ProcessingError.cs +++ b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/ProcessingError.cs @@ -2,8 +2,11 @@ namespace Oma.WndwCtrl.CliOutputParser.Errors; -public record ProcessingError(string Message, int Line, int CharPositionInLine) - : FlowError(Message, IsExceptional: false, IsExpected: true); +public record ProcessingError(string ErrorMessage, int Line, int CharPositionInLine) + : FlowError(IsExceptional: false, IsExpected: true) +{ + public override string Message => ErrorMessage; +} public record ProcessingError(string Message, int Line, int CharPositionInLine, TType OffendingSymbol) : ProcessingError(Message, Line, CharPositionInLine) diff --git a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/ValueAggregationError.cs b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/ValueAggregationError.cs new file mode 100644 index 0000000..8a60a7b --- /dev/null +++ b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Errors/ValueAggregationError.cs @@ -0,0 +1,8 @@ +namespace Oma.WndwCtrl.CliOutputParser.Errors; + +public record ValueAggregationError : ParserStateError +{ + protected ValueAggregationError() : base(isExceptional: true) + { + } +} \ No newline at end of file diff --git a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Visitors/TransformationListener.Values.cs b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Visitors/TransformationListener.Values.cs index 92c0904..f97c6a0 100644 --- a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Visitors/TransformationListener.Values.cs +++ b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Visitors/TransformationListener.Values.cs @@ -1,4 +1,7 @@ +using Antlr4.Runtime.Tree; using JetBrains.Annotations; +using Oma.WndwCtrl.CliOutputParser.Errors; +using Oma.WndwCtrl.CliOutputParser.Model; namespace Oma.WndwCtrl.CliOutputParser.Visitors; @@ -114,4 +117,53 @@ public override void ExitValuesAt(Grammar.CliOutputParser.ValuesAtContext contex : itemList[index]; } } + + public override void ExitValuesCount(Grammar.CliOutputParser.ValuesCountContext context) + { + object? result = FoldItemsRecursive(CurrentValues, Fold); + StoreFoldResult(result); + + base.ExitValuesCount(context); + return; + + object Fold(IEnumerable val) + { + object res = val.Count(); + return res; + } + } + + public override void EnterStrictValueAggregation( + Grammar.CliOutputParser.StrictValueAggregationContext context + ) + { + List collapsedValues = CurrentValues.ToList(); + + if (collapsedValues.Count == 0) + { + IParseTree aggregationFunctionContext = context.GetChild(i: 0); + + // TODO: This is really stupid.. Instead the parser should be fixed so that all aggregation functions are handled generically. + string aggregationFunction = aggregationFunctionContext switch + { + Grammar.CliOutputParser.ValuesAvgContext => "Average", + Grammar.CliOutputParser.ValuesMinContext => "Min", + Grammar.CliOutputParser.ValuesMaxContext => "Max", + Grammar.CliOutputParser.ValuesFirstContext => "First", + Grammar.CliOutputParser.ValuesLastContext => "Last", + var _ => throw new InvalidOperationException( + $"Unknown aggregation function {aggregationFunctionContext.GetText()}. This is a programming error. Finally fix the parser." + ), + }; + + Error = new EmptyEnumerationAggregationError( + aggregationFunction + ); + + return; + } + + CurrentValues = NestedEnumerable.FromEnumerableInternal(collapsedValues, CurrentValues.IsNested); + base.EnterStrictValueAggregation(context); + } } \ No newline at end of file diff --git a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Visitors/TransformationListener.cs b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Visitors/TransformationListener.cs index 6559b30..813ce86 100644 --- a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Visitors/TransformationListener.cs +++ b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/Visitors/TransformationListener.cs @@ -1,3 +1,4 @@ +using Oma.WndwCtrl.CliOutputParser.Errors; using Oma.WndwCtrl.CliOutputParser.Grammar; using Oma.WndwCtrl.CliOutputParser.Interfaces; using Oma.WndwCtrl.CliOutputParser.Model; @@ -26,6 +27,8 @@ public TransformationListener(IParserLogger log, string input) public NestedEnumerable CurrentValues { get; private set; } + public ParserStateError? Error { get; private set; } + public override void EnterStatement(Grammar.CliOutputParser.StatementContext context) { if (_log.Enabled) @@ -33,6 +36,12 @@ public override void EnterStatement(Grammar.CliOutputParser.StatementContext con _log.Log($"{Environment.NewLine}\t### COMMAND -> {context.GetChild(i: 0).GetText()}"); } + if (Error is not null) + { + // TODO: Check if this really short-circuits everything. + return; + } + base.EnterStatement(context); } } \ No newline at end of file diff --git a/Oma.WndwCtrl.Abstractions/Errors/CommandError.cs b/Oma.WndwCtrl.Abstractions/Errors/CommandError.cs index f14a4e9..4966327 100644 --- a/Oma.WndwCtrl.Abstractions/Errors/CommandError.cs +++ b/Oma.WndwCtrl.Abstractions/Errors/CommandError.cs @@ -9,20 +9,24 @@ public record CommandError : FlowError, ICommandExecutionMetadata { protected CommandError(Error other) : base(other) { + Message = other.Message; } protected CommandError(TechnicalError technicalError) : base(technicalError) { + Message = technicalError.Message; } protected CommandError(string message, bool isExceptional, bool isExpected) : base( - message, isExceptional, isExpected ) { + Message = message; } + public override string Message { get; } + [PublicAPI] public Option ExecutionDuration { get; } = Option.None; diff --git a/Oma.WndwCtrl.Abstractions/Errors/FlowError.cs b/Oma.WndwCtrl.Abstractions/Errors/FlowError.cs index 8cf0a86..ee45466 100644 --- a/Oma.WndwCtrl.Abstractions/Errors/FlowError.cs +++ b/Oma.WndwCtrl.Abstractions/Errors/FlowError.cs @@ -5,9 +5,9 @@ namespace Oma.WndwCtrl.Abstractions.Errors; [method: PublicAPI] -public record FlowError(string Message, bool IsExceptional, bool IsExpected) : Error +public record FlowError(bool IsExceptional, bool IsExpected) : Error { - protected FlowError(Error other) : this(other.Message, other.IsExceptional, other.IsExpected) + public FlowError(Error other) : this(other.IsExceptional, other.IsExpected) { Code = other.Code; Inner = other; @@ -19,12 +19,25 @@ public FlowError(TechnicalError technicalError) : this((Error)technicalError) } [PublicAPI] - public FlowError(string message, bool isExceptional) : this(message, isExceptional, !isExceptional) + public FlowError(bool isExceptional) : this(isExceptional, !isExceptional) { } + public virtual string? Detail => Inner.Match( + err => + { + return err switch + { + FlowError flowError => flowError.Message, + ManyErrors => "Multiple errors occurred. Refer to the nested properties for more details.", + var _ => null, + }; + }, + () => null + ); + public override int Code { get; } - public override string Message { get; } = Message; + public override string Message => "An unexpected error occurred processing a flow."; public override bool IsExceptional { get; } = IsExceptional; public override bool IsExpected { get; } = IsExpected; @@ -37,16 +50,40 @@ public override ErrorException ToErrorException() => ); [System.Diagnostics.Contracts.Pure] - public static FlowError NoCommandExecutorFound(ICommand command) => new( - $"No command executor found that handles transformation type {command.GetType().FullName}.", - isExceptional: false - ); + public static FlowError NoCommandExecutorFound(ICommand command) => + new NoCommandExecutorFoundError(command); [System.Diagnostics.Contracts.Pure] - public static FlowError NoTransformerFound(ITransformation transformation) => new( - $"No transformation executor found that handles transformation type {transformation.GetType().FullName}.", - isExceptional: false - ); + public static FlowError NoTransformerFound(ITransformation transformation) => + new NoTransformerFoundError(transformation); public static implicit operator FlowError(TechnicalError error) => new(error); + + [PublicAPI] + public record NoCommandExecutorFoundError : FlowError + { + private readonly string _commandType; + + public NoCommandExecutorFoundError(ICommand command) : base(isExceptional: true) + { + _commandType = command.GetType().FullName ?? "unknown"; + } + + public override string Message => + $"No command executor found that handles transformation type {_commandType}."; + } + + [PublicAPI] + public record NoTransformerFoundError : FlowError + { + private readonly string _transformationName; + + public NoTransformerFoundError(ITransformation transformation) : base(isExceptional: true) + { + _transformationName = transformation.GetType().FullName ?? "unknown"; + } + + public override string Message => + $"No command executor found that handles transformation type {_transformationName}."; + } } \ No newline at end of file diff --git a/Oma.WndwCtrl.Abstractions/Errors/TransformationError.cs b/Oma.WndwCtrl.Abstractions/Errors/TransformationError.cs index 828f69a..ec29e74 100644 --- a/Oma.WndwCtrl.Abstractions/Errors/TransformationError.cs +++ b/Oma.WndwCtrl.Abstractions/Errors/TransformationError.cs @@ -1,20 +1,18 @@ -using JetBrains.Annotations; using LanguageExt.Common; namespace Oma.WndwCtrl.Abstractions.Errors; -public record TransformationError : FlowError +public abstract record TransformationError : FlowError { - public TransformationError(Error other) : base(other) + protected TransformationError(Error error) : base(error) { } - [PublicAPI] - public TransformationError(string message, bool isExceptional, bool isExpected) : base( - message, - isExceptional, - isExpected + protected TransformationError(bool isExceptional) : base( + isExceptional ) { } + + public override string Message => "An error occurred executing an outcome transformation."; } \ No newline at end of file diff --git a/Oma.WndwCtrl.Abstractions/Errors/Transformations/MismatchedCardinalityTransformationError.cs b/Oma.WndwCtrl.Abstractions/Errors/Transformations/MismatchedCardinalityTransformationError.cs new file mode 100644 index 0000000..da3dd29 --- /dev/null +++ b/Oma.WndwCtrl.Abstractions/Errors/Transformations/MismatchedCardinalityTransformationError.cs @@ -0,0 +1,10 @@ +using Oma.WndwCtrl.Abstractions.Model; + +namespace Oma.WndwCtrl.Abstractions.Errors.Transformations; + +public record MismatchedCardinalityTransformationError(Cardinality expectedCardinality) + : TransformationError(isExceptional: false) +{ + public override string Message => + $"Expected cardinality of '{expectedCardinality}' but the outcome returned is a different cardinality."; +} \ No newline at end of file diff --git a/Oma.WndwCtrl.Abstractions/Errors/Transformations/ValueEmptyTransformationError.cs b/Oma.WndwCtrl.Abstractions/Errors/Transformations/ValueEmptyTransformationError.cs new file mode 100644 index 0000000..b75a628 --- /dev/null +++ b/Oma.WndwCtrl.Abstractions/Errors/Transformations/ValueEmptyTransformationError.cs @@ -0,0 +1,8 @@ +namespace Oma.WndwCtrl.Abstractions.Errors.Transformations; + +public record ValueEmptyTransformationError(ValueType ExpectedValueType) + : TransformationError(isExceptional: false) +{ + public override string Message => + $"Expected value of type '{ExpectedValueType}' but the outcome was a null reference or empty."; +} \ No newline at end of file diff --git a/Oma.WndwCtrl.Abstractions/Errors/Transformations/ValueTypeMismatchTransformationError.cs b/Oma.WndwCtrl.Abstractions/Errors/Transformations/ValueTypeMismatchTransformationError.cs new file mode 100644 index 0000000..e576d5b --- /dev/null +++ b/Oma.WndwCtrl.Abstractions/Errors/Transformations/ValueTypeMismatchTransformationError.cs @@ -0,0 +1,8 @@ +namespace Oma.WndwCtrl.Abstractions.Errors.Transformations; + +public record ValueTypeMismatchTransformationError(ValueType ExpectedValueType, Type actualType) + : TransformationError(isExceptional: false) +{ + public override string Message => + $"Expected value of type '{ExpectedValueType}' but the outcome returned {actualType}."; +} \ No newline at end of file diff --git a/Oma.WndwCtrl.Abstractions/Model/Cardinality.cs b/Oma.WndwCtrl.Abstractions/Model/Cardinality.cs new file mode 100644 index 0000000..bcfe0a4 --- /dev/null +++ b/Oma.WndwCtrl.Abstractions/Model/Cardinality.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Oma.WndwCtrl.Abstractions.Model; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Cardinality +{ + Single, + Multiple, +} \ No newline at end of file diff --git a/Oma.WndwCtrl.Abstractions/Model/TransformationOutcome.cs b/Oma.WndwCtrl.Abstractions/Model/TransformationOutcome.cs index d5c263c..4f74e39 100644 --- a/Oma.WndwCtrl.Abstractions/Model/TransformationOutcome.cs +++ b/Oma.WndwCtrl.Abstractions/Model/TransformationOutcome.cs @@ -6,6 +6,7 @@ namespace Oma.WndwCtrl.Abstractions.Model; [Serializable] [MustDisposeResource] +[PublicAPI] public record TransformationOutcome : IOutcome, IDisposable { public TransformationOutcome() @@ -24,6 +25,9 @@ public TransformationOutcome(IOutcome outcome) OutcomeRaw = outcome.OutcomeRaw; } + [JsonIgnore] + public virtual object? OutcomeUntyped { get; } + public void Dispose() { Dispose(disposing: true); @@ -31,7 +35,7 @@ public void Dispose() } public bool Success { get; init; } - + [JsonInclude] public string OutcomeRaw { get; internal set; } = string.Empty; @@ -69,6 +73,8 @@ public TransformationOutcome(TData data, bool success = true) [JsonInclude] public TData? Outcome { get; internal set; } + public override object? OutcomeUntyped => Outcome; + public override FlowOutcome ToFlowOutcome() => Outcome is not null ? new FlowOutcome(Outcome, Success) : new FlowOutcome(this); diff --git a/Oma.WndwCtrl.Abstractions/Model/ValueType.cs b/Oma.WndwCtrl.Abstractions/Model/ValueType.cs new file mode 100644 index 0000000..6b066ff --- /dev/null +++ b/Oma.WndwCtrl.Abstractions/Model/ValueType.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Oma.WndwCtrl.Abstractions.Model; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ValueType +{ + Boolean, + String, + Long, + Decimal, +} \ No newline at end of file diff --git a/Oma.WndwCtrl.Api/Controllers/Components/ButtonController.cs b/Oma.WndwCtrl.Api/Controllers/Components/ButtonController.cs index c7b0c9c..ff7f5a7 100644 --- a/Oma.WndwCtrl.Api/Controllers/Components/ButtonController.cs +++ b/Oma.WndwCtrl.Api/Controllers/Components/ButtonController.cs @@ -1,6 +1,9 @@ using System.Diagnostics.CodeAnalysis; +using LanguageExt; using Microsoft.AspNetCore.Mvc; +using Oma.WndwCtrl.Abstractions.Errors; using Oma.WndwCtrl.Abstractions.Messaging.Model.ComponentExecution; +using Oma.WndwCtrl.Abstractions.Model; using Oma.WndwCtrl.Api.Attributes; using Oma.WndwCtrl.Core.Model; @@ -17,5 +20,6 @@ public class ButtonController : ComponentControllerBase