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
26 changes: 26 additions & 0 deletions src/Trax.Effect/Models/Metadata/Metadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,32 @@ public override string ToString() =>
/// </remarks>
public dynamic? GetOutputObject() => _outputObject;

/// <summary>
/// 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 (<c>OnStarted</c>, <c>OnCompleted</c>, <c>OnFailed</c>, <c>OnCancelled</c>).
/// Returns <c>default</c> if the input is null or not of type <typeparamref name="T"/>.
/// </summary>
/// <remarks>
/// For per-train lifecycle hooks, prefer the <c>TrainInput</c> property on
/// <c>ServiceTrain&lt;TIn, TOut&gt;</c> which provides the same value without
/// requiring a type parameter.
/// </remarks>
public T? GetInput<T>() => _inputObject is T typed ? typed : default;

/// <summary>
/// Gets the deserialized output object cast to the specified type.
/// The output is set after a successful run, so it is only meaningful in <c>OnCompleted</c>.
/// Returns <c>default</c> in <c>OnStarted</c>, <c>OnFailed</c>, and <c>OnCancelled</c>.
/// Returns <c>default</c> if the output is null or not of type <typeparamref name="T"/>.
/// </summary>
/// <remarks>
/// For per-train lifecycle hooks, prefer the <c>TrainOutput</c> property on
/// <c>ServiceTrain&lt;TIn, TOut&gt;</c> which provides the same value without
/// requiring a type parameter.
/// </remarks>
public T? GetOutput<T>() => _outputObject is T typed ? typed : default;

#endregion

/// <summary>
Expand Down
18 changes: 18 additions & 0 deletions src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ public abstract class ServiceTrain<TIn, TOut> : Train<TIn, TOut>, IServiceTrain<
[JsonIgnore]
public string? CanonicalName { get; set; }

/// <summary>
/// Gets the typed input that was passed to this train. Set before <see cref="RunInternal"/>
/// executes, so it is available in all lifecycle hooks: <see cref="OnStarted"/>,
/// <see cref="OnCompleted"/>, <see cref="OnFailed"/>, and <see cref="OnCancelled"/>.
/// Returns <c>default</c> before the train has been run.
/// </summary>
protected TIn TrainInput =>
Metadata is not null && Metadata.GetInputObject() is TIn typed ? typed : default!;

/// <summary>
/// Gets the typed output produced by this train. Set after a successful run, so it is
/// only meaningful in <see cref="OnCompleted"/>. Returns <c>default</c> in
/// <see cref="OnStarted"/>, <see cref="OnFailed"/>, and <see cref="OnCancelled"/>
/// because the train either hasn't run yet, failed before producing output, or was cancelled.
/// </summary>
protected TOut TrainOutput =>
Metadata is not null && Metadata.GetOutputObject() is TOut typed ? typed : default!;

/// <summary>
/// Gets the canonical train name. Prefers the interface name set at registration time
/// via <c>AddScopedTraxRoute</c>, falling back to the concrete type's FullName for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public override ServiceProvider ConfigureServices(IServiceCollection services) =
.AddScopedTraxRoute<IPartialOverrideTrain, PartialOverrideTrain>()
.AddScopedTraxRoute<INoOverrideTrain, NoOverrideTrain>()
.AddScopedTraxRoute<IOutputRecordingTrain, OutputRecordingTrain>()
.AddScopedTraxRoute<ITypedAccessTrain, TypedAccessTrain>()
.AddScopedTraxRoute<IFailingTypedAccessTrain, FailingTypedAccessTrain>()
.BuildServiceProvider();

#region OnStarted
Expand Down Expand Up @@ -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<ITypedAccessTrain>();

await train.Run("hello");

train.CapturedInput.Should().Be("hello");
}

[Test]
public async Task Run_OnCompleted_TrainOutputIsTyped()
{
var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService<ITypedAccessTrain>();

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<IFailingTypedAccessTrain>();

var act = async () => await train.Run("hello");
await act.Should().ThrowAsync<TrainException>();

train.CapturedInput.Should().Be("hello");
}

[Test]
public async Task Run_OnFailed_TrainOutputIsDefault()
{
var train = (FailingTypedAccessTrain)
Scope.ServiceProvider.GetRequiredService<IFailingTypedAccessTrain>();

var act = async () => await train.Run("hello");
await act.Should().ThrowAsync<TrainException>();

train.CapturedOutput.Should().BeNull();
}

[Test]
public async Task Run_MetadataGetInput_ReturnsTyped()
{
var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService<ITypedAccessTrain>();

await train.Run("hello");

train.CapturedMetadataInput.Should().Be("hello");
}

[Test]
public async Task Run_MetadataGetOutput_ReturnsTyped()
{
var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService<ITypedAccessTrain>();

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<ITypedAccessTrain>();

await train.Run("hello");

train.CapturedWrongTypeInput.Should().Be(0);
}

#endregion

#region Test Trains

private interface IRecordingTrain : IServiceTrain<Unit, Unit> { }
Expand Down Expand Up @@ -510,5 +593,53 @@ protected override Task OnCompleted(Metadata metadata, CancellationToken ct)
}
}

private interface ITypedAccessTrain : IServiceTrain<string, TestOutputDto> { }

private class TypedAccessTrain : ServiceTrain<string, TestOutputDto>, 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<Either<Exception, TestOutputDto>> RunInternal(string input) =>
new TestOutputDto($"processed:{input}", 42);

protected override Task OnCompleted(Metadata metadata, CancellationToken ct)
{
CapturedInput = TrainInput;
CapturedOutput = TrainOutput;
CapturedMetadataInput = metadata.GetInput<string>();
CapturedMetadataOutput = metadata.GetOutput<TestOutputDto>();
CapturedWrongTypeInput = metadata.GetInput<int>();
return Task.CompletedTask;
}
}

private interface IFailingTypedAccessTrain : IServiceTrain<string, TestOutputDto> { }

private class FailingTypedAccessTrain
: ServiceTrain<string, TestOutputDto>,
IFailingTypedAccessTrain
{
public string? CapturedInput { get; private set; }
public TestOutputDto? CapturedOutput { get; private set; }

protected override async Task<Either<Exception, TestOutputDto>> 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
}
Loading