diff --git a/OpenNest.Data/INestRepository.cs b/OpenNest.Data/INestRepository.cs new file mode 100644 index 0000000..bf34476 --- /dev/null +++ b/OpenNest.Data/INestRepository.cs @@ -0,0 +1,31 @@ +namespace OpenNest.Data; + +/// +/// Storage backend for saved nests. talks to the +/// central nest server; File mode in the desktop app keeps using SaveFileDialog and +/// does not go through this interface. +/// +public interface INestRepository +{ + /// All stored nests, newest saved first. + Task> ListAsync(CancellationToken cancellationToken = default); + + Task GetMetadataAsync(Guid id, CancellationToken cancellationToken = default); + + /// Downloaded .nest archive bytes, or null when the id does not exist. + Task GetFileAsync(Guid id, CancellationToken cancellationToken = default); + + /// Stores a new nest; returns the server-assigned record. + Task UploadAsync( + byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default); + + /// Replaces the archive and metadata of an existing id, keeping the id. + Task UpdateFileAsync( + Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default); + + /// Replaces metadata only (status, made-by, comments...), leaving the archive. + Task UpdateMetadataAsync( + Guid id, NestRecord record, CancellationToken cancellationToken = default); + + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/OpenNest.Data/NestRecord.cs b/OpenNest.Data/NestRecord.cs new file mode 100644 index 0000000..faaadb5 --- /dev/null +++ b/OpenNest.Data/NestRecord.cs @@ -0,0 +1,42 @@ +using OpenNest; + +namespace OpenNest.Data; + +/// +/// Server-side metadata for one saved nest. The .nest archive itself is stored +/// alongside; this record is what lists, filters and status tracking read. +/// Counts are captured by the client at save time. +/// +public sealed class NestRecord +{ + public Guid Id { get; set; } + + public string Name { get; set; } = ""; + + public string Customer { get; set; } = ""; + + public DateTime DateCreated { get; set; } + + public DateTime DateModified { get; set; } + + public string Material { get; set; } = ""; + + public double Thickness { get; set; } + + /// quote | toBeCut | hasBeenCut (camelCase on the wire, case-insensitive readers). + public NestStatus Status { get; set; } = NestStatus.Quote; + + public int PlateCount { get; set; } + + public int PartCount { get; set; } + + public string Comments { get; set; } = ""; + + public string MadeBy { get; set; } = ""; + + /// Size of the stored .nest archive in bytes. + public long FileSize { get; set; } + + /// When the server last stored the nest contents or metadata. + public DateTime SavedAt { get; set; } +} diff --git a/OpenNest.Data/NestStorageSettings.cs b/OpenNest.Data/NestStorageSettings.cs new file mode 100644 index 0000000..f092c5d --- /dev/null +++ b/OpenNest.Data/NestStorageSettings.cs @@ -0,0 +1,83 @@ +using System.Text.Json; + +namespace OpenNest.Data; + +/// Where the desktop app persists nests: local .nest files or the shared nest server. +public enum NestStorageMode +{ + File, + Database, +} + +/// +/// Storage-mode toggle and nest-server address, stored at %APPDATA%\OpenNest\storage.json. +/// Loading never throws; a missing or corrupt file means File mode so existing installs +/// keep the original behavior until the operator opts in. +/// +public sealed class NestStorageSettings +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + public NestStorageMode Mode { get; set; } = NestStorageMode.File; + + /// Base URL of the nest server, e.g. http://barge.lan:8090. No trailing slash required. + public string ServerUrl { get; set; } = ""; + + /// True when Database mode is active and a server URL is configured. + public bool IsDatabaseMode => + Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl); + + /// %APPDATA%\OpenNest\storage.json. + public static string DefaultPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenNest", "storage.json"); + + public static NestStorageSettings Load(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return new(); + + try + { + var settings = JsonSerializer.Deserialize( + File.ReadAllText(path), JsonOptions) ?? new(); + settings.ServerUrl = NormalizeUrl(settings.ServerUrl); + return settings; + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + return new(); + } + } + + /// Writes camelCase JSON, creating the parent directory and retrying IO collisions. + public void Save(string path) + { + var json = JsonSerializer.Serialize( + new NestStorageSettings { Mode = Mode, ServerUrl = NormalizeUrl(ServerUrl) }, + JsonOptions); + var directory = Path.GetDirectoryName(Path.GetFullPath(path)); + + for (var attempt = 0; attempt < 3; attempt++) + { + try + { + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + File.WriteAllText(path, json); + return; + } + catch (IOException) when (attempt < 2) + { + Thread.Sleep(100); + } + } + } + + private static string NormalizeUrl(string? url) => (url ?? "").Trim().TrimEnd('/'); +} diff --git a/OpenNest.Data/RemoteNestRepository.cs b/OpenNest.Data/RemoteNestRepository.cs new file mode 100644 index 0000000..b893180 --- /dev/null +++ b/OpenNest.Data/RemoteNestRepository.cs @@ -0,0 +1,159 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenNest.Data; + +/// +/// Talks to the central OpenNest nest server over HTTP. Uploads are multipart: +/// a "metadata" JSON part and a "file" part holding the .nest archive. +/// Connection and HTTP failures surface as HttpRequestException/IOException to the caller. +/// +public sealed class RemoteNestRepository : INestRepository, IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + private readonly HttpClient _httpClient; + private readonly bool _ownsClient; + private readonly Uri _baseUri; + + public RemoteNestRepository(string baseUrl) + : this(new HttpClient(), baseUrl) + { + _ownsClient = true; + } + + /// For tests and hosts that pool HttpClient instances. + public RemoteNestRepository(HttpClient httpClient, string baseUrl) + { + _httpClient = httpClient; + _ownsClient = false; + var url = (baseUrl ?? "").Trim().TrimEnd('/'); + if (!Uri.TryCreate(url, UriKind.Absolute, out var baseUri) + || (baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps)) + { + throw new ArgumentException($"Invalid nest server URL: '{baseUrl}'", nameof(baseUrl)); + } + // Keep the trailing slash so relative combines replace no path segment. + _baseUri = new Uri(baseUri.AbsoluteUri.TrimEnd('/') + "/"); + } + + private Uri Url(string relative) => new(_baseUri, relative); + + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + using var response = await _httpClient.GetAsync(Url("api/nests"), cancellationToken); + await EnsureSuccess(response, "list nests", cancellationToken); + var items = await response.Content + .ReadFromJsonAsync>(JsonOptions, cancellationToken); + return items ?? new List(); + } + + public async Task GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) + { + using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + await EnsureSuccess(response, $"get nest {id}", cancellationToken); + return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + } + + public async Task GetFileAsync(Guid id, CancellationToken cancellationToken = default) + { + using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/file"), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + await EnsureSuccess(response, $"download nest {id}", cancellationToken); + return await response.Content.ReadAsByteArrayAsync(cancellationToken); + } + + public async Task UploadAsync( + byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default) + { + using var content = BuildMultipart(nestFile, record); + using var response = await _httpClient.PostAsync(Url("api/nests"), content, cancellationToken); + await EnsureSuccess(response, $"upload nest '{record.Name}'", cancellationToken); + return await ReadRecord(response, cancellationToken); + } + + public async Task UpdateFileAsync( + Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default) + { + using var content = BuildMultipart(nestFile, record); + using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/file"), content, cancellationToken); + await EnsureSuccess(response, $"update nest {id}", cancellationToken); + return await ReadRecord(response, cancellationToken); + } + + public async Task UpdateMetadataAsync( + Guid id, NestRecord record, CancellationToken cancellationToken = default) + { + using var content = new StringContent( + JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json"); + using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/metadata"), content, cancellationToken); + await EnsureSuccess(response, $"update nest metadata {id}", cancellationToken); + return await ReadRecord(response, cancellationToken); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + { + using var response = await _httpClient.DeleteAsync(Url($"api/nests/{id}"), cancellationToken); + await EnsureSuccess(response, $"delete nest {id}", cancellationToken); + } + + public void Dispose() + { + if (_ownsClient) + _httpClient.Dispose(); + } + + private static MultipartFormDataContent BuildMultipart(byte[] nestFile, NestRecord record) + { + var content = new MultipartFormDataContent(); + var metadata = new StringContent( + JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json"); + content.Add(metadata, "metadata"); + var file = new ByteArrayContent(nestFile); + file.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); + content.Add(file, "file", record.Name.Length > 0 ? $"{record.Name}.nest" : "nest.nest"); + return content; + } + + private static async Task ReadRecord( + HttpResponseMessage response, CancellationToken cancellationToken) + { + var record = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return record ?? throw new IOException("Nest server returned an empty record."); + } + + private static async Task EnsureSuccess( + HttpResponseMessage response, string action, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + return; + + var detail = ""; + try + { + detail = await response.Content.ReadAsStringAsync(cancellationToken); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException) + { + // The status code is the useful part; the body is best-effort. + } + + if (detail.Length > 200) + detail = detail[..200] + "…"; + throw new IOException( + $"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd()); + } +} diff --git a/OpenNest.Tests/Data/NestStorageTests.cs b/OpenNest.Tests/Data/NestStorageTests.cs new file mode 100644 index 0000000..606c024 --- /dev/null +++ b/OpenNest.Tests/Data/NestStorageTests.cs @@ -0,0 +1,223 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class NestStorageSettingsTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), $"opennest-storage-{Guid.NewGuid():N}"); + + private string FilePath => Path.Combine(_directory, "storage.json"); + + [Fact] + public void Load_MissingFile_DefaultsToFileMode() + { + var settings = NestStorageSettings.Load(FilePath); + + Assert.Equal(NestStorageMode.File, settings.Mode); + Assert.Equal("", settings.ServerUrl); + Assert.False(settings.IsDatabaseMode); + } + + [Fact] + public void Load_CorruptFile_DefaultsToFileModeWithoutThrowing() + { + Directory.CreateDirectory(_directory); + File.WriteAllText(FilePath, "{ not json"); + + var settings = NestStorageSettings.Load(FilePath); + + Assert.Equal(NestStorageMode.File, settings.Mode); + } + + [Fact] + public void SaveThenLoad_DatabaseMode_RoundTrips() + { + NestStorageSettings.Load(FilePath) + .Save(FilePath); // ensure directory exists pattern + var settings = new NestStorageSettings + { + Mode = NestStorageMode.Database, + ServerUrl = "http://barge.lan:8090/", + }; + + settings.Save(FilePath); + var loaded = NestStorageSettings.Load(FilePath); + + Assert.Equal(NestStorageMode.Database, loaded.Mode); + Assert.Equal("http://barge.lan:8090", loaded.ServerUrl); + Assert.True(loaded.IsDatabaseMode); + } + + [Fact] + public void DatabaseMode_WithoutServerUrl_IsNotActive() + { + var settings = new NestStorageSettings { Mode = NestStorageMode.Database }; + Assert.False(settings.IsDatabaseMode); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } +} + +/// Records requests and replies with canned responses. +internal sealed class StubHandler : HttpMessageHandler +{ + private readonly Func _responder; + + public List Requests { get; } = new(); + public List RequestBodies { get; } = new(); + + public StubHandler(Func responder) => + _responder = responder; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + RequestBodies.Add(request.Content is null + ? "" + : await request.Content.ReadAsStringAsync(cancellationToken)); + return _responder(request); + } + + public static HttpResponseMessage Json(object payload, HttpStatusCode code = HttpStatusCode.OK) => + new(code) + { + Content = new StringContent( + JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"), + }; +} + +public class RemoteNestRepositoryTests +{ + private static NestRecord SampleRecord(Guid? id = null) => new() + { + Id = id ?? Guid.NewGuid(), + Name = "Job 1", + Customer = "Cozy Cat", + Material = "Aluminum", + Thickness = 0.25, + Status = NestStatus.ToBeCut, + PlateCount = 2, + PartCount = 17, + Comments = "rush", + MadeBy = "AJ", + FileSize = 1234, + SavedAt = new DateTime(2026, 9, 29, 12, 0, 0), + }; + + [Fact] + public void Constructor_RejectsInvalidUrl() + { + Assert.Throws(() => new RemoteNestRepository("not a url")); + Assert.Throws(() => new RemoteNestRepository("ftp://host")); + } + + [Fact] + public async Task ListAsync_DeserializesCamelCaseRecords() + { + var handler = new StubHandler(_ => StubHandler.Json(new[] { SampleRecord() })); + using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090/"); + + var records = await repo.ListAsync(); + + Assert.Single(records); + Assert.Equal("Cozy Cat", records[0].Customer); + Assert.Equal(NestStatus.ToBeCut, records[0].Status); + Assert.Contains("/api/nests", handler.Requests[0].RequestUri!.ToString()); + } + + [Fact] + public async Task UploadAsync_PostsMultipartWithMetadataAndFile() + { + var record = SampleRecord(); + var handler = new StubHandler(_ => StubHandler.Json(record)); + using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090"); + + var result = await repo.UploadAsync(new byte[] { 1, 2, 3 }, record); + + Assert.Equal(record.Id, result.Id); + var request = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, request.Method); + Assert.EndsWith("/api/nests", request.RequestUri!.ToString()); + Assert.StartsWith("multipart/form-data", request.Content!.Headers.ContentType!.ToString()); + Assert.Contains("metadata", handler.RequestBodies[0]); + Assert.Contains("toBeCut", handler.RequestBodies[0]); + Assert.Contains("filename=\"Job 1.nest\"", handler.RequestBodies[0]); + } + + [Fact] + public async Task UpdateFileAsync_PutsMultipartToIdFileRoute() + { + var id = Guid.NewGuid(); + var record = SampleRecord(id); + var handler = new StubHandler(_ => StubHandler.Json(record)); + using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090"); + + await repo.UpdateFileAsync(id, new byte[] { 9 }, record); + + var request = handler.Requests[0]; + Assert.Equal(HttpMethod.Put, request.Method); + Assert.EndsWith($"/api/nests/{id}/file", request.RequestUri!.ToString()); + } + + [Fact] + public async Task GetFileAsync_NullOnNotFound_BytesOtherwise() + { + var missing = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + using var missingRepo = new RemoteNestRepository(new HttpClient(missing), "http://s"); + Assert.Null(await missingRepo.GetFileAsync(Guid.NewGuid())); + + var found = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(new byte[] { 4, 5, 6 }), + }); + using var foundRepo = new RemoteNestRepository(new HttpClient(found), "http://s"); + Assert.Equal(new byte[] { 4, 5, 6 }, await foundRepo.GetFileAsync(Guid.NewGuid())); + } + + [Fact] + public async Task GetMetadataAsync_NullOnNotFound() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s"); + + Assert.Null(await repo.GetMetadataAsync(Guid.NewGuid())); + } + + [Fact] + public async Task UpdateMetadataAsync_PutsJsonToMetadataRoute() + { + var id = Guid.NewGuid(); + var record = SampleRecord(id); + var handler = new StubHandler(_ => StubHandler.Json(record)); + using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s"); + + await repo.UpdateMetadataAsync(id, record); + + Assert.Equal(HttpMethod.Put, handler.Requests[0].Method); + Assert.EndsWith($"/api/nests/{id}/metadata", handler.Requests[0].RequestUri!.ToString()); + Assert.Equal("application/json", handler.Requests[0].Content!.Headers.ContentType!.MediaType); + } + + [Fact] + public async Task DeleteAsync_ThrowsWithStatusDetailOnError() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("disk on fire"), + }); + using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s"); + + var ex = await Assert.ThrowsAsync(() => repo.DeleteAsync(Guid.NewGuid())); + Assert.Contains("500", ex.Message); + Assert.Contains("disk on fire", ex.Message); + } +}