using System.Net.Http.Headers; using System.Text; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.CLI; public class ManagementHttpClient : IDisposable { private readonly HttpClient _httpClient; /// /// Default bound on the CONNECT phase only (30 s) — how long a TCP/TLS /// connection attempt to a black-holed management endpoint may hang before the /// call fails. /// /// /// It is deliberately NOT an overall request timeout. /// caps the whole request/response, so setting /// it to any fixed value silently truncates every caller whose own per-call /// TimeSpan timeout argument is longer: the effective deadline becomes /// min(HttpClient.Timeout, caller timeout). That is exactly what a 30 s /// client timeout did to deploy site's 5-minute bulk deploy and to the /// five-minute bundle export/preview/import calls — each printed a fake /// 504 Request timed out at 30 s while the server carried on working. /// is therefore /// and the per-call /// in /// // /// is the SINGLE overall deadline — it bounds connect too, since the token is /// passed into the send itself. /// /// /// /// The connect bound lives on /// instead, which is connect-scoped and so cannot truncate a long-running /// request that has already reached the server. Overridable via the /// SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS environment variable, /// consistent with how every other CLI setting is overridden (see /// ) — kept self-contained here (no /// /command-file plumbing) since CLI commands are owned /// by a separate work package this phase. /// /// public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(30); /// Test seam — the effective this instance was constructed with. internal TimeSpan EffectiveTimeout { get; } /// /// Resolves the effective connect timeout: the /// SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS environment variable when set /// to a positive integer, otherwise . /// /// The connect timeout to apply to the socket handler. internal static TimeSpan ResolveConnectTimeout() { var env = Environment.GetEnvironmentVariable("SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS"); if (!string.IsNullOrWhiteSpace(env) && int.TryParse(env, out var seconds) && seconds > 0) { return TimeSpan.FromSeconds(seconds); } return DefaultConnectTimeout; } /// /// Initializes a new instance of the class with /// an INFINITE (each call supplies its own /// deadline) over a whose /// is /// . /// /// The base URL for the management API. /// The username for HTTP Basic authentication. /// The password for HTTP Basic authentication. public ManagementHttpClient(string baseUrl, string username, string password) : this( new HttpClient(new SocketsHttpHandler { ConnectTimeout = ResolveConnectTimeout() }) { Timeout = Timeout.InfiniteTimeSpan }, baseUrl, username, password) { } /// /// Test-only constructor that accepts a pre-built (typically /// over a stub ) so the request/response handling can /// be exercised without a live server. /// /// The HTTP client to use for requests. /// The base URL for the management API. /// The username for HTTP Basic authentication. /// The password for HTTP Basic authentication. internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password) { _httpClient = httpClient; // Test seam (WP2.6e): exposes the constructed HttpClient's effective Timeout // without requiring reflection. EffectiveTimeout = httpClient.Timeout; _httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); } /// /// Sends a management command to the management API. /// /// The command name to execute. /// The command payload. /// The request timeout. /// A management response containing status and data. public async Task SendCommandAsync(string commandName, object payload, TimeSpan timeout) { using var cts = new CancellationTokenSource(timeout); var body = JsonSerializer.Serialize(new { command = commandName, payload }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); var content = new StringContent(body, Encoding.UTF8, "application/json"); HttpResponseMessage httpResponse; try { httpResponse = await _httpClient.PostAsync("management", content, cts.Token); } catch (TaskCanceledException) { return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT"); } catch (HttpRequestException ex) { return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED"); } var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token); if (httpResponse.IsSuccessStatusCode) { return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null); } // Parse error response string? error = null; string? code = null; try { using var doc = JsonDocument.Parse(responseBody); error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody; code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null; } catch { error = responseBody; } return new ManagementResponse((int)httpResponse.StatusCode, null, error, code); } /// /// Issues a plain HTTP GET against a REST endpoint (e.g. the audit /// /api/audit/query endpoint) and returns the /// response body. Unlike , this does not wrap the call /// in the POST /management command envelope — the audit endpoints are plain /// REST resources. Authentication (HTTP Basic) and the base address are shared. /// /// Path relative to the base URL, with query string. /// The request timeout. /// A management response containing status and data. public async Task SendGetAsync(string relativePath, TimeSpan timeout) { using var cts = new CancellationTokenSource(timeout); HttpResponseMessage httpResponse; try { httpResponse = await _httpClient.GetAsync(relativePath, cts.Token); } catch (TaskCanceledException) { return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT"); } catch (HttpRequestException ex) { return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED"); } var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token); if (httpResponse.IsSuccessStatusCode) { return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null); } string? error = null; string? code = null; try { using var doc = JsonDocument.Parse(responseBody); error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody; code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null; } catch { error = responseBody; } return new ManagementResponse((int)httpResponse.StatusCode, null, error, code); } /// /// Issues a plain HTTP POST against a REST endpoint (e.g. the audit /// maintenance endpoints) with a JSON body and returns the response. Unlike /// , this does not wrap the call in the /// POST /management command envelope — these are plain REST resources. /// Authentication (HTTP Basic) and the base address are shared. /// /// Path relative to the base URL. /// The JSON body to send, or null for an empty body. /// The request timeout. /// A management response containing status and data. public async Task SendPostAsync(string relativePath, string? body, TimeSpan timeout) { using var cts = new CancellationTokenSource(timeout); var content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json"); HttpResponseMessage httpResponse; try { httpResponse = await _httpClient.PostAsync(relativePath, content, cts.Token); } catch (TaskCanceledException) { return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT"); } catch (HttpRequestException ex) { return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED"); } var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token); if (httpResponse.IsSuccessStatusCode) { return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null); } string? error = null; string? code = null; try { using var doc = JsonDocument.Parse(responseBody); error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody; code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null; } catch { error = responseBody; } return new ManagementResponse((int)httpResponse.StatusCode, null, error, code); } /// /// Issues a plain HTTP GET and returns the raw /// so the caller can stream the response body without buffering it in memory — used /// by audit export, where the response can be many megabytes. The caller owns /// disposing the returned message. The /// option ensures the body is not pre-buffered. /// /// Path relative to the base URL, with query string. /// A cancellation token that can be used to cancel the operation. /// The raw HTTP response message for streaming. public async Task SendGetStreamAsync(string relativePath, CancellationToken cancellationToken) => await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken); /// /// Disposes the underlying HTTP client. /// public void Dispose() => _httpClient.Dispose(); } public record ManagementResponse(int StatusCode, string? JsonData, string? Error, string? ErrorCode);