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
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using DxWorks.ScriptBee.Analysis.Sdk.Results;
using NSubstitute;
using ScriptBee.Common;
using ScriptBee.Domain.Model.Analysis;
using ScriptBee.Domain.Model.File;

namespace DxWorks.ScriptBee.Analysis.Sdk.Tests.Results;

public class AnalysisResultServiceTests
{
private static readonly Guid FixedGuid = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly DateTimeOffset FixedDate = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);

private readonly IScriptResultsStore _store = Substitute.For<IScriptResultsStore>();
private readonly IGuidProvider _guidProvider = Substitute.For<IGuidProvider>();
private readonly IDateTimeProvider _dateTimeProvider = Substitute.For<IDateTimeProvider>();
private readonly List<ResultSummary> _recordedSummaries = [];

private readonly AnalysisResultService _service;

public AnalysisResultServiceTests()
{
_guidProvider.NewGuid().Returns(FixedGuid);
_dateTimeProvider.UtcNow().Returns(FixedDate);

_service = new AnalysisResultService(
_store,
_guidProvider,
_dateTimeProvider,
_recordedSummaries.Add
);
}

[Fact]
public async Task AddFileAsync_WithStreamContent_UploadsToStoreAndNotifies()
{
// Arrange
await using var content = new MemoryStream("file content"u8.ToArray());

// Act
var resultId = await _service.AddFileAsync(
"report.txt",
content,
cancellationToken: TestContext.Current.CancellationToken
);

// Assert
Assert.Equal(new ResultId(FixedGuid), resultId);
await _store
.Received(1)
.UploadFileAsync<object>(
new FileId(FixedGuid),
content,
null,
Arg.Any<CancellationToken>()
);
var summary = Assert.Single(_recordedSummaries);
Assert.Equal("report.txt", summary.Name);
Assert.Equal(RunResultTypes.File, summary.Type);
Assert.Equal(FixedDate, summary.CreationDate);
}

[Fact]
public async Task AddFileAsync_WithStringContent_NotifiesWithCorrectMetadata()
{
// Act
var resultId = await _service.AddFileAsync(
"output.txt",
"hello world",
cancellationToken: TestContext.Current.CancellationToken
);

// Assert
Assert.Equal(new ResultId(FixedGuid), resultId);
await _store
.Received(1)
.UploadFileAsync<object>(
new FileId(FixedGuid),
Arg.Any<Stream>(),
null,
Arg.Any<CancellationToken>()
);
var summary = Assert.Single(_recordedSummaries);
Assert.Equal("output.txt", summary.Name);
Assert.Equal(RunResultTypes.File, summary.Type);
}

[Fact]
public async Task AddConsoleAsync_UploadsWithConsoleTypeAndDefaultName()
{
// Act
var resultId = await _service.AddConsoleAsync(
"log output",
cancellationToken: TestContext.Current.CancellationToken
);

// Assert
Assert.Equal(new ResultId(FixedGuid), resultId);
var summary = Assert.Single(_recordedSummaries);
Assert.Equal("ConsoleOutput", summary.Name);
Assert.Equal(RunResultTypes.Console, summary.Type);
}

[Fact]
public async Task AddErrorAsync_UploadsWithRunErrorTypeAndDefaultName()
{
// Act
var resultId = await _service.AddErrorAsync(
"something failed",
cancellationToken: TestContext.Current.CancellationToken
);

// Assert
Assert.Equal(new ResultId(FixedGuid), resultId);
var summary = Assert.Single(_recordedSummaries);
Assert.Equal("RunError", summary.Name);
Assert.Equal(RunResultTypes.RunError, summary.Type);
}

[Fact]
public async Task AddResultAsync_WithCustomType_NotifiesWithCorrectSummary()
{
// Arrange
await using var content = new MemoryStream("chart data"u8.ToArray());

// Act
await _service.AddResultAsync(
"MyChart",
"Chart",
content,
TestContext.Current.CancellationToken
);

// Assert
var summary = Assert.Single(_recordedSummaries);
Assert.Equal("MyChart", summary.Name);
Assert.Equal("Chart", summary.Type);
Assert.Equal(FixedDate, summary.CreationDate);
}

[Fact]
public async Task AddResultAsync_WithoutCallback_DoesNotThrow()
{
// Arrange
var serviceWithoutCallback = new AnalysisResultService(
_store,
_guidProvider,
_dateTimeProvider
);
await using var content = new MemoryStream("data"u8.ToArray());

// Act
var exception = await Record.ExceptionAsync(() =>
serviceWithoutCallback.AddResultAsync(
"name",
RunResultTypes.File,
content,
TestContext.Current.CancellationToken
)
);

// Assert
Assert.Null(exception);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

<ItemGroup>
<ProjectReference Include="..\..\..\..\ScriptBeeWebApp\src\Common\Model\Model.csproj" />
<ProjectReference Include="..\..\..\..\ScriptBeeWebApp\src\Common\Common\Common.csproj" />
<ProjectReference Include="..\..\..\..\ScriptBeeWebApp\src\Common\CodeGeneration\CodeGeneration.csproj" />
<ProjectReference Include="..\..\..\..\ScriptBeeWebApp\src\Common\Common.Web\Common.Web.csproj" />
<ProjectReference Include="..\..\..\..\ScriptBeeWebApp\src\Common\Persistence.Mongodb.Abstractions\Persistence.Mongodb.Abstractions.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ private IServiceCollection AddServices()
.AddSingleton<IScriptLoader, ScriptLoader>()
.AddSingleton<IAnalysisState, AnalysisState>()
.AddSingleton<IScriptResultsStore, ScriptResultsStore>()
.AddSingleton<IAnalysisResultService, AnalysisResultService>()
.AddSingleton<IModelFileLoader, ModelFileLoader>()
.AddSingleton<FileBundler>();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Text;
using ScriptBee.Common;
using ScriptBee.Domain.Model.Analysis;

namespace DxWorks.ScriptBee.Analysis.Sdk.Results;

public sealed class AnalysisResultService(
IScriptResultsStore store,
IGuidProvider guidProvider,
IDateTimeProvider dateTimeProvider,
Action<ResultSummary>? onResultAdded = null
) : IAnalysisResultService
{
public Task<ResultId> AddFileAsync(
string name,
Stream content,
string resultType = RunResultTypes.File,
CancellationToken cancellationToken = default
) => AddResultAsync(name, resultType, content, cancellationToken);

public async Task<ResultId> AddFileAsync(
string name,
string content,
string resultType = RunResultTypes.File,
CancellationToken cancellationToken = default
)
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
return await AddResultAsync(name, resultType, stream, cancellationToken);
}

public async Task<ResultId> AddConsoleAsync(
string content,
string name = "ConsoleOutput",
CancellationToken cancellationToken = default
)
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
return await AddResultAsync(name, RunResultTypes.Console, stream, cancellationToken);
}

public async Task<ResultId> AddErrorAsync(
string message,
string name = "RunError",
CancellationToken cancellationToken = default
)
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(message));
return await AddResultAsync(name, RunResultTypes.RunError, stream, cancellationToken);
}

public async Task<ResultId> AddResultAsync(
string name,
string resultType,
Stream content,
CancellationToken cancellationToken = default
)
{
var resultId = new ResultId(guidProvider.NewGuid());

await store.UploadFileAsync<object>(resultId.ToFileId(), content, null, cancellationToken);

onResultAdded?.Invoke(
new ResultSummary(resultId, name, resultType, dateTimeProvider.UtcNow())
);

return resultId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using ScriptBee.Domain.Model.Analysis;

namespace DxWorks.ScriptBee.Analysis.Sdk.Results;

public interface IAnalysisResultService
{
Task<ResultId> AddFileAsync(
string name,
Stream content,
string resultType = RunResultTypes.File,
CancellationToken cancellationToken = default
);

Task<ResultId> AddFileAsync(
string name,
string content,
string resultType = RunResultTypes.File,
CancellationToken cancellationToken = default
);

Task<ResultId> AddConsoleAsync(
string content,
string name = "ConsoleOutput",
CancellationToken cancellationToken = default
);

Task<ResultId> AddErrorAsync(
string message,
string name = "RunError",
CancellationToken cancellationToken = default
);

Task<ResultId> AddResultAsync(
string name,
string resultType,
Stream content,
CancellationToken cancellationToken = default
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace DxWorks.ScriptBee.Analysis.Sdk.Results;

public static class RunResultTypes
{
public const string File = "File";
public const string Console = "Console";
public const string RunError = "RunError";
}
2 changes: 2 additions & 0 deletions analysis.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ COPY ScriptBeeWebApp/src/Plugins ScriptBeeWebApp/src/Plugins
COPY ScriptBeeWebApp/src/Analysis/Application ScriptBeeWebApp/src/Analysis/Application
COPY ScriptBeeWebApp/src/Analysis/Adapters ScriptBeeWebApp/src/Analysis/Adapters

COPY Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk

RUN dotnet restore ScriptBeeWebApp/src/Analysis/Adapters/Analysis.Web/Analysis.Web.csproj

RUN dotnet publish ScriptBeeWebApp/src/Analysis/Adapters/Analysis.Web/Analysis.Web.csproj -c Release -o publish --no-restore
Expand Down
64 changes: 64 additions & 0 deletions docs/sdks/csharp_analysis_service_sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,70 @@ app.Run();
`AddAnalysisEndpoints()` registers all FluentValidation validators and maps every endpoint to
`IEndpointRouteBuilder` automatically.

### `IAnalysisResultService`

The high-level result service lets your analysis logic emit typed results (files, console output, errors,
or custom types) without dealing with `FileId`s, GUIDs, or stream management directly.

```csharp
public interface IAnalysisResultService
{
Task<ResultId> AddFileAsync(
string name, Stream content,
string resultType = RunResultTypes.File,
CancellationToken cancellationToken = default);

Task<ResultId> AddFileAsync(
string name, string content,
string resultType = RunResultTypes.File,
CancellationToken cancellationToken = default);

Task<ResultId> AddConsoleAsync(
string content, string name = "ConsoleOutput",
CancellationToken cancellationToken = default);

Task<ResultId> AddErrorAsync(
string message, string name = "RunError",
CancellationToken cancellationToken = default);

Task<ResultId> AddResultAsync(
string name, string resultType, Stream content,
CancellationToken cancellationToken = default);
}
```

Inject `IAnalysisResultService` into your use-case implementations and use the ergonomic helpers:

```csharp
public sealed class MyRunAnalysisUseCase(IAnalysisResultService results) : IRunAnalysisUseCase
{
public async Task RunAsync(CancellationToken cancellationToken)
{
await results.AddConsoleAsync("Analysis started", cancellationToken: cancellationToken);

await results.AddFileAsync(
"summary.json",
"""{"count": 42}""",
cancellationToken: cancellationToken
);

await using var csvStream = BuildCsvReport();
await results.AddFileAsync("report.csv", csvStream, cancellationToken: cancellationToken);
}
}
```

The well-known type constants are available on `RunResultTypes`:

| Constant | Value |
| :------------------------ | :----------- |
| `RunResultTypes.File` | `"File"` |
| `RunResultTypes.Console` | `"Console"` |
| `RunResultTypes.RunError` | `"RunError"` |

You can also pass any custom string as `resultType` when calling `AddFileAsync` or `AddResultAsync`
to define your own result categories.

## Versioning & Changelog

The SDK follows [Semantic Versioning](https://semver.org/). Releases are tagged `analysis-sdk@<version>`
Expand Down
Loading
Loading