diff --git a/OpenNest.Data/ClampSettings.cs b/OpenNest.Data/ClampSettings.cs index b666081b..9d57219b 100644 --- a/OpenNest.Data/ClampSettings.cs +++ b/OpenNest.Data/ClampSettings.cs @@ -194,19 +194,39 @@ public sealed class ClampSettings /// public static ClampSettings Load(string path, out ClampSettingsStatus status) { - var settings = Fallback; if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) { status = ClampSettingsStatus.Missing; - return settings; + return Fallback; } + string json; + try + { + json = File.ReadAllText(path); + } + catch (Exception ex) when (IsUnreadableFile(ex)) + { + status = ClampSettingsStatus.Invalid; + return Fallback; + } + return Parse(json, out status); + } + + /// + /// Reads settings from (a file or the shared copy) the way + /// reads a file, never throwing. + /// + public static ClampSettings Parse(string json, out ClampSettingsStatus status) + { + var settings = Fallback; + // Read as a raw document so a missing field (fallback, Ok) is distinguishable from a // present but unusable one, including an explicit null (fallback, Partial). Dictionary values; try { - using var document = JsonDocument.Parse(File.ReadAllText(path)); + using var document = JsonDocument.Parse(json ?? ""); if (document.RootElement.ValueKind != JsonValueKind.Object) { status = ClampSettingsStatus.Invalid; @@ -307,24 +327,7 @@ public sealed class ClampSettings /// public void Save(string path, int maxRetries = 3) { - var dto = new Dictionary - { - ["version"] = CurrentVersion, - ["enabled"] = Enabled, - ["units"] = Units.ToString().ToLowerInvariant(), - ["width"] = Width, - ["depth"] = Depth, - ["removalMinutes"] = RemovalMinutes, - ["centers"] = Centers, - }; - // Other edges are written only when used, so origin-side-only files stay unchanged. - if (FarSideCenters.Count > 0) - dto["farSideCenters"] = FarSideCenters; - if (OriginEndCenters.Count > 0) - dto["originEndCenters"] = OriginEndCenters; - if (FarEndCenters.Count > 0) - dto["farEndCenters"] = FarEndCenters; - var json = JsonSerializer.Serialize(dto, JsonOptions); + var json = ToJson(); var directory = Path.GetDirectoryName(Path.GetFullPath(path)); if (!string.IsNullOrEmpty(directory)) @@ -344,6 +347,29 @@ public sealed class ClampSettings } } + /// The settings as writes them, for the shared copy. + public string ToJson() + { + var dto = new Dictionary + { + ["version"] = CurrentVersion, + ["enabled"] = Enabled, + ["units"] = Units.ToString().ToLowerInvariant(), + ["width"] = Width, + ["depth"] = Depth, + ["removalMinutes"] = RemovalMinutes, + ["centers"] = Centers, + }; + // Other edges are written only when used, so origin-side-only files stay unchanged. + if (FarSideCenters.Count > 0) + dto["farSideCenters"] = FarSideCenters; + if (OriginEndCenters.Count > 0) + dto["originEndCenters"] = OriginEndCenters; + if (FarEndCenters.Count > 0) + dto["farEndCenters"] = FarEndCenters; + return JsonSerializer.Serialize(dto, JsonOptions); + } + private static double Scale(Units from, Units to) => from == to ? 1 : from == Units.Inches ? MillimetersPerInch diff --git a/OpenNest.Data/LocalJsonProvider.cs b/OpenNest.Data/LocalJsonProvider.cs index fe13ac0b..11dac4a1 100644 --- a/OpenNest.Data/LocalJsonProvider.cs +++ b/OpenNest.Data/LocalJsonProvider.cs @@ -7,7 +7,7 @@ public class LocalJsonProvider : IDataProvider { private readonly string _directory; - private static readonly JsonSerializerOptions JsonOptions = new() + internal static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, diff --git a/OpenNest.Data/NestDefaults.cs b/OpenNest.Data/NestDefaults.cs index 16a3d404..5967f3d5 100644 --- a/OpenNest.Data/NestDefaults.cs +++ b/OpenNest.Data/NestDefaults.cs @@ -95,26 +95,43 @@ public sealed class NestDefaults out NestDefaultsStatus status ) { - var defaults = Fallback; - if (Enum.IsDefined(fallbackUnits)) - defaults.Units = fallbackUnits; - if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) { status = NestDefaultsStatus.Missing; - return defaults; + return WithUnits(Fallback, fallbackUnits); } - NestDefaultsDto? dto; + string json; try { - var json = File.ReadAllText(path); - dto = JsonSerializer.Deserialize(json, JsonOptions); + json = File.ReadAllText(path); } catch (Exception ex) when (IsUnreadableFile(ex)) { // A file that exists but cannot be read (locked, access-denied) - // or parsed must never block creating a nest. + // must never block creating a nest. + status = NestDefaultsStatus.Invalid; + return WithUnits(Fallback, fallbackUnits); + } + return Parse(json, fallbackUnits, out status); + } + + /// + /// Reads defaults from (a file or the shared copy) the way + /// reads a file, never throwing. + /// + public static NestDefaults Parse(string json, Units fallbackUnits, out NestDefaultsStatus status) + { + var defaults = WithUnits(Fallback, fallbackUnits); + + NestDefaultsDto? dto; + try + { + dto = JsonSerializer.Deserialize(json ?? "", JsonOptions); + } + catch (Exception ex) when (IsUnreadableFile(ex)) + { + // Defaults that cannot be parsed must never block creating a nest. status = NestDefaultsStatus.Invalid; return defaults; } @@ -162,6 +179,13 @@ public sealed class NestDefaults return defaults; } + private static NestDefaults WithUnits(NestDefaults defaults, Units units) + { + if (Enum.IsDefined(units)) + defaults.Units = units; + return defaults; + } + /// /// Captures the current units and plate defaults from a nest. /// @@ -237,24 +261,7 @@ public sealed class NestDefaults /// public void Save(string path, int maxRetries = 3) { - var dto = new NestDefaultsDto - { - Version = CurrentVersion, - Units = JsonSerializer.SerializeToElement(Units.ToString().ToLowerInvariant()), - Size = new SizeDto { Width = Size.Width, Length = Size.Length }, - Quadrant = Quadrant, - PartSpacing = PartSpacing, - EdgeSpacing = new SpacingDto - { - Left = EdgeSpacing.Left, - Bottom = EdgeSpacing.Bottom, - Right = EdgeSpacing.Right, - Top = EdgeSpacing.Top, - }, - MachineId = MachineId is { } machineId ? JsonSerializer.SerializeToElement(machineId.ToString()) : null, - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); + var json = ToJson(); var directory = Path.GetDirectoryName(Path.GetFullPath(path)); if (!string.IsNullOrEmpty(directory)) @@ -274,6 +281,29 @@ public sealed class NestDefaults } } + /// The defaults as writes them, for the shared copy. + public string ToJson() + { + var dto = new NestDefaultsDto + { + Version = CurrentVersion, + Units = JsonSerializer.SerializeToElement(Units.ToString().ToLowerInvariant()), + Size = new SizeDto { Width = Size.Width, Length = Size.Length }, + Quadrant = Quadrant, + PartSpacing = PartSpacing, + EdgeSpacing = new SpacingDto + { + Left = EdgeSpacing.Left, + Bottom = EdgeSpacing.Bottom, + Right = EdgeSpacing.Right, + Top = EdgeSpacing.Top, + }, + MachineId = MachineId is { } machineId ? JsonSerializer.SerializeToElement(machineId.ToString()) : null, + }; + + return JsonSerializer.Serialize(dto, JsonOptions); + } + /// /// Accepts only a defined unit name (case-insensitive). Enum.TryParse /// would also accept numeric strings ("7") and comma-joined names, diff --git a/OpenNest.Data/RemoteSharedSettings.cs b/OpenNest.Data/RemoteSharedSettings.cs new file mode 100644 index 00000000..177d0ba2 --- /dev/null +++ b/OpenNest.Data/RemoteSharedSettings.cs @@ -0,0 +1,151 @@ +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.") +{ +} diff --git a/OpenNest.Data/SharedMachineProvider.cs b/OpenNest.Data/SharedMachineProvider.cs new file mode 100644 index 00000000..7549b511 --- /dev/null +++ b/OpenNest.Data/SharedMachineProvider.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +namespace OpenNest.Data; + +/// +/// The machine configurations shared through the nest server, in the same JSON as +/// 's files. Reads come from the store's last-read copy; saves +/// and deletes go to the server and throw when it cannot take them. +/// +public sealed class SharedMachineProvider(SharedSettingsStore store) : IDataProvider +{ + public IReadOnlyList GetMachines() => + store.List(SharedSettingKinds.Machine) + .Select(Read) + .OfType() + .Select(machine => new MachineSummary(machine.Id, machine.Name)) + .ToList(); + + public MachineConfig? GetMachine(Guid id) => + store.Get(SharedSettingKinds.Machine, id.ToString("D")) is { } record ? Read(record) : null; + + public void SaveMachine(MachineConfig machine) => + store.Save(SharedSettingKinds.Machine, machine.Id.ToString("D"), Serialize(machine)); + + public void DeleteMachine(Guid id) => store.Delete(SharedSettingKinds.Machine, id.ToString("D")); + + /// A machine's shared document, the same JSON its local file holds. + public static string Serialize(MachineConfig machine) => JsonSerializer.Serialize(machine, LocalJsonProvider.JsonOptions); + + private static MachineConfig? Read(SharedSettingRecord record) + { + try + { + return JsonSerializer.Deserialize(record.Json, LocalJsonProvider.JsonOptions); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/OpenNest.Data/SharedSettings.cs b/OpenNest.Data/SharedSettings.cs new file mode 100644 index 00000000..fa0e9f5f --- /dev/null +++ b/OpenNest.Data/SharedSettings.cs @@ -0,0 +1,121 @@ +using System.Text.Json; + +namespace OpenNest.Data; + +/// +/// One settings document every workstation shares through the nest server +/// (/api/settings): a machine configuration, a post's settings, the clamp rail or the +/// nest defaults. is the document exactly as its owner serializes it; +/// guards every change. +/// +public sealed class SharedSettingRecord +{ + public string Kind { get; set; } = ""; + + public string Key { get; set; } = ""; + + public int Revision { get; set; } + + public string Json { get; set; } = ""; + + public DateTime SavedAt { get; set; } + + public string SavedBy { get; set; } = ""; +} + +/// The body of a shared settings create or update. +public sealed class SharedSettingWrite +{ + public string Json { get; set; } = ""; + + public string SavedBy { get; set; } = ""; +} + +/// The kinds of shared settings and the keys of the single-document kinds. +public static class SharedSettingKinds +{ + /// A , keyed by its id. + public const string Machine = "machine"; + + /// A post processor's settings, keyed by the post's type name. + public const string Post = "post"; + + /// The new nests start from, under . + public const string Clamps = "clamps"; + + /// The new nests start from, under . + public const string NestDefaults = "nest-defaults"; + + /// The key of the clamp settings and nest defaults documents. + public const string DefaultKey = "default"; + + public static IReadOnlyList All { get; } = [Machine, Post, Clamps, NestDefaults]; + + public static bool IsKnown(string? kind) => kind is not null && All.Contains(kind); +} + +/// Validation shared by the nest server and its clients. +public static class SharedSettingRules +{ + public const int MaxKeyLength = 200; + + public const int MaxSavedByLength = 200; + + /// + /// A settings document's limit in characters: far above the largest post configuration, and + /// small enough that the escaped document fits the server's request limit. + /// + public const int MaxJsonLength = 512 * 1024; + + /// + /// The key as stored: a machine id in lower-case "D" form, any other key as given. Null when + /// the key is not valid for the kind. + /// + public static string? NormalizeKey(string? kind, string? key) + { + if (!SharedSettingKinds.IsKnown(kind) || string.IsNullOrEmpty(key) || key.Length > MaxKeyLength) + return null; + if (kind == SharedSettingKinds.Machine) + return Guid.TryParse(key, out var id) && id != Guid.Empty ? id.ToString("D") : null; + if (kind is SharedSettingKinds.Clamps or SharedSettingKinds.NestDefaults) + return key == SharedSettingKinds.DefaultKey ? key : null; + return key.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-' or '+') ? key : null; + } + + /// Why the document cannot be stored under the kind and key, or null when it can. + public static string? GetError(string? kind, string? key, SharedSettingWrite? write) + { + if (!SharedSettingKinds.IsKnown(kind)) + return $"Unknown settings kind '{kind}'."; + var normalized = NormalizeKey(kind, key); + if (normalized is null) + return $"Invalid {kind} settings key."; + if (write is null || string.IsNullOrEmpty(write.Json)) + return "The settings document is required."; + if (write.Json.Length > MaxJsonLength) + return "The settings document is too large."; + if ((write.SavedBy ?? "").Length > MaxSavedByLength) + return "savedBy is too long."; + + try + { + using var document = JsonDocument.Parse(write.Json); + if (document.RootElement.ValueKind != JsonValueKind.Object) + return "The settings document must be a JSON object."; + } + catch (JsonException) + { + return "The settings document is not valid JSON."; + } + + if (kind == SharedSettingKinds.Machine) + { + MachineConfig? machine; + try { machine = JsonSerializer.Deserialize(write.Json, LocalJsonProvider.JsonOptions); } + catch (JsonException) { return "The machine configuration is not valid."; } + if (machine is null || machine.Id.ToString("D") != normalized) + return "The machine configuration's id does not match its key."; + } + return null; + } +} diff --git a/OpenNest.Data/SharedSettingsStore.cs b/OpenNest.Data/SharedSettingsStore.cs new file mode 100644 index 00000000..22ccb53b --- /dev/null +++ b/OpenNest.Data/SharedSettingsStore.cs @@ -0,0 +1,226 @@ +using System.Text.Json; + +namespace OpenNest.Data; + +/// +/// The shared settings as last read from the nest server, for callers that read settings +/// synchronously (nest creation, posting). Every successful read is copied to a cache file; +/// when the server cannot be reached the cached copy is served instead and +/// is set. Writes always go to the server, so nothing can be changed +/// while it is unreachable. Thread-safe. +/// +public sealed class SharedSettingsStore : IDisposable +{ + private static readonly JsonSerializerOptions CacheOptions = new(NestServerHttp.JsonOptions) { WriteIndented = true }; + + private readonly object _sync = new(); + private readonly RemoteSharedSettings _remote; + private readonly string _cachePath; + private readonly string _savedBy; + private List? _records; + + /// The file holding this server's last-read copy. + /// Who saves through this store, recorded with every change. + public SharedSettingsStore(RemoteSharedSettings remote, string cachePath, string savedBy) + { + _remote = remote; + _cachePath = cachePath; + _savedBy = savedBy ?? ""; + } + + /// The last refresh could not reach the server; reads come from the cached copy, if any. + public bool IsOffline { get; private set; } + + /// The server predates shared settings: there are none, and none can be saved. + public bool IsUnsupported { get; private set; } + + /// When the settings being served were read from the server, or null when there are none. + public DateTime? ReadAt { get; private set; } + + /// Why the last refresh failed, or null after a successful one. + public Exception? LastError { get; private set; } + + /// When the last refresh was attempted, successful or not. + public DateTime? RefreshedAt { get; private set; } + + /// + /// Reads every document from the server. Returns false when the server could not be + /// reached; the cached copy (or nothing) is then served. + /// + public bool Refresh() + { + try + { + var records = Task.Run(() => _remote.ListAsync()).GetAwaiter().GetResult(); + lock (_sync) + { + _records = records.ToList(); + ReadAt = DateTime.Now; + RefreshedAt = ReadAt; + IsOffline = false; + IsUnsupported = false; + LastError = null; + WriteCache(); + } + return true; + } + catch (ServerTooOldException ex) + { + lock (_sync) + { + _records = new List(); + ReadAt = null; + RefreshedAt = DateTime.Now; + IsOffline = false; + IsUnsupported = true; + LastError = ex; + } + return true; + } + catch (Exception ex) when (IsUnreachable(ex)) + { + lock (_sync) + { + if (_records is null) + ReadCache(); + RefreshedAt = DateTime.Now; + IsOffline = true; + LastError = ex; + } + return false; + } + } + + /// Every document of , by key. + public IReadOnlyList List(string kind) + { + lock (_sync) + return (_records ?? []).Where(record => record.Kind == kind).OrderBy(record => record.Key, StringComparer.Ordinal).ToList(); + } + + /// The document of under , or null when none is shared. + public SharedSettingRecord? Get(string kind, string key) + { + var normalized = SharedSettingRules.NormalizeKey(kind, key); + lock (_sync) + return _records?.FirstOrDefault(record => record.Kind == kind && record.Key == normalized); + } + + /// + /// Stores on the server, creating the document or replacing the + /// revision last read. Unchanged JSON is not sent. Throws + /// (after taking the newer document) when someone else changed it, and + /// when the server cannot be reached. + /// + public SharedSettingRecord Save(string kind, string key, string json) + { + var current = Get(kind, key); + if (current is not null && current.Json == json) + return current; + var write = new SharedSettingWrite { Json = json, SavedBy = _savedBy }; + return Send(kind, key, async () => current is null + ? await _remote.CreateAsync(kind, key, write) + : await _remote.UpdateAsync(kind, key, write, current.Revision)); + } + + /// Deletes the document, if shared. Throws as does. + public void Delete(string kind, string key) + { + if (Get(kind, key) is not { } current) + return; + Send(kind, key, async () => + { + await _remote.DeleteAsync(kind, key, _savedBy, current.Revision); + return null; + }); + } + + private SharedSettingRecord Send(string kind, string key, Func> send) + { + SharedSettingRecord? saved; + try + { + saved = Task.Run(send).GetAwaiter().GetResult(); + } + catch (SharedSettingSaveConflictException conflict) + { + lock (_sync) + Replace(kind, key, conflict.Current); + throw; + } + catch (SharedSettingMissingException) + { + lock (_sync) + Replace(kind, key, null); + throw; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + throw new IOException( + "The nest server could not be reached, so the shared settings cannot be changed until it is back. " + ex.Message, ex); + } + + lock (_sync) + Replace(kind, key, saved); + return saved!; + } + + private void Replace(string kind, string key, SharedSettingRecord? record) + { + var normalized = SharedSettingRules.NormalizeKey(kind, key); + _records ??= new List(); + _records.RemoveAll(existing => existing.Kind == kind && existing.Key == normalized); + if (record is not null) + _records.Add(record); + if (!IsOffline) + WriteCache(); + } + + private void WriteCache() + { + try + { + var directory = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(_cachePath)); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + var temporary = _cachePath + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(new Cache { ReadAt = ReadAt, Records = _records ?? [] }, CacheOptions)); + File.Move(temporary, _cachePath, overwrite: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The copy only matters while the server is down; the settings themselves are on the server. + } + } + + private void ReadCache() + { + try + { + if (!File.Exists(_cachePath)) + return; + var cache = JsonSerializer.Deserialize(File.ReadAllText(_cachePath), CacheOptions); + if (cache?.Records is null || cache.Records.Any(record => record is null)) + return; + _records = cache.Records.Where(record => SharedSettingKinds.IsKnown(record.Kind)).ToList(); + ReadAt = cache.ReadAt; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + // An unreadable copy is the same as none. + } + } + + // A server error is treated like an unreachable server: the cached copy is still the best available. + private static bool IsUnreachable(Exception ex) => + ex is HttpRequestException or TaskCanceledException or IOException; + + public void Dispose() => _remote.Dispose(); + + private sealed class Cache + { + public DateTime? ReadAt { get; set; } + + public List Records { get; set; } = []; + } +} diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index 6585b4b7..99f32db8 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -22,7 +22,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items); Assert.Equal(before, NestDigest(factory.DatabasePath)); - Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -43,7 +43,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) { - Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(before, NestDigest(factory.DatabasePath)); // Two distinct blobs across four revisions: the bracket's notes edit and the // plate both reuse the bracket's first package. @@ -80,7 +80,7 @@ public sealed class DrawingLibraryDatabaseTests { using var factory = new ServerFactory(); using (new NestDatabase(factory.DatabasePath, pooling: false)) { } - Execute(factory.DatabasePath, "PRAGMA user_version=13"); + Execute(factory.DatabasePath, "PRAGMA user_version=14"); Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); } diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs index 86b0ffb7..e7aecf67 100644 --- a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs @@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items); Assert.Equal(before, Digest(factory.DatabasePath)); - Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests Assert.Equal(before, Digest(factory.DatabasePath)); Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing"); using var database = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } [Fact] diff --git a/OpenNest.Server.Tests/MaterialListTests.cs b/OpenNest.Server.Tests/MaterialListTests.cs index 9aa24cbf..988446e8 100644 --- a/OpenNest.Server.Tests/MaterialListTests.cs +++ b/OpenNest.Server.Tests/MaterialListTests.cs @@ -24,7 +24,7 @@ public sealed class MaterialListTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Empty(upgraded.ListMaterials()); Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade)); Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName); diff --git a/OpenNest.Server.Tests/NestMaterialGradeTests.cs b/OpenNest.Server.Tests/NestMaterialGradeTests.cs index f51502bb..34b30a89 100644 --- a/OpenNest.Server.Tests/NestMaterialGradeTests.cs +++ b/OpenNest.Server.Tests/NestMaterialGradeTests.cs @@ -74,7 +74,7 @@ public sealed class NestMaterialGradeTests Assert.Equal("Stainless", record.Material); Assert.Equal("", record.MaterialGrade); Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade); - Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static NestRecord Record(string name, string material, string grade) => new() diff --git a/OpenNest.Server.Tests/NestUnitsTests.cs b/OpenNest.Server.Tests/NestUnitsTests.cs index b2262637..cf53256a 100644 --- a/OpenNest.Server.Tests/NestUnitsTests.cs +++ b/OpenNest.Server.Tests/NestUnitsTests.cs @@ -76,7 +76,7 @@ public sealed class NestUnitsTests Assert.Equal([Units.Millimeters, Units.Millimeters], upgraded.History(metric, 0, 10)!.Items.Select(r => r.Units)); Assert.Equal(Units.Inches, upgraded.Get(inch)!.Units); Assert.Equal(Units.Inches, upgraded.Get(unreadable)!.Units); - Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static NestRecord Record(string name, Units units) => new() diff --git a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs index 26c25774..94f513d2 100644 --- a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs +++ b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs @@ -151,7 +151,7 @@ public sealed class SetReleaseDrawingTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version")); var kept = upgraded.GetRelease(releaseId)!; Assert.Empty(kept.Drawings); Assert.Equal(((Guid?)kit.Id, drawing, 2), (kept.Nests[0].Items.Single().SetId, kept.Nests[0].Items.Single().DrawingId, kept.Nests[0].Items.Single().Quantity)); diff --git a/OpenNest.Server.Tests/SetReleaseTests.cs b/OpenNest.Server.Tests/SetReleaseTests.cs index fd8e4493..a25d4dc7 100644 --- a/OpenNest.Server.Tests/SetReleaseTests.cs +++ b/OpenNest.Server.Tests/SetReleaseTests.cs @@ -294,7 +294,7 @@ public sealed class SetReleaseTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'set_release%' OR name LIKE 'ix_set_release%'")); var release = upgraded.GetRelease(releaseId)!; Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone)); diff --git a/OpenNest.Server.Tests/SharedSettingsTests.cs b/OpenNest.Server.Tests/SharedSettingsTests.cs new file mode 100644 index 00000000..1df18522 --- /dev/null +++ b/OpenNest.Server.Tests/SharedSettingsTests.cs @@ -0,0 +1,193 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.Data.Sqlite; +using OpenNest.Data; + +namespace OpenNest.Server.Tests; + +/// +/// The settings every workstation shares (/api/settings): revision-guarded documents, +/// the v13 upgrade, the HTTP contract, and the desktop's store with its offline copy. +/// +public sealed class SharedSettingsTests +{ + private const string PostKey = "Synthetic.Posts.Laser"; + + [Fact] + public void Database_RevisesConflictsAndContinuesRevisionsAfterADelete() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + + var (created, isNew) = database.CreateSharedSetting("post", PostKey, Write("""{"feed":1}""")); + var (same, sameIsNew) = database.CreateSharedSetting("post", PostKey, Write("""{"feed":1}""")); + var updated = database.UpdateSharedSetting("post", PostKey, Write("""{"feed":2}"""), created.Revision); + var unchanged = database.UpdateSharedSetting("post", PostKey, Write("""{"feed":2}"""), updated!.Revision); + + Assert.Equal((1, true, 1, false, 2, 2), (created.Revision, isNew, same.Revision, sameIsNew, updated.Revision, unchanged!.Revision)); + var stale = Assert.Throws(() => + database.UpdateSharedSetting("post", PostKey, Write("""{"feed":3}"""), created.Revision)); + Assert.Equal(2, stale.Current.Revision); + Assert.Throws(() => database.CreateSharedSetting("post", PostKey, Write("""{"feed":3}"""))); + + Assert.True(database.DeleteSharedSetting("post", PostKey, "tester", 2)); + Assert.Null(database.GetSharedSetting("post", PostKey)); + Assert.Empty(database.ListSharedSettings()); + Assert.Null(database.UpdateSharedSetting("post", PostKey, Write("""{"feed":4}"""), 3)); + + var (recreated, _) = database.CreateSharedSetting("post", PostKey, Write("""{"feed":5}""")); + Assert.Equal(4, recreated.Revision); + Assert.Equal(4L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM shared_setting_revisions")); + Assert.Equal("tester", Assert.Single(database.ListSharedSettings()).SavedBy); + } + + [Fact] + public void Database_StoresMachinesUnderTheirLowerCaseId() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var machine = new MachineConfig { Name = "Synthetic laser" }; + + database.CreateSharedSetting("machine", machine.Id.ToString().ToUpperInvariant(), Write(SharedMachineProvider.Serialize(machine))); + + Assert.Equal(machine.Id.ToString("D"), Assert.Single(database.ListSharedSettings()).Key); + Assert.NotNull(database.GetSharedSetting("machine", machine.Id.ToString().ToUpperInvariant())); + Assert.Throws(() => + database.CreateSharedSetting("machine", Guid.NewGuid().ToString(), Write(SharedMachineProvider.Serialize(machine)))); + } + + [Fact] + public void V12Upgrade_StartsWithNoSharedSettings() + { + using var factory = new ServerFactory(); + using (new NestDatabase(factory.DatabasePath, pooling: false)) { } + Execute(factory.DatabasePath, + "DROP TABLE shared_setting_revisions; DROP TABLE shared_settings; PRAGMA user_version=12;"); + + using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); + + Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Empty(upgraded.ListSharedSettings()); + } + + [Fact] + public async Task Api_FollowsTheCreateAndIfMatchContract() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var url = $"/api/settings/post/{PostKey}"; + + var created = await client.PostAsJsonAsync(url, Write("""{"feed":1}""")); + var again = await client.PostAsJsonAsync(url, Write("""{"feed":1}""")); + var different = await client.PostAsJsonAsync(url, Write("""{"feed":2}""")); + var unguarded = await client.PutAsJsonAsync(url, Write("""{"feed":2}""")); + var stale = await Put(client, url, Write("""{"feed":2}"""), revision: 5); + var updated = await Put(client, url, Write("""{"feed":2}"""), revision: 1); + var unknownKind = await client.GetAsync($"/api/settings/printer/{PostKey}"); + var badKey = await client.PostAsJsonAsync("/api/settings/clamps/other", Write("{}")); + var notObject = await client.PostAsJsonAsync("/api/settings/post/Other.Post", Write("[1]")); + var extraField = await client.PostAsync("/api/settings/post/Other.Post", + new StringContent("""{"json":"{}","savedBy":"","revision":1}""", MediaTypeHeaderValue.Parse("application/json"))); + + Assert.Equal( + new[] { HttpStatusCode.Created, HttpStatusCode.OK, HttpStatusCode.Conflict, (HttpStatusCode)428, + HttpStatusCode.Conflict, HttpStatusCode.OK, HttpStatusCode.BadRequest, HttpStatusCode.BadRequest, + HttpStatusCode.BadRequest, HttpStatusCode.BadRequest }, + new[] { created.StatusCode, again.StatusCode, different.StatusCode, unguarded.StatusCode, stale.StatusCode, + updated.StatusCode, unknownKind.StatusCode, badKey.StatusCode, notObject.StatusCode, extraField.StatusCode }); + Assert.Equal(1, (await different.Content.ReadFromJsonAsync())!.Revision); + Assert.Equal(2, (await updated.Content.ReadFromJsonAsync())!.Revision); + + using var delete = new HttpRequestMessage(HttpMethod.Delete, url + "?savedBy=tester"); + delete.Headers.IfMatch.Add(new EntityTagHeaderValue("\"2\"")); + Assert.Equal(HttpStatusCode.NoContent, (await client.SendAsync(delete)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(url)).StatusCode); + Assert.Empty((await client.GetFromJsonAsync>("/api/settings"))!); + } + + [Fact] + public void Store_SavesThroughTheServerAndTakesTheNewerDocumentOnAConflict() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var cache = Path.Combine(factory.DirectoryPath, "cache", "settings.json"); + using var mine = Store(client, cache, "me"); + using var theirs = Store(client, Path.Combine(factory.DirectoryPath, "theirs.json"), "them"); + var machine = new MachineConfig { Name = "Synthetic laser" }; + + Assert.True(mine.Refresh()); + new SharedMachineProvider(mine).SaveMachine(machine); + mine.Save("post", PostKey, """{"feed":1}"""); + Assert.True(theirs.Refresh()); + theirs.Save("post", PostKey, """{"feed":2}"""); + + var conflict = Assert.Throws(() => mine.Save("post", PostKey, """{"feed":3}""")); + Assert.Equal(("them", """{"feed":2}"""), (conflict.Current.SavedBy, mine.Get("post", PostKey)!.Json)); + Assert.Equal("""{"feed":3}""", mine.Save("post", PostKey, """{"feed":3}""").Json); + + var provider = new SharedMachineProvider(theirs); + Assert.Equal("Synthetic laser", Assert.Single(provider.GetMachines()).Name); + Assert.Equal(machine.Id, provider.GetMachine(machine.Id)!.Id); + provider.DeleteMachine(machine.Id); + Assert.True(mine.Refresh()); + Assert.Empty(mine.List("machine")); + Assert.True(File.Exists(cache)); + } + + [Fact] + public void Store_ServesTheLastCopyReadOnlyWhileTheServerIsUnreachable() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var cache = Path.Combine(factory.DirectoryPath, "settings.json"); + using (var online = Store(client, cache, "me")) + { + online.Refresh(); + online.Save("post", PostKey, """{"feed":1}"""); + } + + // Nothing listens on the discard port, so every request fails as an unreachable server does. + using var offline = new SharedSettingsStore( + new RemoteSharedSettings("http://127.0.0.1:9/", TimeSpan.FromSeconds(2)), cache, "me"); + + Assert.False(offline.Refresh()); + Assert.True(offline.IsOffline); + Assert.NotNull(offline.ReadAt); + Assert.Equal("""{"feed":1}""", offline.Get("post", PostKey)!.Json); + Assert.Same(offline.Get("post", PostKey), offline.Save("post", PostKey, """{"feed":1}""")); + var refused = Assert.ThrowsAny(() => offline.Save("post", PostKey, """{"feed":2}""")); + Assert.IsNotType(refused); + Assert.Equal("""{"feed":1}""", offline.Get("post", PostKey)!.Json); + } + + private static SharedSettingsStore Store(HttpClient client, string cache, string savedBy) => + new(new RemoteSharedSettings(client, client.BaseAddress!.ToString()), cache, savedBy); + + private static SharedSettingWrite Write(string json) => new() { Json = json, SavedBy = "tester" }; + + private static async Task Put(HttpClient client, string url, SharedSettingWrite write, int revision) + { + using var request = new HttpRequestMessage(HttpMethod.Put, url) { Content = JsonContent.Create(write) }; + request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{revision}\"")); + return await client.SendAsync(request); + } + + private static void Execute(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + private static object? Scalar(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } +} diff --git a/OpenNest.Server/NestDatabase.Settings.cs b/OpenNest.Server/NestDatabase.Settings.cs new file mode 100644 index 00000000..581965e7 --- /dev/null +++ b/OpenNest.Server/NestDatabase.Settings.cs @@ -0,0 +1,192 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// Settings every workstation shares (): shared_settings +/// holds each document's current revision and shared_setting_revisions every revision, +/// including deletions, so a bad change can be traced and recovered by hand. A deleted document +/// keeps its row; creating it again continues its revision count, so a client holding an old +/// revision can never overwrite the new one. +/// +public sealed partial class NestDatabase +{ + private const string SettingColumnDefinitions = """ + kind TEXT NOT NULL, + key TEXT NOT NULL, + revision INTEGER NOT NULL, + json TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + savedAt TEXT NOT NULL, + savedBy TEXT NOT NULL DEFAULT '' + """; + + // v12 -> v13. The tables start empty: the first workstation offers to upload its own settings. + private const string SharedSettingSchema = $""" + CREATE TABLE IF NOT EXISTS shared_settings ({SettingColumnDefinitions}, PRIMARY KEY (kind, key)); + CREATE TABLE IF NOT EXISTS shared_setting_revisions ({SettingColumnDefinitions}, PRIMARY KEY (kind, key, revision)); + PRAGMA user_version=13; + """; + + private const string SettingColumns = "kind, key, revision, json, deleted, savedAt, savedBy"; + + /// Every current, undeleted settings document, by kind then key. + public IReadOnlyList ListSharedSettings() + { + lock (_sync) + { + using var command = _connection.CreateCommand(); + command.CommandText = $"SELECT {SettingColumns} FROM shared_settings WHERE deleted=0 ORDER BY kind, key"; + using var reader = command.ExecuteReader(); + var records = new List(); + while (reader.Read()) + records.Add(ReadSetting(reader).Record); + return records; + } + } + + /// The current document, or null when it was never stored or is deleted. + public SharedSettingRecord? GetSharedSetting(string kind, string key) + { + var stored = FindSetting(kind, key); + return stored is { Deleted: false } ? stored.Value.Record : null; + } + + /// + /// Stores a new document (revision 1, or the next revision of a deleted one). A document + /// already stored is a conflict carrying it, unless it holds the same JSON, which is a no-op. + /// + public (SharedSettingRecord Record, bool Created) CreateSharedSetting(string kind, string key, SharedSettingWrite write) + { + key = Validate(kind, key, write); + lock (_sync) + { + using var transaction = _connection.BeginTransaction(); + var stored = FindSetting(kind, key); + if (stored is { Deleted: false } current) + { + if (current.Record.Json == write.Json) + return (current.Record, false); + throw new SharedSettingConflictException(current.Record, "These settings already exist. Reload before saving."); + } + + var record = WriteSetting(kind, key, write, (stored?.Record.Revision ?? 0) + 1, deleted: false, transaction); + transaction.Commit(); + return (record, true); + } + } + + /// + /// Appends a revision. Returns null when the document is not stored (or deleted); a stale + /// revision is a conflict, and the same JSON is a no-op. + /// + public SharedSettingRecord? UpdateSharedSetting(string kind, string key, SharedSettingWrite write, int expectedRevision) + { + key = Validate(kind, key, write); + lock (_sync) + { + using var transaction = _connection.BeginTransaction(); + if (FindSetting(kind, key) is not { Deleted: false } current) + return null; + if (current.Record.Revision != expectedRevision) + throw new SharedSettingConflictException(current.Record, "These settings were changed by someone else. Reload before saving."); + if (current.Record.Json == write.Json) + return current.Record; + + var record = WriteSetting(kind, key, write, checked(current.Record.Revision + 1), deleted: false, transaction); + transaction.Commit(); + return record; + } + } + + /// Deletes the document as a new revision. Returns false when it is not stored (or already deleted). + public bool DeleteSharedSetting(string kind, string key, string savedBy, int expectedRevision) + { + key = SharedSettingRules.NormalizeKey(kind, key) ?? throw new ArgumentException($"Invalid {kind} settings key.", nameof(key)); + lock (_sync) + { + using var transaction = _connection.BeginTransaction(); + if (FindSetting(kind, key) is not { Deleted: false } current) + return false; + if (current.Record.Revision != expectedRevision) + throw new SharedSettingConflictException(current.Record, "These settings were changed by someone else. Reload before deleting."); + + var write = new SharedSettingWrite { Json = current.Record.Json, SavedBy = savedBy ?? "" }; + WriteSetting(kind, key, write, checked(current.Record.Revision + 1), deleted: true, transaction); + transaction.Commit(); + return true; + } + } + + private static string Validate(string kind, string key, SharedSettingWrite write) + { + if (SharedSettingRules.GetError(kind, key, write) is { } error) + throw new ArgumentException(error, nameof(write)); + return SharedSettingRules.NormalizeKey(kind, key)!; + } + + private (SharedSettingRecord Record, bool Deleted)? FindSetting(string kind, string? key) + { + key = SharedSettingRules.NormalizeKey(kind, key); + if (key is null) + return null; + lock (_sync) + { + using var command = _connection.CreateCommand(); + command.CommandText = $"SELECT {SettingColumns} FROM shared_settings WHERE kind=$kind AND key=$key"; + command.Parameters.AddWithValue("$kind", kind); + command.Parameters.AddWithValue("$key", key); + using var reader = command.ExecuteReader(); + return reader.Read() ? ReadSetting(reader) : null; + } + } + + private SharedSettingRecord WriteSetting(string kind, string key, SharedSettingWrite write, int revision, bool deleted, + SqliteTransaction transaction) + { + var record = new SharedSettingRecord + { + Kind = kind, + Key = key, + Revision = revision, + Json = write.Json, + SavedAt = DateTime.Now, + SavedBy = write.SavedBy ?? "", + }; + foreach (var statement in new[] { "INSERT OR REPLACE INTO shared_settings", "INSERT INTO shared_setting_revisions" }) + { + using var command = _connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = $"{statement} ({SettingColumns}) VALUES ($kind, $key, $revision, $json, $deleted, $savedAt, $savedBy)"; + command.Parameters.AddWithValue("$kind", record.Kind); + command.Parameters.AddWithValue("$key", record.Key); + command.Parameters.AddWithValue("$revision", record.Revision); + command.Parameters.AddWithValue("$json", record.Json); + command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0); + command.Parameters.AddWithValue("$savedAt", record.SavedAt.ToString("o", CultureInfo.InvariantCulture)); + command.Parameters.AddWithValue("$savedBy", record.SavedBy); + command.ExecuteNonQuery(); + } + return record; + } + + private static (SharedSettingRecord Record, bool Deleted) ReadSetting(SqliteDataReader reader) => ( + new SharedSettingRecord + { + Kind = reader.GetString(0), + Key = reader.GetString(1), + Revision = reader.GetInt32(2), + Json = reader.GetString(3), + SavedAt = DateTime.Parse(reader.GetString(5), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + SavedBy = reader.GetString(6), + }, + reader.GetInt64(4) != 0); +} + +/// A guarded settings write found a different current document; nothing was changed. +public sealed class SharedSettingConflictException(SharedSettingRecord current, string message) : Exception(message) +{ + public SharedSettingRecord Current { get; } = current; +} diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index f1c1e242..e27a12e3 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -342,7 +342,7 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 12) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 13) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -429,6 +429,12 @@ public sealed partial class NestDatabase : IDisposable } if (version < 12) AddUnits(command); + if (version < 13) + { + // Settings every workstation shares, empty until a workstation uploads its own. + command.CommandText = SharedSettingSchema; + command.ExecuteNonQuery(); + } transaction.Commit(); } } diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index 4c211ff3..79b9e123 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -153,6 +153,7 @@ app.MapDrawingLibrary(); app.MapDrawingSets(); app.MapReleases(); app.MapMaterials(); +app.MapSettings(); app.Run(); diff --git a/OpenNest.Server/SettingRoutes.cs b/OpenNest.Server/SettingRoutes.cs new file mode 100644 index 00000000..c28a15d9 --- /dev/null +++ b/OpenNest.Server/SettingRoutes.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// /api/settings: the settings every workstation shares (machines, post settings, clamps +/// and nest defaults), one JSON document per kind and key. Creating returns 201, or 200 when the +/// same document is already stored; changes and deletes follow the If-Match contract (428 +/// missing, 400 malformed, 409 stale with the current document). +/// +internal static class SettingRoutes +{ + private static readonly JsonSerializerOptions WriteOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + public static void MapSettings(this IEndpointRouteBuilder app) + { + app.MapGet("/api/settings", (NestDatabase db) => Results.Ok(db.ListSharedSettings())); + + app.MapGet("/api/settings/{kind}/{key}", (string kind, string key, NestDatabase db) => + !SharedSettingKinds.IsKnown(kind) ? Results.BadRequest($"Unknown settings kind '{kind}'.") : + db.GetSharedSetting(kind, key) is { } record ? Results.Ok(record) : Results.NotFound()); + + app.MapPost("/api/settings/{kind}/{key}", async (string kind, string key, HttpRequest request, NestDatabase db) => + { + var (write, error) = await ReadWrite(request, kind, key); + if (error is not null) return error; + try + { + var (record, created) = db.CreateSharedSetting(kind, key, write!); + return created ? Results.Created($"/api/settings/{kind}/{record.Key}", record) : Results.Ok(record); + } + catch (SharedSettingConflictException conflict) { return Conflict(conflict); } + }); + + app.MapPut("/api/settings/{kind}/{key}", async (string kind, string key, HttpRequest request, NestDatabase db) => + { + var precondition = IfMatch.Read(request, out var expected); + if (precondition is not null) return precondition; + var (write, error) = await ReadWrite(request, kind, key); + if (error is not null) return error; + try + { + var updated = db.UpdateSharedSetting(kind, key, write!, expected); + return updated is null ? Results.NotFound() : Results.Ok(updated); + } + catch (SharedSettingConflictException conflict) { return Conflict(conflict); } + }); + + app.MapDelete("/api/settings/{kind}/{key}", (string kind, string key, string? savedBy, HttpRequest request, NestDatabase db) => + { + var precondition = IfMatch.Read(request, out var expected); + if (precondition is not null) return precondition; + if (SharedSettingRules.NormalizeKey(kind, key) is null) + return Results.BadRequest($"Invalid {kind} settings key."); + if ((savedBy ?? "").Length > SharedSettingRules.MaxSavedByLength) + return Results.BadRequest("savedBy is too long."); + try + { + return db.DeleteSharedSetting(kind, key, savedBy ?? "", expected) ? Results.NoContent() : Results.NotFound(); + } + catch (SharedSettingConflictException conflict) { return Conflict(conflict); } + }); + } + + private static IResult Conflict(SharedSettingConflictException conflict) => + Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict); + + private static async Task<(SharedSettingWrite? Write, IResult? Error)> ReadWrite(HttpRequest request, string kind, string key) + { + var (json, error) = await DrawingSetRoutes.ReadJson(request, "settings document"); + if (error is not null) return (null, error); + + SharedSettingWrite? write; + try { write = JsonSerializer.Deserialize(json!, WriteOptions); } + catch (JsonException) { return (null, Results.BadRequest("Invalid settings JSON.")); } + return SharedSettingRules.GetError(kind, key, write) is { } invalid + ? (null, Results.BadRequest(invalid)) + : (write, null); + } +} diff --git a/OpenNest.Tests/Data/SharedSettingsTests.cs b/OpenNest.Tests/Data/SharedSettingsTests.cs new file mode 100644 index 00000000..23cc1c99 --- /dev/null +++ b/OpenNest.Tests/Data/SharedSettingsTests.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using OpenNest.Data; +using OpenNest.Geometry; +using OpenNest.Posts.Cincinnati; +using OpenNest.Posts.CincinnatiCIFiber; +using OpenNest.Posts.GravographIS; + +namespace OpenNest.Tests.Data; + +/// +/// The documents every workstation shares through the nest server: their key and document +/// rules, and the JSON forms of clamps, nest defaults and post settings, which must read back +/// exactly what their files hold. +/// +public class SharedSettingsTests +{ + [Theory] + [InlineData("machine", "D3F0B2A4-0D7B-4B8C-9A55-3C8B7B0F1E22", "d3f0b2a4-0d7b-4b8c-9a55-3c8b7b0f1e22")] + [InlineData("machine", "00000000-0000-0000-0000-000000000000", null)] + [InlineData("machine", "CL-980", null)] + [InlineData("post", "OpenNest.Posts.CincinnatiCIFiber.CIFiberPostProcessor", "OpenNest.Posts.CincinnatiCIFiber.CIFiberPostProcessor")] + [InlineData("post", "../escape", null)] + [InlineData("post", "", null)] + [InlineData("clamps", "default", "default")] + [InlineData("clamps", "other", null)] + [InlineData("nest-defaults", "default", "default")] + [InlineData("unknown", "default", null)] + public void NormalizeKey_AcceptsOnlyEachKindsKeys(string kind, string key, string? expected) => + Assert.Equal(expected, SharedSettingRules.NormalizeKey(kind, key)); + + [Fact] + public void GetError_RequiresAJsonObjectAndAMatchingMachineId() + { + var machine = new MachineConfig { Name = "Synthetic laser" }; + var json = SharedMachineProvider.Serialize(machine); + + Assert.Null(SharedSettingRules.GetError("machine", machine.Id.ToString(), Write(json))); + Assert.NotNull(SharedSettingRules.GetError("machine", Guid.NewGuid().ToString(), Write(json))); + Assert.NotNull(SharedSettingRules.GetError("post", "Synthetic.Post", Write("[1, 2]"))); + Assert.NotNull(SharedSettingRules.GetError("post", "Synthetic.Post", Write("{ not json"))); + Assert.NotNull(SharedSettingRules.GetError("post", "Synthetic.Post", Write(""))); + Assert.NotNull(SharedSettingRules.GetError("post", "Synthetic.Post", Write(new string(' ', SharedSettingRules.MaxJsonLength + 1)))); + Assert.Null(SharedSettingRules.GetError("post", "Synthetic.Post", Write("{}"))); + } + + [Fact] + public void ClampSettings_ParseReadsWhatToJsonWrites() + { + var settings = new ClampSettings + { + Enabled = true, + Units = Units.Millimeters, + Width = 38, + Depth = 16, + RemovalMinutes = 0.5, + Centers = [150, 530], + FarEndCenters = [200], + }; + + var read = ClampSettings.Parse(settings.ToJson(), out var status); + + Assert.Equal(ClampSettingsStatus.Ok, status); + Assert.Equal(settings.ToJson(), read.ToJson()); + ClampSettings.Parse("{ not json", out var invalid); + Assert.Equal(ClampSettingsStatus.Invalid, invalid); + } + + [Fact] + public void NestDefaults_ParseReadsWhatToJsonWrites() + { + var defaults = new NestDefaults + { + Units = Units.Millimeters, + Size = new Size(1500, 3000), + Quadrant = 3, + PartSpacing = 6, + EdgeSpacing = new Spacing(5, 6, 7, 8), + MachineId = Guid.NewGuid(), + }; + + var read = NestDefaults.Parse(defaults.ToJson(), Units.Inches, out var status); + + Assert.Equal(NestDefaultsStatus.Ok, status); + Assert.Equal(defaults.ToJson(), read.ToJson()); + Assert.Equal(Units.Inches, NestDefaults.Parse("{ not json", Units.Inches, out var invalid).Units); + Assert.Equal(NestDefaultsStatus.Invalid, invalid); + } + + public static TheoryData> Posts => new() + { + { + new CincinnatiPostProcessor(new CincinnatiPostConfig { ConfigurationName = "Synthetic CL" }), + new CincinnatiPostProcessor(new CincinnatiPostConfig()), + config => Assert.Equal("Synthetic CL", ((CincinnatiPostConfig)config).ConfigurationName) + }, + { + new CIFiberPostProcessor(new CIFiberPostConfig { ConfigurationName = "Synthetic Fiber" }), + new CIFiberPostProcessor(new CIFiberPostConfig()), + config => Assert.Equal("Synthetic Fiber", ((CIFiberPostConfig)config).ConfigurationName) + }, + { + new GravographISPostProcessor(new GravographISPostConfig { Engrave = new LayerCutConfig { Depth = 0.125 } }), + new GravographISPostProcessor(new GravographISPostConfig()), + config => Assert.Equal(0.125, ((GravographISPostConfig)config).Engrave.Depth) + }, + }; + + [Theory] + [MemberData(nameof(Posts))] + public void PostSettings_LoadConfigReadsWhatSerializeConfigWrites( + IJsonConfigPostProcessor source, IJsonConfigPostProcessor target, Action check) + { + var json = source.SerializeConfig(); + + target.LoadConfig(json); + + check(target.Config); + Assert.Equal(json, target.SerializeConfig()); + } + + [Theory] + [MemberData(nameof(Posts))] + public void PostSettings_UnusableJsonKeepsTheCurrentSettings( + IJsonConfigPostProcessor source, IJsonConfigPostProcessor _, Action check) + { + Assert.ThrowsAny(() => source.LoadConfig("null")); + Assert.ThrowsAny(() => source.LoadConfig("{ not json")); + + check(source.Config); + } + + private static SharedSettingWrite Write(string json) => new() { Json = json, SavedBy = "tester" }; +}