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 GetAsync(string path, IDictionary? query = null, CancellationToken ct = default) => SendAsync(HttpMethod.Get, path, null, query, ct); public async Task SendAsync(HttpMethod method, string path, object? body = null, IDictionary? 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(text, _json); if (obj is null) throw new ApiException("Fehler beim Deserialisieren der Daten."); return obj; } public Task DeleteAsync(string path, IDictionary? query = null, CancellationToken ct = default) => SendAsync(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? 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); } }