up
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.ExportCenter.Client.Models;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client implementation for the ExportCenter WebService API.
|
||||
/// </summary>
|
||||
public sealed class ExportCenterClient : IExportCenterClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ExportCenterClient with the specified HttpClient.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">HTTP client instance.</param>
|
||||
public ExportCenterClient(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ExportCenterClient with the specified options.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">HTTP client instance.</param>
|
||||
/// <param name="options">Client options.</param>
|
||||
public ExportCenterClient(HttpClient httpClient, IOptions<ExportCenterClientOptions> options)
|
||||
: this(httpClient)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var opts = options.Value;
|
||||
_httpClient.BaseAddress = new Uri(opts.BaseUrl);
|
||||
_httpClient.Timeout = opts.Timeout;
|
||||
}
|
||||
|
||||
#region Discovery
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpenApiDiscoveryMetadata> GetDiscoveryMetadataAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _httpClient.GetAsync("/.well-known/openapi", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var metadata = await response.Content.ReadFromJsonAsync<OpenApiDiscoveryMetadata>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return metadata ?? throw new InvalidOperationException("Invalid discovery metadata response.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Profiles
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExportProfileListResponse> ListProfilesAsync(
|
||||
string? continuationToken = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = "/v1/exports/profiles";
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(continuationToken))
|
||||
{
|
||||
queryParams.Add($"continuationToken={Uri.EscapeDataString(continuationToken)}");
|
||||
}
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={limit.Value}");
|
||||
}
|
||||
|
||||
if (queryParams.Count > 0)
|
||||
{
|
||||
url += "?" + string.Join("&", queryParams);
|
||||
}
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ExportProfileListResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExportProfileListResponse([], null, false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExportProfile?> GetProfileAsync(
|
||||
string profileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(profileId);
|
||||
|
||||
var response = await _httpClient.GetAsync($"/v1/exports/profiles/{Uri.EscapeDataString(profileId)}", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ExportProfile>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Runs
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExportRunListResponse> ListRunsAsync(
|
||||
string? profileId = null,
|
||||
string? continuationToken = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = "/v1/exports/runs";
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(profileId))
|
||||
{
|
||||
queryParams.Add($"profileId={Uri.EscapeDataString(profileId)}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(continuationToken))
|
||||
{
|
||||
queryParams.Add($"continuationToken={Uri.EscapeDataString(continuationToken)}");
|
||||
}
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={limit.Value}");
|
||||
}
|
||||
|
||||
if (queryParams.Count > 0)
|
||||
{
|
||||
url += "?" + string.Join("&", queryParams);
|
||||
}
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ExportRunListResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExportRunListResponse([], null, false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExportRun?> GetRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var response = await _httpClient.GetAsync($"/v1/exports/runs/{Uri.EscapeDataString(runId)}", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ExportRun>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Evidence Exports
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CreateEvidenceExportResponse> CreateEvidenceExportAsync(
|
||||
CreateEvidenceExportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("/v1/exports/evidence", request, JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<CreateEvidenceExportResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Invalid evidence export response.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EvidenceExportStatus?> GetEvidenceExportStatusAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var response = await _httpClient.GetAsync($"/v1/exports/evidence/{Uri.EscapeDataString(runId)}/status", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<EvidenceExportStatus>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream?> DownloadEvidenceExportAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var response = await _httpClient.GetAsync(
|
||||
$"/v1/exports/evidence/{Uri.EscapeDataString(runId)}/download",
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound ||
|
||||
response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Attestation Exports
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CreateAttestationExportResponse> CreateAttestationExportAsync(
|
||||
CreateAttestationExportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("/v1/exports/attestations", request, JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<CreateAttestationExportResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Invalid attestation export response.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationExportStatus?> GetAttestationExportStatusAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var response = await _httpClient.GetAsync($"/v1/exports/attestations/{Uri.EscapeDataString(runId)}/status", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<AttestationExportStatus>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream?> DownloadAttestationExportAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var response = await _httpClient.GetAsync(
|
||||
$"/v1/exports/attestations/{Uri.EscapeDataString(runId)}/download",
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound ||
|
||||
response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.ExportCenter.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the ExportCenter client.
|
||||
/// </summary>
|
||||
public sealed class ExportCenterClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URL for the ExportCenter API.
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "https://localhost:5001";
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for HTTP requests.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for streaming downloads.
|
||||
/// </summary>
|
||||
public TimeSpan DownloadTimeout { get; set; } = TimeSpan.FromMinutes(10);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring ExportCenter client services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the ExportCenter client to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Action to configure client options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddExportCenterClient(
|
||||
this IServiceCollection services,
|
||||
Action<ExportCenterClientOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.Configure(configureOptions);
|
||||
|
||||
services.AddHttpClient<IExportCenterClient, ExportCenterClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ExportCenterClientOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
client.Timeout = options.Timeout;
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the ExportCenter client to the service collection with a named HttpClient.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="name">HttpClient name.</param>
|
||||
/// <param name="configureOptions">Action to configure client options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddExportCenterClient(
|
||||
this IServiceCollection services,
|
||||
string name,
|
||||
Action<ExportCenterClientOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.Configure(name, configureOptions);
|
||||
|
||||
services.AddHttpClient<IExportCenterClient, ExportCenterClient>(name, (sp, client) =>
|
||||
{
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<ExportCenterClientOptions>>();
|
||||
var options = optionsMonitor.Get(name);
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
client.Timeout = options.Timeout;
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the ExportCenter client with custom HttpClient configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Action to configure client options.</param>
|
||||
/// <param name="configureClient">Additional HttpClient configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddExportCenterClient(
|
||||
this IServiceCollection services,
|
||||
Action<ExportCenterClientOptions> configureOptions,
|
||||
Action<HttpClient> configureClient)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
ArgumentNullException.ThrowIfNull(configureClient);
|
||||
|
||||
services.Configure(configureOptions);
|
||||
|
||||
services.AddHttpClient<IExportCenterClient, ExportCenterClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ExportCenterClientOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
client.Timeout = options.Timeout;
|
||||
configureClient(client);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using StellaOps.ExportCenter.Client.Models;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for the ExportCenter WebService API.
|
||||
/// </summary>
|
||||
public interface IExportCenterClient
|
||||
{
|
||||
#region Discovery
|
||||
|
||||
/// <summary>
|
||||
/// Gets OpenAPI discovery metadata.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>OpenAPI discovery metadata.</returns>
|
||||
Task<OpenApiDiscoveryMetadata> GetDiscoveryMetadataAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Profiles
|
||||
|
||||
/// <summary>
|
||||
/// Lists export profiles.
|
||||
/// </summary>
|
||||
/// <param name="continuationToken">Continuation token for pagination.</param>
|
||||
/// <param name="limit">Maximum number of profiles to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated list of export profiles.</returns>
|
||||
Task<ExportProfileListResponse> ListProfilesAsync(
|
||||
string? continuationToken = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific export profile by ID.
|
||||
/// </summary>
|
||||
/// <param name="profileId">Profile identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Export profile or null if not found.</returns>
|
||||
Task<ExportProfile?> GetProfileAsync(
|
||||
string profileId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Runs
|
||||
|
||||
/// <summary>
|
||||
/// Lists export runs, optionally filtered by profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">Optional profile ID filter.</param>
|
||||
/// <param name="continuationToken">Continuation token for pagination.</param>
|
||||
/// <param name="limit">Maximum number of runs to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated list of export runs.</returns>
|
||||
Task<ExportRunListResponse> ListRunsAsync(
|
||||
string? profileId = null,
|
||||
string? continuationToken = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific export run by ID.
|
||||
/// </summary>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Export run or null if not found.</returns>
|
||||
Task<ExportRun?> GetRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Evidence Exports
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new evidence export job.
|
||||
/// </summary>
|
||||
/// <param name="request">Export creation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Export creation response.</returns>
|
||||
Task<CreateEvidenceExportResponse> CreateEvidenceExportAsync(
|
||||
CreateEvidenceExportRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of an evidence export job.
|
||||
/// </summary>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Evidence export status or null if not found.</returns>
|
||||
Task<EvidenceExportStatus?> GetEvidenceExportStatusAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads an evidence export bundle as a stream.
|
||||
/// </summary>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Stream containing the bundle, or null if not ready/found.</returns>
|
||||
Task<Stream?> DownloadEvidenceExportAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Attestation Exports
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new attestation export job.
|
||||
/// </summary>
|
||||
/// <param name="request">Export creation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Export creation response.</returns>
|
||||
Task<CreateAttestationExportResponse> CreateAttestationExportAsync(
|
||||
CreateAttestationExportRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of an attestation export job.
|
||||
/// </summary>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Attestation export status or null if not found.</returns>
|
||||
Task<AttestationExportStatus?> GetAttestationExportStatusAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads an attestation export bundle as a stream.
|
||||
/// </summary>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Stream containing the bundle, or null if not ready/found.</returns>
|
||||
Task<Stream?> DownloadAttestationExportAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using StellaOps.ExportCenter.Client.Models;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for export job lifecycle operations.
|
||||
/// </summary>
|
||||
public static class ExportJobLifecycleHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Terminal statuses for export jobs.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlySet<string> TerminalStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a status is terminal (export job has finished).
|
||||
/// </summary>
|
||||
/// <param name="status">Status to check.</param>
|
||||
/// <returns>True if terminal status.</returns>
|
||||
public static bool IsTerminalStatus(string status)
|
||||
=> TerminalStatuses.Contains(status);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an evidence export and waits for completion.
|
||||
/// </summary>
|
||||
/// <param name="client">ExportCenter client.</param>
|
||||
/// <param name="request">Export creation request.</param>
|
||||
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
|
||||
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Final evidence export status.</returns>
|
||||
public static async Task<EvidenceExportStatus> CreateEvidenceExportAndWaitAsync(
|
||||
IExportCenterClient client,
|
||||
CreateEvidenceExportRequest request,
|
||||
TimeSpan? pollInterval = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var createResponse = await client.CreateEvidenceExportAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await WaitForEvidenceExportCompletionAsync(
|
||||
client, createResponse.RunId, pollInterval, timeout, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for an evidence export to complete.
|
||||
/// </summary>
|
||||
/// <param name="client">ExportCenter client.</param>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
|
||||
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Final evidence export status.</returns>
|
||||
public static async Task<EvidenceExportStatus> WaitForEvidenceExportCompletionAsync(
|
||||
IExportCenterClient client,
|
||||
string runId,
|
||||
TimeSpan? pollInterval = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
|
||||
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(maxWait);
|
||||
|
||||
while (true)
|
||||
{
|
||||
var status = await client.GetEvidenceExportStatusAsync(runId, cts.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Evidence export '{runId}' not found.");
|
||||
}
|
||||
|
||||
if (IsTerminalStatus(status.Status))
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an attestation export and waits for completion.
|
||||
/// </summary>
|
||||
/// <param name="client">ExportCenter client.</param>
|
||||
/// <param name="request">Export creation request.</param>
|
||||
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
|
||||
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Final attestation export status.</returns>
|
||||
public static async Task<AttestationExportStatus> CreateAttestationExportAndWaitAsync(
|
||||
IExportCenterClient client,
|
||||
CreateAttestationExportRequest request,
|
||||
TimeSpan? pollInterval = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var createResponse = await client.CreateAttestationExportAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await WaitForAttestationExportCompletionAsync(
|
||||
client, createResponse.RunId, pollInterval, timeout, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for an attestation export to complete.
|
||||
/// </summary>
|
||||
/// <param name="client">ExportCenter client.</param>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
|
||||
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Final attestation export status.</returns>
|
||||
public static async Task<AttestationExportStatus> WaitForAttestationExportCompletionAsync(
|
||||
IExportCenterClient client,
|
||||
string runId,
|
||||
TimeSpan? pollInterval = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
|
||||
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(maxWait);
|
||||
|
||||
while (true)
|
||||
{
|
||||
var status = await client.GetAttestationExportStatusAsync(runId, cts.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Attestation export '{runId}' not found.");
|
||||
}
|
||||
|
||||
if (IsTerminalStatus(status.Status))
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an evidence export, waits for completion, and downloads the bundle.
|
||||
/// </summary>
|
||||
/// <param name="client">ExportCenter client.</param>
|
||||
/// <param name="request">Export creation request.</param>
|
||||
/// <param name="outputPath">Path to save the downloaded bundle.</param>
|
||||
/// <param name="pollInterval">Interval between status checks.</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Final evidence export status.</returns>
|
||||
public static async Task<EvidenceExportStatus> CreateEvidenceExportAndDownloadAsync(
|
||||
IExportCenterClient client,
|
||||
CreateEvidenceExportRequest request,
|
||||
string outputPath,
|
||||
TimeSpan? pollInterval = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
|
||||
var status = await CreateEvidenceExportAndWaitAsync(client, request, pollInterval, timeout, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (status.Status != "completed")
|
||||
{
|
||||
throw new InvalidOperationException($"Evidence export failed: {status.ErrorCode} - {status.ErrorMessage}");
|
||||
}
|
||||
|
||||
await using var stream = await client.DownloadEvidenceExportAsync(status.RunId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (stream is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Evidence export bundle not available for download.");
|
||||
}
|
||||
|
||||
await using var fileStream = File.Create(outputPath);
|
||||
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an attestation export, waits for completion, and downloads the bundle.
|
||||
/// </summary>
|
||||
/// <param name="client">ExportCenter client.</param>
|
||||
/// <param name="request">Export creation request.</param>
|
||||
/// <param name="outputPath">Path to save the downloaded bundle.</param>
|
||||
/// <param name="pollInterval">Interval between status checks.</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Final attestation export status.</returns>
|
||||
public static async Task<AttestationExportStatus> CreateAttestationExportAndDownloadAsync(
|
||||
IExportCenterClient client,
|
||||
CreateAttestationExportRequest request,
|
||||
string outputPath,
|
||||
TimeSpan? pollInterval = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
|
||||
var status = await CreateAttestationExportAndWaitAsync(client, request, pollInterval, timeout, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (status.Status != "completed")
|
||||
{
|
||||
throw new InvalidOperationException($"Attestation export failed: {status.ErrorCode} - {status.ErrorMessage}");
|
||||
}
|
||||
|
||||
await using var stream = await client.DownloadAttestationExportAsync(status.RunId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (stream is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Attestation export bundle not available for download.");
|
||||
}
|
||||
|
||||
await using var fileStream = File.Create(outputPath);
|
||||
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Export profile metadata.
|
||||
/// </summary>
|
||||
public sealed record ExportProfile(
|
||||
[property: JsonPropertyName("profileId")] string ProfileId,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("description")] string? Description,
|
||||
[property: JsonPropertyName("adapter")] string Adapter,
|
||||
[property: JsonPropertyName("selectors")] IReadOnlyDictionary<string, string>? Selectors,
|
||||
[property: JsonPropertyName("outputFormat")] string OutputFormat,
|
||||
[property: JsonPropertyName("signingEnabled")] bool SigningEnabled,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("updatedAt")] DateTimeOffset? UpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Paginated list of export profiles.
|
||||
/// </summary>
|
||||
public sealed record ExportProfileListResponse(
|
||||
[property: JsonPropertyName("profiles")] IReadOnlyList<ExportProfile> Profiles,
|
||||
[property: JsonPropertyName("continuationToken")] string? ContinuationToken,
|
||||
[property: JsonPropertyName("hasMore")] bool HasMore);
|
||||
|
||||
/// <summary>
|
||||
/// Export run representing a single export job execution.
|
||||
/// </summary>
|
||||
public sealed record ExportRun(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("profileId")] string ProfileId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("progress")] int? Progress,
|
||||
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
|
||||
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
|
||||
[property: JsonPropertyName("bundleHash")] string? BundleHash,
|
||||
[property: JsonPropertyName("bundleUrl")] string? BundleUrl,
|
||||
[property: JsonPropertyName("errorCode")] string? ErrorCode,
|
||||
[property: JsonPropertyName("errorMessage")] string? ErrorMessage,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Paginated list of export runs.
|
||||
/// </summary>
|
||||
public sealed record ExportRunListResponse(
|
||||
[property: JsonPropertyName("runs")] IReadOnlyList<ExportRun> Runs,
|
||||
[property: JsonPropertyName("continuationToken")] string? ContinuationToken,
|
||||
[property: JsonPropertyName("hasMore")] bool HasMore);
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new evidence export.
|
||||
/// </summary>
|
||||
public sealed record CreateEvidenceExportRequest(
|
||||
[property: JsonPropertyName("profileId")] string ProfileId,
|
||||
[property: JsonPropertyName("selectors")] IReadOnlyDictionary<string, string>? Selectors = null,
|
||||
[property: JsonPropertyName("callbackUrl")] string? CallbackUrl = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response from creating an evidence export.
|
||||
/// </summary>
|
||||
public sealed record CreateEvidenceExportResponse(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("statusUrl")] string StatusUrl,
|
||||
[property: JsonPropertyName("estimatedCompletionSeconds")] int? EstimatedCompletionSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Status of an evidence export.
|
||||
/// </summary>
|
||||
public sealed record EvidenceExportStatus(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("profileId")] string ProfileId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("progress")] int Progress,
|
||||
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
|
||||
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
|
||||
[property: JsonPropertyName("bundleHash")] string? BundleHash,
|
||||
[property: JsonPropertyName("downloadUrl")] string? DownloadUrl,
|
||||
[property: JsonPropertyName("errorCode")] string? ErrorCode,
|
||||
[property: JsonPropertyName("errorMessage")] string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new attestation export.
|
||||
/// </summary>
|
||||
public sealed record CreateAttestationExportRequest(
|
||||
[property: JsonPropertyName("profileId")] string ProfileId,
|
||||
[property: JsonPropertyName("selectors")] IReadOnlyDictionary<string, string>? Selectors = null,
|
||||
[property: JsonPropertyName("includeTransparencyLog")] bool IncludeTransparencyLog = true,
|
||||
[property: JsonPropertyName("callbackUrl")] string? CallbackUrl = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response from creating an attestation export.
|
||||
/// </summary>
|
||||
public sealed record CreateAttestationExportResponse(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("statusUrl")] string StatusUrl,
|
||||
[property: JsonPropertyName("estimatedCompletionSeconds")] int? EstimatedCompletionSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Status of an attestation export.
|
||||
/// </summary>
|
||||
public sealed record AttestationExportStatus(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("profileId")] string ProfileId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("progress")] int Progress,
|
||||
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
|
||||
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
|
||||
[property: JsonPropertyName("bundleHash")] string? BundleHash,
|
||||
[property: JsonPropertyName("downloadUrl")] string? DownloadUrl,
|
||||
[property: JsonPropertyName("transparencyLogIncluded")] bool TransparencyLogIncluded,
|
||||
[property: JsonPropertyName("errorCode")] string? ErrorCode,
|
||||
[property: JsonPropertyName("errorMessage")] string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// OpenAPI discovery metadata.
|
||||
/// </summary>
|
||||
public sealed record OpenApiDiscoveryMetadata(
|
||||
[property: JsonPropertyName("service")] string Service,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("specVersion")] string SpecVersion,
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("jsonUrl")] string? JsonUrl,
|
||||
[property: JsonPropertyName("errorEnvelopeSchema")] string ErrorEnvelopeSchema,
|
||||
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
|
||||
[property: JsonPropertyName("profilesSupported")] IReadOnlyList<string>? ProfilesSupported,
|
||||
[property: JsonPropertyName("checksumSha256")] string? ChecksumSha256);
|
||||
|
||||
/// <summary>
|
||||
/// Standard error envelope.
|
||||
/// </summary>
|
||||
public sealed record ErrorEnvelope(
|
||||
[property: JsonPropertyName("error")] ErrorDetail Error);
|
||||
|
||||
/// <summary>
|
||||
/// Error detail within an error envelope.
|
||||
/// </summary>
|
||||
public sealed record ErrorDetail(
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("message")] string Message,
|
||||
[property: JsonPropertyName("correlationId")] string? CorrelationId = null,
|
||||
[property: JsonPropertyName("details")] IReadOnlyList<ErrorDetailItem>? Details = null);
|
||||
|
||||
/// <summary>
|
||||
/// Individual error detail item.
|
||||
/// </summary>
|
||||
public sealed record ErrorDetailItem(
|
||||
[property: JsonPropertyName("field")] string? Field,
|
||||
[property: JsonPropertyName("reason")] string Reason);
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Description>SDK client for StellaOps ExportCenter WebService API</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for streaming export bundle downloads.
|
||||
/// </summary>
|
||||
public static class ExportDownloadHelper
|
||||
{
|
||||
private const int DefaultBufferSize = 81920; // 80 KB
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a stream to a file with progress reporting.
|
||||
/// </summary>
|
||||
/// <param name="stream">Source stream.</param>
|
||||
/// <param name="outputPath">Destination file path.</param>
|
||||
/// <param name="expectedLength">Expected content length (if known).</param>
|
||||
/// <param name="progressCallback">Progress callback (bytes downloaded, total bytes or null).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Total bytes downloaded.</returns>
|
||||
public static async Task<long> DownloadToFileAsync(
|
||||
Stream stream,
|
||||
string outputPath,
|
||||
long? expectedLength = null,
|
||||
Action<long, long?>? progressCallback = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
|
||||
await using var fileStream = File.Create(outputPath);
|
||||
return await CopyWithProgressAsync(stream, fileStream, expectedLength, progressCallback, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a stream to a file and verifies SHA-256 checksum.
|
||||
/// </summary>
|
||||
/// <param name="stream">Source stream.</param>
|
||||
/// <param name="outputPath">Destination file path.</param>
|
||||
/// <param name="expectedSha256">Expected SHA-256 hash (hex string).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Actual SHA-256 hash of the downloaded file.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if checksum doesn't match.</exception>
|
||||
public static async Task<string> DownloadAndVerifyAsync(
|
||||
Stream stream,
|
||||
string outputPath,
|
||||
string expectedSha256,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(expectedSha256);
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
|
||||
await using var fileStream = File.Create(outputPath);
|
||||
await using var cryptoStream = new CryptoStream(fileStream, sha256, CryptoStreamMode.Write);
|
||||
|
||||
var buffer = new byte[DefaultBufferSize];
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
await cryptoStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await cryptoStream.FlushFinalBlockAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var actualHash = Convert.ToHexString(sha256.Hash!).ToLowerInvariant();
|
||||
var expectedNormalized = expectedSha256.ToLowerInvariant().Replace("sha256:", "");
|
||||
|
||||
if (!string.Equals(actualHash, expectedNormalized, StringComparison.Ordinal))
|
||||
{
|
||||
// Delete the corrupted file
|
||||
File.Delete(outputPath);
|
||||
throw new InvalidOperationException(
|
||||
$"Checksum verification failed. Expected: {expectedNormalized}, Actual: {actualHash}");
|
||||
}
|
||||
|
||||
return actualHash;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes SHA-256 hash of a stream.
|
||||
/// </summary>
|
||||
/// <param name="stream">Source stream.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>SHA-256 hash as hex string.</returns>
|
||||
public static async Task<string> ComputeSha256Async(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = await sha256.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies a stream with progress reporting.
|
||||
/// </summary>
|
||||
/// <param name="source">Source stream.</param>
|
||||
/// <param name="destination">Destination stream.</param>
|
||||
/// <param name="expectedLength">Expected content length (if known).</param>
|
||||
/// <param name="progressCallback">Progress callback (bytes copied, total bytes or null).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Total bytes copied.</returns>
|
||||
public static async Task<long> CopyWithProgressAsync(
|
||||
Stream source,
|
||||
Stream destination,
|
||||
long? expectedLength = null,
|
||||
Action<long, long?>? progressCallback = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
|
||||
var buffer = new byte[DefaultBufferSize];
|
||||
long totalBytes = 0;
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
|
||||
totalBytes += bytesRead;
|
||||
progressCallback?.Invoke(totalBytes, expectedLength);
|
||||
}
|
||||
|
||||
return totalBytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a progress callback that logs progress at specified intervals.
|
||||
/// </summary>
|
||||
/// <param name="logAction">Action to invoke with progress message.</param>
|
||||
/// <param name="reportIntervalBytes">Minimum bytes between progress reports (default: 1 MB).</param>
|
||||
/// <returns>Progress callback action.</returns>
|
||||
public static Action<long, long?> CreateProgressLogger(
|
||||
Action<string> logAction,
|
||||
long reportIntervalBytes = 1_048_576)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logAction);
|
||||
|
||||
long lastReportedBytes = 0;
|
||||
|
||||
return (bytesDownloaded, totalBytes) =>
|
||||
{
|
||||
if (bytesDownloaded - lastReportedBytes >= reportIntervalBytes)
|
||||
{
|
||||
lastReportedBytes = bytesDownloaded;
|
||||
var message = totalBytes.HasValue
|
||||
? $"Downloaded {FormatBytes(bytesDownloaded)} of {FormatBytes(totalBytes.Value)} ({bytesDownloaded * 100 / totalBytes.Value}%)"
|
||||
: $"Downloaded {FormatBytes(bytesDownloaded)}";
|
||||
logAction(message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
string[] sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
var order = 0;
|
||||
double len = bytes;
|
||||
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len /= 1024;
|
||||
}
|
||||
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user