Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft. Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch. WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b umbenannten Parameter angeglichen. Dabei behoben: - 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue" gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day. - Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine CSS-Klasse gab - jetzt "success" wie laufende Instanzen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
|
|
/// The shared foundation for all Deploymentcenter modules (watchdog, error stream,
|
|
/// bug tracker, token provisioning).
|
|
///
|
|
/// <para>All JSON endpoints answer uniformly with an envelope —
|
|
/// <c>{"status":"success",…}</c> or <c>{"status":"error","error":{"code":…}}</c>. The
|
|
/// <c>code</c> is stable and meant for programs, the <c>message</c> for humans.
|
|
/// This class unpacks the envelope and turns an error into a
|
|
/// <see cref="DeploymentcenterException"/> with the code attached.</para>
|
|
///
|
|
/// <para><b>Exception:</b> the license endpoints deliberately do <em>not</em> carry this envelope
|
|
/// — there the field <c>status</c> holds the license state. They are
|
|
/// therefore not addressed through this class, but through <c>Deploymentcenter.Client</c>.</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>The base URL without a trailing slash.</summary>
|
|
public string BaseUrl { get; }
|
|
|
|
/// <param name="baseUrl">Base URL of the Deploymentcenter, for example <c>https://dc.mhdf.de</c>.</param>
|
|
/// <param name="token">Token with the necessary rights. Sent as <c>Authorization: Bearer</c>.</param>
|
|
/// <param name="httpClient">Only for tests — otherwise an own one with a timeout is created.</param>
|
|
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<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
|
|
{
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A call rejected by the Deploymentcenter. <see cref="Code"/> is the stable
|
|
/// error code from the envelope (<c>unauthorized</c>, <c>rate_limited</c>, …) — it is
|
|
/// meant for evaluation, the text is not.
|
|
/// </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 is missing, expired or does not cover the necessary right.</summary>
|
|
public bool IsAuthorizationProblem =>
|
|
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
|
|
}
|