using System.Net; using System.Net.Http.Json; namespace OpenNest.Data; /// /// Talks to the settings every workstation shares on the nest server (/api/settings). /// A server without them raises ; a stale write raises /// ; other failures surface as . /// public sealed class RemoteSharedSettings : IDisposable { private const string Feature = "shared machine settings"; private readonly HttpClient _httpClient; private readonly bool _ownsClient; private readonly Uri _baseUri; /// A client that gives up after , so an unreachable server falls back quickly. public RemoteSharedSettings(string baseUrl, TimeSpan timeout) : this(new HttpClient { Timeout = timeout }, baseUrl) { _ownsClient = true; } /// For tests and hosts that pool HttpClient instances. public RemoteSharedSettings(HttpClient httpClient, string baseUrl) { _httpClient = httpClient; _baseUri = ServerUrl.ParseBase(baseUrl, nameof(baseUrl)); } private Uri Url(string relative) => new(_baseUri, relative); private static string Path(string kind, string key) => $"api/settings/{Uri.EscapeDataString(kind)}/{Uri.EscapeDataString(key)}"; /// Every shared settings document. public async Task> ListAsync(CancellationToken cancellationToken = default) { using var response = await _httpClient.GetAsync(Url("api/settings"), cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) throw new ServerTooOldException(Feature); await NestServerHttp.EnsureSuccessAsync(response, "read the shared settings", cancellationToken); var records = await response.Content.ReadFromJsonAsync>(NestServerHttp.JsonOptions, cancellationToken) ?? throw new IOException("Nest server returned no shared settings."); if (records.Any(record => record is null || record.Revision < 1 || string.IsNullOrEmpty(record.Json))) throw new IOException("Nest server returned invalid shared settings."); // A newer server may share kinds this version does not use. return records.Where(record => SharedSettingKinds.IsKnown(record.Kind)).ToList(); } /// /// Stores a new document. A different document already stored raises /// with it. /// public async Task CreateAsync(string kind, string key, SharedSettingWrite write, CancellationToken cancellationToken = default) { Validate(kind, key, write); using var response = await _httpClient.PostAsync(Url(Path(kind, key)), JsonContent.Create(write, options: NestServerHttp.JsonOptions), cancellationToken); return await ReadSaved(response, kind, key, $"save the shared {kind} settings", cancellationToken); } /// /// Replaces the document stored at . A newer one raises /// ; one that is gone raises . /// public async Task UpdateAsync(string kind, string key, SharedSettingWrite write, int expectedRevision, CancellationToken cancellationToken = default) { Validate(kind, key, write); using var request = new HttpRequestMessage(HttpMethod.Put, Url(Path(kind, key))) { Content = JsonContent.Create(write, options: NestServerHttp.JsonOptions), }; NestServerHttp.AddIfMatch(request, expectedRevision); using var response = await _httpClient.SendAsync(request, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) throw new SharedSettingMissingException(kind, key); return await ReadSaved(response, kind, key, $"save the shared {kind} settings", cancellationToken); } /// Deletes the document stored at ; one already gone is not an error. public async Task DeleteAsync(string kind, string key, string savedBy, int expectedRevision, CancellationToken cancellationToken = default) { if (SharedSettingRules.NormalizeKey(kind, key) is null) throw new ArgumentException($"Invalid {kind} settings key.", nameof(key)); using var request = new HttpRequestMessage(HttpMethod.Delete, Url(Path(kind, key) + "?savedBy=" + Uri.EscapeDataString(savedBy ?? ""))); NestServerHttp.AddIfMatch(request, expectedRevision); using var response = await _httpClient.SendAsync(request, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) return; if (response.StatusCode == HttpStatusCode.Conflict) throw new SharedSettingSaveConflictException(await ReadRecord(response, cancellationToken)); await NestServerHttp.EnsureSuccessAsync(response, $"delete the shared {kind} settings", cancellationToken); } private static void Validate(string kind, string key, SharedSettingWrite write) { if (SharedSettingRules.GetError(kind, key, write) is { } error) throw new ArgumentException(error, nameof(write)); } private static async Task ReadSaved(HttpResponseMessage response, string kind, string key, string action, CancellationToken cancellationToken) { if (response.StatusCode == HttpStatusCode.Conflict) throw new SharedSettingSaveConflictException(await ReadRecord(response, cancellationToken)); if (response.StatusCode == HttpStatusCode.NotFound) throw new ServerTooOldException(Feature); await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken); var saved = await ReadRecord(response, cancellationToken); if (saved.Kind != kind || saved.Key != SharedSettingRules.NormalizeKey(kind, key) || saved.Revision < 1) throw new IOException("The nest server returned different settings than were saved."); return saved; } private static async Task ReadRecord(HttpResponseMessage response, CancellationToken cancellationToken) => await response.Content.ReadFromJsonAsync(NestServerHttp.JsonOptions, cancellationToken) ?? throw new IOException("Nest server returned empty settings."); public void Dispose() { if (_ownsClient) _httpClient.Dispose(); } } /// A shared settings write found a newer or different stored document; nothing was changed. public sealed class SharedSettingSaveConflictException : IOException { public SharedSettingSaveConflictException(SharedSettingRecord current) : base(string.IsNullOrEmpty(current.SavedBy) ? $"These settings were changed on another workstation at {current.SavedAt:g}." : $"These settings were changed by {current.SavedBy} at {current.SavedAt:g}.") { Current = current; } public SharedSettingRecord Current { get; } } /// A shared settings update found the document deleted; nothing was changed. public sealed class SharedSettingMissingException(string kind, string key) : IOException($"The shared {kind} settings '{key}' were deleted on another workstation.") { }