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.")
{
}