using System.Diagnostics; using System.Net.Http.Json; using System.Text.Json; using LumaTunnel.Shared.Models; using LumaTunnel.Shared.Protocol; namespace LumaTunnel.Client.Core.Nodes; public sealed class NodeApiClient : IDisposable { private readonly HttpClient _client; public NodeApiClient(TimeSpan? timeout = null) { _client = new HttpClient(new SocketsHttpHandler { UseProxy = false, AutomaticDecompression = System.Net.DecompressionMethods.All, PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) { Timeout = timeout ?? TimeSpan.FromSeconds(10) }; } public async Task<(NodeHealthDto Health, TimeSpan Latency)> GetHealthAsync(Uri nodeUri, CancellationToken cancellationToken = default) { var stopwatch = Stopwatch.StartNew(); using var response = await _client.GetAsync(BuildEndpoint(nodeUri, "v1/health"), cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var health = await DeserializeAsync(response, cancellationToken).ConfigureAwait(false); return (health, stopwatch.Elapsed); } public async Task EnrollAsync(Uri nodeUri, DeviceEnrollmentRequest request, CancellationToken cancellationToken = default) { using var response = await _client.PostAsJsonAsync(BuildEndpoint(nodeUri, "v1/device/enroll"), request, TunnelMessageSerializer.Options, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await DeserializeAsync(response, cancellationToken).ConfigureAwait(false); } public async Task GetStatusAsync(Uri nodeUri, NodeStatusRequest request, CancellationToken cancellationToken = default) { using var response = await _client.PostAsJsonAsync(BuildEndpoint(nodeUri, "v1/node/status"), request, TunnelMessageSerializer.Options, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await DeserializeAsync(response, cancellationToken).ConfigureAwait(false); } public void Dispose() { _client.Dispose(); GC.SuppressFinalize(this); } private static Uri BuildEndpoint(Uri nodeUri, string route) { var builder = new UriBuilder(nodeUri); var path = builder.Path.TrimEnd('/'); builder.Path = path.EndsWith("/api", StringComparison.OrdinalIgnoreCase) ? $"{path}/{route}" : $"{path}/api/{route}"; builder.Query = string.Empty; builder.Fragment = string.Empty; return builder.Uri; } private static async Task DeserializeAsync(HttpResponseMessage response, CancellationToken cancellationToken) where T : class { await using var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return await JsonSerializer.DeserializeAsync(content, TunnelMessageSerializer.Options, cancellationToken).ConfigureAwait(false) ?? throw new InvalidDataException($"The node returned an empty {typeof(T).Name} response."); } }