176 lines
7.4 KiB
C#
176 lines
7.4 KiB
C#
using Jugenddienst_Stunden.Interfaces;
|
||
using System.Net.Http.Headers;
|
||
using System.Net.Http.Json;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using ZXing.Aztec.Internal;
|
||
|
||
namespace Jugenddienst_Stunden.Infrastructure;
|
||
|
||
internal sealed class ApiClient : IApiClient {
|
||
private readonly HttpClient _http;
|
||
private readonly JsonSerializerOptions _json;
|
||
private readonly ApiOptions _options;
|
||
private readonly IAppSettings _settings;
|
||
|
||
public ApiClient(HttpClient http, ApiOptions options, ITokenProvider tokenProvider, IAppSettings settings) {
|
||
_http = http;
|
||
_options = options;
|
||
_settings = settings;
|
||
|
||
// Timeout nur einmalig beim Erstellen setzen – spätere Änderungen an HttpClient.Timeout
|
||
// nach der ersten Verwendung führen zu InvalidOperationException.
|
||
if (_http.Timeout != options.Timeout)
|
||
_http.Timeout = options.Timeout;
|
||
// Standardmäßig JSON akzeptieren; doppelte Einträge vermeiden
|
||
if (!_http.DefaultRequestHeaders.Accept.Any(h => h.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true))
|
||
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||
|
||
// KEINE globalen Header/Properties mehr dynamisch setzen. Authorization wird pro Request gesetzt.
|
||
|
||
_json = new JsonSerializerOptions {
|
||
PropertyNameCaseInsensitive = true,
|
||
WriteIndented = false,
|
||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||
};
|
||
|
||
// Globale Converter: erlauben numerische Felder auch als Strings (z.B. user.id)
|
||
_json.Converters.Add(new Jugenddienst_Stunden.Models.JsonFlexibleInt32Converter());
|
||
_json.Converters.Add(new Jugenddienst_Stunden.Models.JsonFlexibleNullableInt32Converter());
|
||
|
||
// WICHTIG: HttpClient.BaseAddress NICHT dynamisch setzen oder ändern – das verursacht Exceptions,
|
||
// sobald bereits Requests gestartet wurden. Wir bauen stattdessen absolute URIs pro Request.
|
||
}
|
||
|
||
public Task<T> GetAsync<T>(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default)
|
||
=> SendAsync<T>(HttpMethod.Get, path, null, query, ct);
|
||
|
||
public async Task<T> SendAsync<T>(HttpMethod method, string path, object? body = null,
|
||
IDictionary<string, string?>? query = null, CancellationToken ct = default) {
|
||
// Absolute URI aus aktuellem Settings‑BaseUrl bauen, ohne HttpClient.BaseAddress zu nutzen.
|
||
var uri = BuildAbsoluteUri(_settings.ApiUrl, path, query);
|
||
using var req = new HttpRequestMessage(method, uri);
|
||
// Authorization PRO REQUEST setzen (immer, wenn Token vorhanden ist)
|
||
// Hinweis: Das QR-Token kann RFC-unzulässige Zeichen (z. B. '|') enthalten.
|
||
// AuthenticationHeaderValue würde solche Werte ablehnen. Daher ohne Validierung setzen.
|
||
var currentToken = _settings.ApiKey;
|
||
if (!string.IsNullOrWhiteSpace(currentToken)) {
|
||
// Vorherige Header (falls vorhanden) entfernen, um Duplikate zu vermeiden
|
||
req.Headers.Remove("Authorization");
|
||
req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {currentToken}");
|
||
}
|
||
if (body is HttpContent httpContent) {
|
||
req.Content = httpContent;
|
||
} else if (body is not null) {
|
||
req.Content = JsonContent.Create(body, options: _json);
|
||
}
|
||
|
||
// Sicherstellen, dass Accept: application/json auch auf Request-Ebene vorhanden ist (z. B. für LoginWithToken GET)
|
||
if (!req.Headers.Accept.Any(h => h.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true)) {
|
||
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||
}
|
||
|
||
using var res = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||
var text = await res.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||
|
||
if (res.StatusCode == System.Net.HttpStatusCode.NotFound) {
|
||
var message = req.Method + ": " + req.RequestUri + " nicht gefunden";
|
||
throw ApiException.From(res.StatusCode, message);
|
||
}
|
||
|
||
if (!res.IsSuccessStatusCode)
|
||
throw ApiException.From(res.StatusCode, TryGetMessage(text), text);
|
||
|
||
|
||
if (res.StatusCode != System.Net.HttpStatusCode.OK) {
|
||
|
||
// Verhalten wie in BaseFunc: bei Fehlerstatus -> "message" aus Body lesen und mit dessen Inhalt eine Exception werfen.
|
||
try {
|
||
var options = new JsonDocumentOptions { AllowTrailingCommas = true };
|
||
using var doc = JsonDocument.Parse(text, options);
|
||
var root = doc.RootElement;
|
||
// GetProperty wirft, wenn "message" fehlt — das entspricht dem bisherigen Verhalten in BaseFunc.
|
||
var messageElement = root.GetProperty("message");
|
||
if (messageElement.ValueKind != JsonValueKind.String)
|
||
throw ApiException.From(res.StatusCode, "Fehler: 'message' ist null.", text);
|
||
|
||
var message = messageElement.GetString() ?? throw ApiException.From(res.StatusCode, "Fehler: 'message' ist null.", text);
|
||
throw ApiException.From(res.StatusCode, message, text);
|
||
} catch (ApiException) {
|
||
throw;
|
||
} catch (Exception) {
|
||
// Fallback: Wenn Parsing fehlschlägt oder "message" fehlt, konsistente Fehlermeldung wie BaseFunc
|
||
throw ApiException.From(res.StatusCode, "Fehler: 'message' ist null.", text);
|
||
}
|
||
}
|
||
|
||
if (typeof(T) == typeof(void) || typeof(T) == typeof(object) || string.IsNullOrWhiteSpace(text))
|
||
return default!;
|
||
|
||
var obj = System.Text.Json.JsonSerializer.Deserialize<T>(text, _json);
|
||
if (obj is null)
|
||
throw new ApiException("Fehler beim Deserialisieren der Daten.");
|
||
return obj;
|
||
}
|
||
|
||
public Task DeleteAsync(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default)
|
||
=> SendAsync<object>(HttpMethod.Delete, path, null, query, ct);
|
||
|
||
// Entfernt: EnsureBaseAddress – wir ändern BaseAddress nicht mehr dynamisch.
|
||
|
||
private static string TryGetMessage(string text) {
|
||
try {
|
||
using var doc = JsonDocument.Parse(text);
|
||
if (doc.RootElement.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String)
|
||
return m.GetString() ?? text;
|
||
} catch {
|
||
}
|
||
|
||
return text;
|
||
}
|
||
|
||
private static Uri BuildAbsoluteUri(string baseUrl, string path, IDictionary<string, string?>? query) {
|
||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||
throw new InvalidOperationException(
|
||
"ApiUrl ist leer. Bitte zuerst eine gültige Server-URL setzen (Preferences key 'apiUrl').");
|
||
|
||
// Basis muss absolut sein (z. B. https://host/appapi/)
|
||
var baseUri = new Uri(baseUrl, UriKind.Absolute);
|
||
|
||
// Pfad relativ zur Basis aufbauen
|
||
string relativePath = path ?? string.Empty;
|
||
if (query is not null && query.Count > 0) {
|
||
var sb = new StringBuilder(relativePath);
|
||
sb.Append(relativePath.Contains('?') ? '&' : '?');
|
||
sb.Append(string.Join('&', query
|
||
.Where(kv => kv.Value is not null)
|
||
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}")));
|
||
relativePath = sb.ToString();
|
||
}
|
||
|
||
// Wenn path bereits absolut ist, direkt verwenden
|
||
//if (Uri.TryCreate(relativePath, UriKind.Absolute, out var absoluteFromPath))
|
||
// return absoluteFromPath;
|
||
|
||
// Sonderfall: Wenn path ein absoluter file:// URI ist, diesen relativ zur Basis behandeln
|
||
// Weiß nicht wie file:// zustande kommt, vermutlich wäre das zu verhindern
|
||
if (Uri.TryCreate(relativePath, UriKind.Absolute, out var uri)) {
|
||
if (uri.Scheme == Uri.UriSchemeFile) {
|
||
|
||
var normalizedBase = baseUrl.Trim();
|
||
if (!normalizedBase.EndsWith('/'))
|
||
normalizedBase += "/";
|
||
|
||
if (relativePath.StartsWith('/'))
|
||
relativePath = relativePath.TrimStart('/');
|
||
|
||
var baseUriNormalized = new Uri(normalizedBase, UriKind.Absolute);
|
||
return new Uri(baseUriNormalized, relativePath);
|
||
}
|
||
return uri;
|
||
}
|
||
|
||
|
||
return new Uri(baseUri, relativePath);
|
||
}
|
||
} |