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;
}