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