173 lines
6.6 KiB
C#
173 lines
6.6 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace ClawdDotNet.Core.Deploymentcenter;
|
|
|
|
/// <summary>
|
|
/// Der gemeinsame Unterbau für alle Deploymentcenter-Module (Watchdog, Fehler-Stream,
|
|
/// Bugtracker, Token-Provisionierung).
|
|
///
|
|
/// <para>Alle JSON-Endpunkte antworten einheitlich mit einem Umschlag —
|
|
/// <c>{"status":"success",…}</c> bzw. <c>{"status":"error","error":{"code":…}}</c>. Der
|
|
/// <c>code</c> ist stabil und für Programme gedacht, die <c>message</c> für Menschen.
|
|
/// Diese Klasse packt den Umschlag aus und macht aus einem Fehler eine
|
|
/// <see cref="DeploymentcenterException"/> mit dem Code daran.</para>
|
|
///
|
|
/// <para><b>Ausnahme:</b> Die Lizenz-Endpunkte tragen diesen Umschlag bewusst
|
|
/// <em>nicht</em> — dort steht im Feld <c>status</c> der Lizenzzustand. Sie werden
|
|
/// deshalb nicht hierüber, sondern über <c>Deploymentcenter.Client</c> angesprochen.</para>
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>Die Basis-URL ohne abschließenden Schrägstrich.</summary>
|
|
public string BaseUrl { get; }
|
|
|
|
/// <param name="baseUrl">Basis-URL des Deploymentcenters, etwa <c>https://dc.mhdf.de</c>.</param>
|
|
/// <param name="token">Token mit den nötigen Rechten. Geht als <c>Authorization: Bearer</c> mit.</param>
|
|
/// <param name="httpClient">Nur für Tests — sonst wird ein eigener mit Zeitgrenze erstellt.</param>
|
|
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<JsonElement> 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<JsonElement> GetAsync(string path, CancellationToken ct)
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + path);
|
|
return await SendAsync(request, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<JsonElement> 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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ein vom Deploymentcenter abgelehnter Aufruf. <see cref="Code"/> ist der stabile
|
|
/// Fehlercode aus dem Umschlag (<c>unauthorized</c>, <c>rate_limited</c>, …) — er ist
|
|
/// zum Auswerten gedacht, der Text nicht.
|
|
/// </summary>
|
|
public sealed class DeploymentcenterException(string code, string message, HttpStatusCode statusCode)
|
|
: Exception($"{message} [{code}]")
|
|
{
|
|
public string Code { get; } = code;
|
|
|
|
public HttpStatusCode StatusCode { get; } = statusCode;
|
|
|
|
/// <summary>Token fehlt, ist abgelaufen oder deckt das nötige Recht nicht ab.</summary>
|
|
public bool IsAuthorizationProblem =>
|
|
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
|
|
}
|