up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
164
src/Cli/StellaOps.Cli/Services/Transport/HttpTransport.cs
Normal file
164
src/Cli/StellaOps.Cli/Services/Transport/HttpTransport.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP transport implementation for online mode.
|
||||
/// CLI-SDK-62-001: Provides HTTP transport for online API operations.
|
||||
/// </summary>
|
||||
public sealed class HttpTransport : IStellaOpsTransport
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly TransportOptions _options;
|
||||
private readonly ILogger<HttpTransport> _logger;
|
||||
private bool _disposed;
|
||||
|
||||
public HttpTransport(HttpClient httpClient, TransportOptions options, ILogger<HttpTransport> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && _httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
_httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
|
||||
_httpClient.Timeout = _options.Timeout;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOffline => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string TransportMode => "http";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var attempt = 0;
|
||||
var maxAttempts = Math.Max(1, _options.MaxRetries);
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Sending {Method} request to {Uri} (attempt {Attempt}/{MaxAttempts})",
|
||||
request.Method, request.RequestUri, attempt, maxAttempts);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Received response {StatusCode} from {Uri}",
|
||||
(int)response.StatusCode, request.RequestUri);
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (HttpRequestException ex) when (attempt < maxAttempts && IsRetryableException(ex))
|
||||
{
|
||||
var delay = GetRetryDelay(attempt);
|
||||
_logger.LogWarning(ex, "Request failed (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s...",
|
||||
attempt, maxAttempts, delay.TotalSeconds);
|
||||
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Clone the request for retry
|
||||
request = await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// For HTTP transport, we return a memory stream that will be uploaded
|
||||
// The caller is responsible for writing to the stream and then calling upload
|
||||
return await Task.FromResult<Stream>(new MemoryStream()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
|
||||
var response = await SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsRetryableException(HttpRequestException ex)
|
||||
{
|
||||
// Retry on connection errors, timeouts, and server errors
|
||||
return ex.InnerException is IOException
|
||||
|| ex.InnerException is OperationCanceledException
|
||||
|| (ex.StatusCode.HasValue && (int)ex.StatusCode.Value >= 500);
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(int attempt)
|
||||
{
|
||||
// Exponential backoff with jitter
|
||||
var baseDelay = Math.Pow(2, attempt);
|
||||
var jitter = Random.Shared.NextDouble() * 0.5;
|
||||
return TimeSpan.FromSeconds(baseDelay + jitter);
|
||||
}
|
||||
|
||||
private static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
|
||||
|
||||
// Copy headers
|
||||
foreach (var header in request.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
// Copy content if present
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var content = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
clone.Content = new ByteArrayContent(content);
|
||||
|
||||
foreach (var header in request.Content.Headers)
|
||||
{
|
||||
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy options
|
||||
foreach (var option in request.Options)
|
||||
{
|
||||
clone.Options.TryAdd(option.Key, option.Value);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
// Note: We don't dispose _httpClient as it's typically managed by DI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Transport abstraction for CLI operations.
|
||||
/// CLI-SDK-62-001: Supports modular transport for online and air-gapped modes.
|
||||
/// </summary>
|
||||
public interface IStellaOpsTransport : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether this transport is operating in offline/air-gapped mode.
|
||||
/// </summary>
|
||||
bool IsOffline { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transport mode identifier.
|
||||
/// </summary>
|
||||
string TransportMode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request and returns the response.
|
||||
/// </summary>
|
||||
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request and returns the response with streaming content.
|
||||
/// </summary>
|
||||
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a stream for uploading content.
|
||||
/// </summary>
|
||||
Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a stream for downloading content.
|
||||
/// </summary>
|
||||
Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transport configuration options.
|
||||
/// </summary>
|
||||
public sealed class TransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URL for the backend API.
|
||||
/// </summary>
|
||||
public string? BackendUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to operate in offline/air-gapped mode.
|
||||
/// </summary>
|
||||
public bool IsOffline { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory for offline kit data.
|
||||
/// </summary>
|
||||
public string? OfflineKitDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate SSL certificates.
|
||||
/// </summary>
|
||||
public bool ValidateSsl { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Custom CA certificate path for SSL validation.
|
||||
/// </summary>
|
||||
public string? CaCertificatePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Proxy URL if required.
|
||||
/// </summary>
|
||||
public string? ProxyUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User agent string.
|
||||
/// </summary>
|
||||
public string UserAgent { get; set; } = "StellaOps-CLI/1.0";
|
||||
}
|
||||
186
src/Cli/StellaOps.Cli/Services/Transport/OfflineTransport.cs
Normal file
186
src/Cli/StellaOps.Cli/Services/Transport/OfflineTransport.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Offline transport implementation for air-gapped mode.
|
||||
/// CLI-SDK-62-001: Provides offline transport for air-gapped operations.
|
||||
/// </summary>
|
||||
public sealed class OfflineTransport : IStellaOpsTransport
|
||||
{
|
||||
private readonly TransportOptions _options;
|
||||
private readonly ILogger<OfflineTransport> _logger;
|
||||
private readonly string _offlineKitDirectory;
|
||||
private bool _disposed;
|
||||
|
||||
public OfflineTransport(TransportOptions options, ILogger<OfflineTransport> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.OfflineKitDirectory))
|
||||
{
|
||||
throw new ArgumentException("OfflineKitDirectory must be specified for offline transport.", nameof(options));
|
||||
}
|
||||
|
||||
_offlineKitDirectory = options.OfflineKitDirectory;
|
||||
|
||||
if (!Directory.Exists(_offlineKitDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Offline kit directory not found: {_offlineKitDirectory}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOffline => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string TransportMode => "offline";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_logger.LogDebug("Offline transport handling {Method} {Uri}", request.Method, request.RequestUri);
|
||||
|
||||
// Map the request to an offline resource
|
||||
var (found, content) = await TryGetOfflineContentAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (found)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = content,
|
||||
RequestMessage = request
|
||||
};
|
||||
}
|
||||
|
||||
// Return a 503 Service Unavailable for operations that require online access
|
||||
_logger.LogWarning("Operation not available in offline mode: {Method} {Uri}", request.Method, request.RequestUri);
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = "ERR_AIRGAP_EGRESS_BLOCKED",
|
||||
message = "This operation is not available in offline/air-gapped mode."
|
||||
}
|
||||
})),
|
||||
RequestMessage = request
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// Create a staging area for uploads in offline mode
|
||||
var stagingDir = Path.Combine(_offlineKitDirectory, "staging", "uploads");
|
||||
Directory.CreateDirectory(stagingDir);
|
||||
|
||||
var stagingFile = Path.Combine(stagingDir, $"{Guid.NewGuid():N}.dat");
|
||||
_logger.LogDebug("Creating offline upload staging file: {Path}", stagingFile);
|
||||
|
||||
return Task.FromResult<Stream>(File.Create(stagingFile));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// Map endpoint to offline file
|
||||
var localPath = MapEndpointToLocalPath(endpoint);
|
||||
|
||||
if (!File.Exists(localPath))
|
||||
{
|
||||
_logger.LogWarning("Offline resource not found: {Path}", localPath);
|
||||
throw new FileNotFoundException($"Offline resource not found: {endpoint}", localPath);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Opening offline resource: {Path}", localPath);
|
||||
return Task.FromResult<Stream>(File.OpenRead(localPath));
|
||||
}
|
||||
|
||||
private async Task<(bool Found, HttpContent? Content)> TryGetOfflineContentAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = request.RequestUri;
|
||||
if (uri is null)
|
||||
return (false, null);
|
||||
|
||||
var path = uri.PathAndQuery.TrimStart('/');
|
||||
|
||||
// Check for cached API responses
|
||||
var cachePath = Path.Combine(_offlineKitDirectory, "cache", "api", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
// Try with .json extension
|
||||
var jsonPath = cachePath + ".json";
|
||||
if (File.Exists(jsonPath))
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(jsonPath, cancellationToken).ConfigureAwait(false);
|
||||
return (true, new StringContent(content, System.Text.Encoding.UTF8, "application/json"));
|
||||
}
|
||||
|
||||
// Try exact path
|
||||
if (File.Exists(cachePath))
|
||||
{
|
||||
var content = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false);
|
||||
return (true, new ByteArrayContent(content));
|
||||
}
|
||||
|
||||
// Check for bundled data
|
||||
var bundlePath = Path.Combine(_offlineKitDirectory, "data", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(bundlePath))
|
||||
{
|
||||
var content = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
return (true, new ByteArrayContent(content));
|
||||
}
|
||||
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
private string MapEndpointToLocalPath(string endpoint)
|
||||
{
|
||||
var path = endpoint.TrimStart('/');
|
||||
|
||||
// Check data directory first
|
||||
var dataPath = Path.Combine(_offlineKitDirectory, "data", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(dataPath))
|
||||
return dataPath;
|
||||
|
||||
// Check cache directory
|
||||
var cachePath = Path.Combine(_offlineKitDirectory, "cache", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(cachePath))
|
||||
return cachePath;
|
||||
|
||||
// Return data path as default (will throw FileNotFoundException if not found)
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
264
src/Cli/StellaOps.Cli/Services/Transport/StellaOpsClientBase.cs
Normal file
264
src/Cli/StellaOps.Cli/Services/Transport/StellaOpsClientBase.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for SDK-generated clients.
|
||||
/// CLI-SDK-62-001: Provides common functionality for SDK clients with modular transport.
|
||||
/// </summary>
|
||||
public abstract class StellaOpsClientBase : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly IStellaOpsTransport _transport;
|
||||
private readonly ILogger _logger;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization token for API requests.
|
||||
/// </summary>
|
||||
protected string? AccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current tenant context.
|
||||
/// </summary>
|
||||
protected string? TenantId { get; set; }
|
||||
|
||||
protected StellaOpsClientBase(IStellaOpsTransport transport, ILogger logger)
|
||||
{
|
||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the client is operating in offline mode.
|
||||
/// </summary>
|
||||
public bool IsOffline => _transport.IsOffline;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the access token for authenticated requests.
|
||||
/// </summary>
|
||||
public void SetAccessToken(string? token)
|
||||
{
|
||||
AccessToken = token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the tenant context.
|
||||
/// </summary>
|
||||
public void SetTenant(string? tenantId)
|
||||
{
|
||||
TenantId = tenantId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws if the operation requires online connectivity and transport is offline.
|
||||
/// </summary>
|
||||
protected void ThrowIfOffline(string operation)
|
||||
{
|
||||
if (_transport.IsOffline)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Operation '{operation}' is not available in offline/air-gapped mode. " +
|
||||
"Please use online mode or import the required data to the offline kit.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a GET request and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> GetAsync<TResponse>(
|
||||
string relativeUrl,
|
||||
CancellationToken cancellationToken) where TResponse : class
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Get, relativeUrl);
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a POST request with JSON body and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> PostAsync<TRequest, TResponse>(
|
||||
string relativeUrl,
|
||||
TRequest body,
|
||||
CancellationToken cancellationToken)
|
||||
where TRequest : class
|
||||
where TResponse : class
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Post, relativeUrl);
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a PUT request with JSON body and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> PutAsync<TRequest, TResponse>(
|
||||
string relativeUrl,
|
||||
TRequest body,
|
||||
CancellationToken cancellationToken)
|
||||
where TRequest : class
|
||||
where TResponse : class
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Put, relativeUrl);
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a DELETE request.
|
||||
/// </summary>
|
||||
protected async Task DeleteAsync(string relativeUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Delete, relativeUrl);
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request and parses any error response.
|
||||
/// </summary>
|
||||
protected async Task<(TResponse? Result, CliError? Error)> TrySendAsync<TResponse>(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) where TResponse : class
|
||||
{
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return (result, null);
|
||||
}
|
||||
|
||||
var error = await ParseErrorAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
return (null, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP request with standard headers.
|
||||
/// </summary>
|
||||
protected HttpRequestMessage CreateRequest(HttpMethod method, string relativeUrl)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, relativeUrl);
|
||||
|
||||
// Add authorization header
|
||||
if (!string.IsNullOrWhiteSpace(AccessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
|
||||
}
|
||||
|
||||
// Add tenant header
|
||||
if (!string.IsNullOrWhiteSpace(TenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Tenant-Id", TenantId);
|
||||
}
|
||||
|
||||
// Add standard headers
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request through the transport.
|
||||
/// </summary>
|
||||
protected async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_logger.LogDebug("Sending {Method} request to {Uri}", request.Method, request.RequestUri);
|
||||
|
||||
return await _transport.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an error response into a CliError.
|
||||
/// </summary>
|
||||
protected async Task<CliError> ParseErrorAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
string? content = null;
|
||||
|
||||
try
|
||||
{
|
||||
content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore content read errors
|
||||
}
|
||||
|
||||
// Try to parse as error envelope
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = JsonSerializer.Deserialize<ApiErrorEnvelope>(content, JsonOptions);
|
||||
if (envelope?.Error is not null)
|
||||
{
|
||||
return CliError.FromApiErrorEnvelope(envelope, statusCode);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not an error envelope
|
||||
}
|
||||
|
||||
// Try to parse as problem details
|
||||
try
|
||||
{
|
||||
var problem = JsonSerializer.Deserialize<ProblemDocument>(content, JsonOptions);
|
||||
if (problem is not null)
|
||||
{
|
||||
return new CliError(
|
||||
Code: problem.Type ?? $"ERR_HTTP_{statusCode}",
|
||||
Message: problem.Title ?? $"HTTP error {statusCode}",
|
||||
Detail: problem.Detail);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not a problem document
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to HTTP status-based error
|
||||
return CliError.FromHttpStatus(statusCode, content);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_transport.Dispose();
|
||||
}
|
||||
}
|
||||
126
src/Cli/StellaOps.Cli/Services/Transport/TransportFactory.cs
Normal file
126
src/Cli/StellaOps.Cli/Services/Transport/TransportFactory.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating transport instances based on configuration.
|
||||
/// CLI-SDK-62-001: Provides modular transport selection for online/offline modes.
|
||||
/// </summary>
|
||||
public sealed class TransportFactory : ITransportFactory
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly CliProfileManager _profileManager;
|
||||
|
||||
public TransportFactory(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILoggerFactory loggerFactory,
|
||||
StellaOpsCliOptions options,
|
||||
CliProfileManager profileManager)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_profileManager = profileManager ?? throw new ArgumentNullException(nameof(profileManager));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a transport instance based on current configuration.
|
||||
/// </summary>
|
||||
public IStellaOpsTransport CreateTransport()
|
||||
{
|
||||
var transportOptions = CreateTransportOptions();
|
||||
|
||||
if (transportOptions.IsOffline)
|
||||
{
|
||||
return CreateOfflineTransport(transportOptions);
|
||||
}
|
||||
|
||||
return CreateHttpTransport(transportOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP transport for online operations.
|
||||
/// </summary>
|
||||
public IStellaOpsTransport CreateHttpTransport(TransportOptions? options = null)
|
||||
{
|
||||
options ??= CreateTransportOptions();
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient("StellaOps");
|
||||
var logger = _loggerFactory.CreateLogger<HttpTransport>();
|
||||
|
||||
return new HttpTransport(httpClient, options, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an offline transport for air-gapped operations.
|
||||
/// </summary>
|
||||
public IStellaOpsTransport CreateOfflineTransport(TransportOptions? options = null)
|
||||
{
|
||||
options ??= CreateTransportOptions();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.OfflineKitDirectory))
|
||||
{
|
||||
throw new InvalidOperationException("Offline kit directory must be specified for offline transport.");
|
||||
}
|
||||
|
||||
var logger = _loggerFactory.CreateLogger<OfflineTransport>();
|
||||
return new OfflineTransport(options, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates transport options from current configuration.
|
||||
/// </summary>
|
||||
public TransportOptions CreateTransportOptions()
|
||||
{
|
||||
var profile = _profileManager.GetCurrentProfileAsync().GetAwaiter().GetResult();
|
||||
|
||||
return new TransportOptions
|
||||
{
|
||||
BackendUrl = profile?.BackendUrl ?? _options.BackendUrl,
|
||||
IsOffline = profile?.IsOffline ?? _options.IsOffline,
|
||||
OfflineKitDirectory = profile?.OfflineKitDirectory ?? _options.OfflineKitDirectory,
|
||||
Timeout = TimeSpan.FromMinutes(5),
|
||||
MaxRetries = 3,
|
||||
ValidateSsl = true,
|
||||
UserAgent = $"StellaOps-CLI/{GetVersion()}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetVersion()
|
||||
{
|
||||
var assembly = typeof(TransportFactory).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "1.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory interface for creating transport instances.
|
||||
/// </summary>
|
||||
public interface ITransportFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a transport instance based on current configuration.
|
||||
/// </summary>
|
||||
IStellaOpsTransport CreateTransport();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP transport for online operations.
|
||||
/// </summary>
|
||||
IStellaOpsTransport CreateHttpTransport(TransportOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an offline transport for air-gapped operations.
|
||||
/// </summary>
|
||||
IStellaOpsTransport CreateOfflineTransport(TransportOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates transport options from current configuration.
|
||||
/// </summary>
|
||||
TransportOptions CreateTransportOptions();
|
||||
}
|
||||
Reference in New Issue
Block a user