diff --git a/src/Trax.Effect/Models/Metadata/Metadata.cs b/src/Trax.Effect/Models/Metadata/Metadata.cs index 5a05cd7..d9fa5ac 100644 --- a/src/Trax.Effect/Models/Metadata/Metadata.cs +++ b/src/Trax.Effect/Models/Metadata/Metadata.cs @@ -492,6 +492,32 @@ public override string ToString() => /// public dynamic? GetOutputObject() => _outputObject; + /// + /// Gets the deserialized input object cast to the specified type. + /// The input is set before the train's junctions execute, so it is available in all + /// lifecycle hooks (OnStarted, OnCompleted, OnFailed, OnCancelled). + /// Returns default if the input is null or not of type . + /// + /// + /// For per-train lifecycle hooks, prefer the TrainInput property on + /// ServiceTrain<TIn, TOut> which provides the same value without + /// requiring a type parameter. + /// + public T? GetInput() => _inputObject is T typed ? typed : default; + + /// + /// Gets the deserialized output object cast to the specified type. + /// The output is set after a successful run, so it is only meaningful in OnCompleted. + /// Returns default in OnStarted, OnFailed, and OnCancelled. + /// Returns default if the output is null or not of type . + /// + /// + /// For per-train lifecycle hooks, prefer the TrainOutput property on + /// ServiceTrain<TIn, TOut> which provides the same value without + /// requiring a type parameter. + /// + public T? GetOutput() => _outputObject is T typed ? typed : default; + #endregion /// diff --git a/src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs b/src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs index 1852884..eaada7b 100644 --- a/src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs +++ b/src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs @@ -75,6 +75,24 @@ public abstract class ServiceTrain : Train, IServiceTrain< [JsonIgnore] public string? CanonicalName { get; set; } + /// + /// Gets the typed input that was passed to this train. Set before + /// executes, so it is available in all lifecycle hooks: , + /// , , and . + /// Returns default before the train has been run. + /// + protected TIn TrainInput => + Metadata is not null && Metadata.GetInputObject() is TIn typed ? typed : default!; + + /// + /// Gets the typed output produced by this train. Set after a successful run, so it is + /// only meaningful in . Returns default in + /// , , and + /// because the train either hasn't run yet, failed before producing output, or was cancelled. + /// + protected TOut TrainOutput => + Metadata is not null && Metadata.GetOutputObject() is TOut typed ? typed : default!; + /// /// Gets the canonical train name. Prefers the interface name set at registration time /// via AddScopedTraxRoute, falling back to the concrete type's FullName for diff --git a/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/TrainLifecycleOverrideTests.cs b/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/TrainLifecycleOverrideTests.cs index bbef6d3..51593f4 100644 --- a/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/TrainLifecycleOverrideTests.cs +++ b/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/TrainLifecycleOverrideTests.cs @@ -23,6 +23,8 @@ public override ServiceProvider ConfigureServices(IServiceCollection services) = .AddScopedTraxRoute() .AddScopedTraxRoute() .AddScopedTraxRoute() + .AddScopedTraxRoute() + .AddScopedTraxRoute() .BuildServiceProvider(); #region OnStarted @@ -288,6 +290,87 @@ public async Task Run_WithoutSaveTrainParameters_GetOutputObjectStillAvailable() #endregion + #region TypedAccess + + [Test] + public async Task Run_OnCompleted_TrainInputIsTyped() + { + var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService(); + + await train.Run("hello"); + + train.CapturedInput.Should().Be("hello"); + } + + [Test] + public async Task Run_OnCompleted_TrainOutputIsTyped() + { + var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService(); + + await train.Run("hello"); + + train.CapturedOutput.Should().NotBeNull(); + train.CapturedOutput!.Value.Should().Be("processed:hello"); + train.CapturedOutput!.Count.Should().Be(42); + } + + [Test] + public async Task Run_OnFailed_TrainInputIsTyped() + { + var train = (FailingTypedAccessTrain) + Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("hello"); + await act.Should().ThrowAsync(); + + train.CapturedInput.Should().Be("hello"); + } + + [Test] + public async Task Run_OnFailed_TrainOutputIsDefault() + { + var train = (FailingTypedAccessTrain) + Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("hello"); + await act.Should().ThrowAsync(); + + train.CapturedOutput.Should().BeNull(); + } + + [Test] + public async Task Run_MetadataGetInput_ReturnsTyped() + { + var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService(); + + await train.Run("hello"); + + train.CapturedMetadataInput.Should().Be("hello"); + } + + [Test] + public async Task Run_MetadataGetOutput_ReturnsTyped() + { + var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService(); + + await train.Run("hello"); + + train.CapturedMetadataOutput.Should().NotBeNull(); + train.CapturedMetadataOutput!.Value.Should().Be("processed:hello"); + } + + [Test] + public async Task Run_MetadataGetInput_WrongType_ReturnsDefault() + { + var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService(); + + await train.Run("hello"); + + train.CapturedWrongTypeInput.Should().Be(0); + } + + #endregion + #region Test Trains private interface IRecordingTrain : IServiceTrain { } @@ -510,5 +593,53 @@ protected override Task OnCompleted(Metadata metadata, CancellationToken ct) } } + private interface ITypedAccessTrain : IServiceTrain { } + + private class TypedAccessTrain : ServiceTrain, ITypedAccessTrain + { + public string? CapturedInput { get; private set; } + public TestOutputDto? CapturedOutput { get; private set; } + public string? CapturedMetadataInput { get; private set; } + public TestOutputDto? CapturedMetadataOutput { get; private set; } + public int CapturedWrongTypeInput { get; private set; } + + protected override async Task> RunInternal(string input) => + new TestOutputDto($"processed:{input}", 42); + + protected override Task OnCompleted(Metadata metadata, CancellationToken ct) + { + CapturedInput = TrainInput; + CapturedOutput = TrainOutput; + CapturedMetadataInput = metadata.GetInput(); + CapturedMetadataOutput = metadata.GetOutput(); + CapturedWrongTypeInput = metadata.GetInput(); + return Task.CompletedTask; + } + } + + private interface IFailingTypedAccessTrain : IServiceTrain { } + + private class FailingTypedAccessTrain + : ServiceTrain, + IFailingTypedAccessTrain + { + public string? CapturedInput { get; private set; } + public TestOutputDto? CapturedOutput { get; private set; } + + protected override async Task> RunInternal(string input) => + new TrainException("Intentional failure"); + + protected override Task OnFailed( + Metadata metadata, + Exception exception, + CancellationToken ct + ) + { + CapturedInput = TrainInput; + CapturedOutput = TrainOutput; + return Task.CompletedTask; + } + } + #endregion }