using System.Net; using System.Text; using System.Text.Json; namespace ClawdDotNet.Core.Deploymentcenter; /// /// Der gemeinsame Unterbau für alle Deploymentcenter-Module (Watchdog, Fehler-Stream, /// Bugtracker, Token-Provisionierung). /// /// Alle JSON-Endpunkte antworten einheitlich mit einem Umschlag — /// {"status":"success",…} bzw. {"status":"error","error":{"code":…}}. Der /// code ist stabil und für Programme gedacht, die message für Menschen. /// Diese Klasse packt den Umschlag aus und macht aus einem Fehler eine /// mit dem Code daran. /// /// Ausnahme: Die Lizenz-Endpunkte tragen diesen Umschlag bewusst /// nicht — dort steht im Feld status der Lizenzzustand. Sie werden /// deshalb nicht hierüber, sondern über Deploymentcenter.Client angesprochen. /// 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; /// Die Basis-URL ohne abschließenden Schrägstrich. public string BaseUrl { get; } /// Basis-URL des Deploymentcenters, etwa https://dc.mhdf.de. /// Token mit den nötigen Rechten. Geht als Authorization: Bearer mit. /// Nur für Tests — sonst wird ein eigener mit Zeitgrenze erstellt. public DeploymentcenterApi(string baseUrl, string token, HttpClient? httpClient = null) { if (string.IsNullOrWhiteSpace(baseUrl)) throw new ArgumentException("Deploymentcenter-URL fehlt.", nameof(baseUrl)); // Über eine ungesicherte Verbindung ginge das Token im Klartext. Ausnahme ist // nur der eigene Rechner — dort gibt es keine Strecke, auf der jemand mithören // könnte, und eine lokale Testinstallation hat selten ein Zertifikat. 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 { // Geklont, weil das JsonDocument am Ende dieses Blocks freigegeben wird — // ein JsonElement daraus wäre danach nicht mehr lesbar. 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(); } } /// /// Ein vom Deploymentcenter abgelehnter Aufruf. ist der stabile /// Fehlercode aus dem Umschlag (unauthorized, rate_limited, …) — er ist /// zum Auswerten gedacht, der Text nicht. /// public sealed class DeploymentcenterException(string code, string message, HttpStatusCode statusCode) : Exception($"{message} [{code}]") { public string Code { get; } = code; public HttpStatusCode StatusCode { get; } = statusCode; /// Token fehlt, ist abgelaufen oder deckt das nötige Recht nicht ab. public bool IsAuthorizationProblem => StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden; }