using System.Net; using System.Text; using System.Text.Json; namespace ClawdDotNet.Core.Deploymentcenter; /// /// The shared foundation for all Deploymentcenter modules (watchdog, error stream, /// bug tracker, token provisioning). /// /// All JSON endpoints answer uniformly with an envelope — /// {"status":"success",…} or {"status":"error","error":{"code":…}}. The /// code is stable and meant for programs, the message for humans. /// This class unpacks the envelope and turns an error into a /// with the code attached. /// /// Exception: the license endpoints deliberately do not carry this envelope /// — there the field status holds the license state. They are /// therefore not addressed through this class, but through Deploymentcenter.Client. /// public sealed class DeploymentcenterApi : IDisposable { private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); private readonly HttpClient _http; private readonly bool _ownsHttp; private readonly string _token; /// The base URL without a trailing slash. public string BaseUrl { get; } /// Base URL of the Deploymentcenter, for example https://dc.mhdf.de. /// Token with the necessary rights. Sent as Authorization: Bearer. /// Only for tests — otherwise an own one with a timeout is created. public DeploymentcenterApi(string baseUrl, string token, HttpClient? httpClient = null) { if (string.IsNullOrWhiteSpace(baseUrl)) throw new ArgumentException("Deploymentcenter-URL fehlt.", nameof(baseUrl)); // Over an unsecured connection the token would go in plain text. The only exception is // the own machine — there is no route there on which someone could // listen in, and a local test installation rarely has a certificate. if (!IsAcceptableUrl(baseUrl)) { throw new ArgumentException( "Deploymentcenter-URL muss mit https:// beginnen (Ausnahme: localhost).", nameof(baseUrl)); } BaseUrl = baseUrl.TrimEnd('/'); _token = token ?? throw new ArgumentNullException(nameof(token)); _ownsHttp = httpClient is null; _http = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; } private static bool IsAcceptableUrl(string url) { if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; if (uri.Scheme == Uri.UriSchemeHttps) return true; return uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback; } public async Task PostAsync(string path, object payload, CancellationToken ct) { using var request = new HttpRequestMessage(HttpMethod.Post, BaseUrl + path) { Content = new StringContent( JsonSerializer.Serialize(payload, JsonOpts), Encoding.UTF8, "application/json") }; return await SendAsync(request, ct).ConfigureAwait(false); } public async Task GetAsync(string path, CancellationToken ct) { using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + path); return await SendAsync(request, ct).ConfigureAwait(false); } private async Task SendAsync(HttpRequestMessage request, CancellationToken ct) { if (_token.Length > 0) request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + _token); using var response = await _http.SendAsync(request, ct).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); JsonElement root; try { // Cloned because the JsonDocument is released at the end of this block — // a JsonElement from it would no longer be readable afterwards. using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); root = document.RootElement.Clone(); } catch (JsonException) { throw new DeploymentcenterException( "invalid_response", $"Antwort war kein JSON (HTTP {(int)response.StatusCode}).", response.StatusCode); } if (IsErrorEnvelope(root, out var code, out var message)) throw new DeploymentcenterException(code, message, response.StatusCode); if (!response.IsSuccessStatusCode) { throw new DeploymentcenterException( "http_error", $"Deploymentcenter antwortete HTTP {(int)response.StatusCode}.", response.StatusCode); } return root; } private static bool IsErrorEnvelope(JsonElement root, out string code, out string message) { code = "error"; message = "Unbekannter Fehler."; if (root.ValueKind != JsonValueKind.Object) return false; if (!root.TryGetProperty("status", out var status) || status.ValueKind != JsonValueKind.String || !string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase)) { return false; } if (root.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object) { if (error.TryGetProperty("code", out var c) && c.ValueKind == JsonValueKind.String) code = c.GetString() ?? code; if (error.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String) message = m.GetString() ?? message; } return true; } public void Dispose() { if (_ownsHttp) _http.Dispose(); } } /// /// A call rejected by the Deploymentcenter. is the stable /// error code from the envelope (unauthorized, rate_limited, …) — it is /// meant for evaluation, the text is not. /// public sealed class DeploymentcenterException(string code, string message, HttpStatusCode statusCode) : Exception($"{message} [{code}]") { public string Code { get; } = code; public HttpStatusCode StatusCode { get; } = statusCode; /// Token is missing, expired or does not cover the necessary right. public bool IsAuthorizationProblem => StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden; }