using System.Globalization; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace OpenNest.Data; /// /// Talks to the drawing library on the nest server (/api/drawings). Uploads are /// multipart: a "metadata" JSON part and a "package" part (a drawing package). Requests /// are checked against before sending. A server without /// the library raises ; this client never falls back /// to another store. Other failures surface as . /// public sealed class RemoteDrawingLibrary : IDrawingLibrary, 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 RemoteDrawingLibrary(string baseUrl) : this(new HttpClient(), baseUrl) { _ownsClient = true; } /// For tests and hosts that pool HttpClient instances. public RemoteDrawingLibrary(HttpClient httpClient, string baseUrl) { _httpClient = httpClient; _baseUri = ServerUrl.ParseBase(baseUrl, nameof(baseUrl)); } private Uri Url(string relative) => new(_baseUri, relative); /// /// Stores revision 1 under record.Id, which the caller allocates first so an /// ambiguous failure can be reconciled by id. An existing id raises /// with the stored record. /// public async Task UploadAsync(DrawingLibraryRecord record, byte[] package, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(record); ValidateId(record.Id); ValidateUpload(record, package); using var content = BuildMultipart(record, package); using var response = await _httpClient.PostAsync(Url("api/drawings"), content, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) throw new ServerTooOldException(); if (response.StatusCode == HttpStatusCode.Conflict) throw new DrawingLibraryConflictException(await ReadRecord(response, cancellationToken)); await EnsureSuccess(response, $"save drawing '{record.Name}' to the library", cancellationToken); var saved = await ReadRecord(response, cancellationToken); if (saved.Id != record.Id || saved.Revision != 1 || saved.PackageSha256 != Sha256(package)) throw new IOException("The nest server returned a different drawing than was saved. Reload the library before saving again."); return saved; } public async Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(query); var error = query.GetValidationError(); if (error is not null) throw new ArgumentException(error, nameof(query)); using var response = await _httpClient.GetAsync(Url("api/drawings/query?" + BuildQueryString(query)), cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) throw new ServerTooOldException(); await EnsureSuccess(response, "browse the drawing library", cancellationToken); var page = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) ?? throw new IOException("Nest server returned an empty drawing page."); if (page.Items is null || page.Items.Count > query.Limit || page.Total < 0 || page.Items.Any(item => item is null)) throw new IOException($"Nest server returned an invalid drawing page for a page limited to {query.Limit}."); return page; } /// The current record, including an archived one, or null for an unknown id. public async Task GetAsync(Guid id, CancellationToken cancellationToken = default) { ValidateId(id); using var response = await _httpClient.GetAsync(Url($"api/drawings/{id}"), cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) { await ThrowIfServerTooOld(cancellationToken); return null; } await EnsureSuccess(response, $"read library drawing {id}", cancellationToken); var record = await ReadRecord(response, cancellationToken); return record.Id == id ? record : throw new IOException("Nest server returned a different library drawing."); } /// The current package bytes, or null for an unknown id. public Task GetPackageAsync(Guid id, CancellationToken cancellationToken = default) { ValidateId(id); return GetBytesAsync($"api/drawings/{id}/file", $"download library drawing {id}", cancellationToken); } /// One page of immutable revisions, newest first, or null for an unknown id. public async Task GetRevisionsAsync(Guid id, int offset = 0, int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) { ValidateId(id); if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset)); if (limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) throw new ArgumentOutOfRangeException(nameof(limit)); using var response = await _httpClient.GetAsync( Url($"api/drawings/{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) { await ThrowIfServerTooOld(cancellationToken); return null; } await EnsureSuccess(response, $"read library drawing history {id}", cancellationToken); var page = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) ?? throw new IOException("Nest server returned an empty drawing history page."); if (page.Items is null || page.Items.Count > limit || page.Offset != offset || page.Limit != limit || page.Total < 0 || page.Items.Count > System.Math.Max(0L, (long)page.Total - offset) || page.Items.Any(item => item is null || item.Revision < 1 || item.Id != id) || page.Items.Zip(page.Items.Skip(1)).Any(pair => pair.First.Revision <= pair.Second.Revision)) throw new IOException("Nest server returned an invalid or unbounded drawing history page."); return page; } /// The exact package of one revision, or null when the drawing or revision is unknown. public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) { ValidateId(id); ValidateRevision(revision); return GetBytesAsync($"api/drawings/{id}/revisions/{revision.ToString(CultureInfo.InvariantCulture)}/file", $"download library drawing {id} revision {revision}", cancellationToken); } /// Saves new geometry and metadata as the next revision when is current. public async Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package, int expectedRevision, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(record); ValidateId(id); ValidateRevision(expectedRevision); ValidateUpload(record, package); using var request = Guarded(HttpMethod.Put, $"api/drawings/{id}/file", expectedRevision); request.Content = BuildMultipart(record, package); var saved = await SendGuarded(request, id, expectedRevision, $"save library drawing {id}", cancellationToken); if (saved.PackageSha256 != Sha256(package)) throw new IOException("The nest server stored different drawing geometry. Reload the library drawing before saving again."); return saved; } /// Saves new metadata, keeping the geometry, as the next revision. public async Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(record); ValidateId(id); ValidateRevision(expectedRevision); var error = DrawingLibraryRules.GetMetadataError(record); if (error is not null) throw new ArgumentException(error, nameof(record)); using var request = Guarded(HttpMethod.Put, $"api/drawings/{id}/metadata", expectedRevision); request.Content = JsonContent.Create(record, options: JsonOptions); return await SendGuarded(request, id, expectedRevision, $"edit library drawing {id}", cancellationToken); } public Task ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => SetArchivedAsync(id, expectedRevision, "archive", cancellationToken); public Task RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => SetArchivedAsync(id, expectedRevision, "restore", cancellationToken); private async Task SetArchivedAsync(Guid id, int expectedRevision, string action, CancellationToken cancellationToken) { ValidateId(id); ValidateRevision(expectedRevision); using var request = Guarded(HttpMethod.Post, $"api/drawings/{id}/{action}", expectedRevision); var changed = await SendGuarded(request, id, expectedRevision, $"{action} library drawing {id}", cancellationToken); if (changed.Archived != (action == "archive")) throw new IOException($"Nest server did not {action} library drawing {id}. Reload the library."); return changed; } private HttpRequestMessage Guarded(HttpMethod method, string relative, int expectedRevision) { var request = new HttpRequestMessage(method, Url(relative)); request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\"")); return request; } private async Task SendGuarded(HttpRequestMessage request, Guid id, int expectedRevision, string action, CancellationToken cancellationToken) { using var response = await _httpClient.SendAsync(request, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) { await ThrowIfServerTooOld(cancellationToken); throw new IOException($"Library drawing {id} no longer exists on the nest server."); } if (response.StatusCode == HttpStatusCode.Conflict) throw new DrawingLibraryConflictException(await ReadRecord(response, cancellationToken)); await EnsureSuccess(response, action, cancellationToken); var saved = await ReadRecord(response, cancellationToken); if (saved.Id != id || saved.Revision < expectedRevision) throw new IOException("Nest server returned an invalid library drawing revision. Reload the library before saving again."); return saved; } private async Task GetBytesAsync(string relative, string action, CancellationToken cancellationToken) { using var response = await _httpClient.GetAsync(Url(relative), cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) { await ThrowIfServerTooOld(cancellationToken); return null; } await EnsureSuccess(response, action, cancellationToken); return await response.Content.ReadAsByteArrayAsync(cancellationToken); } // A 404 for one drawing is ambiguous; the query route exists on every library-aware server. private async Task ThrowIfServerTooOld(CancellationToken cancellationToken) { using var probe = await _httpClient.GetAsync(Url("api/drawings/query?limit=1"), cancellationToken); if (probe.StatusCode == HttpStatusCode.NotFound) throw new ServerTooOldException(); } private static void ValidateUpload(DrawingLibraryRecord record, byte[] package) { ArgumentNullException.ThrowIfNull(package); var error = DrawingLibraryRules.GetMetadataError(record) ?? DrawingLibraryRules.GetPackageError(package); if (error is not null) throw new ArgumentException(error, nameof(record)); } private static void ValidateId(Guid id) { if (id == Guid.Empty) throw new ArgumentException("A library drawing id is required.", nameof(id)); } private static void ValidateRevision(int revision) { if (revision < 1) throw new ArgumentOutOfRangeException(nameof(revision)); } private static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); private static string BuildQueryString(DrawingLibraryQuery query) { var text = new StringBuilder() .Append("search=").Append(Uri.EscapeDataString(query.NormalizedSearch)) .Append("&sort=").Append(JsonNamingPolicy.CamelCase.ConvertName(query.Sort.ToString())) .Append("&order=").Append(query.Descending ? "desc" : "asc") .Append("&offset=").Append(query.Offset.ToString(CultureInfo.InvariantCulture)) .Append("&limit=").Append(query.Limit.ToString(CultureInfo.InvariantCulture)); AppendFilter(text, "customer", query.Customer); AppendFilter(text, "materialName", query.MaterialName); AppendFilter(text, "materialGrade", query.MaterialGrade); if (query.Thickness is { } thickness) text.Append("&thickness=").Append(DrawingLibraryRules.NormalizeThickness(thickness).ToString("0.####", CultureInfo.InvariantCulture)); if (query.CutType is { } cutType) text.Append("&cutType=").Append(cutType.ToString()); if (query.IncludeArchived) text.Append("&includeArchived=true"); return text.ToString(); } private static void AppendFilter(StringBuilder text, string key, string? value) { if (!string.IsNullOrWhiteSpace(value)) text.Append('&').Append(key).Append('=').Append(Uri.EscapeDataString(value)); } private static MultipartFormDataContent BuildMultipart(DrawingLibraryRecord record, byte[] package) { var content = new MultipartFormDataContent(); content.Add(new StringContent(JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json"), "metadata"); var file = new ByteArrayContent(package); file.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); content.Add(file, "package", "drawing.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 library drawing 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()); } public void Dispose() { if (_ownsClient) _httpClient.Dispose(); } } /// The nest server predates the drawing library (it answered HTTP 404). public sealed class ServerTooOldException : IOException { public ServerTooOldException() : base("This nest server does not support the drawing library (HTTP 404). Update the nest server to this OpenNest version.") { } } /// A guarded library write found a newer or different current state; nothing was changed. public sealed class DrawingLibraryConflictException : IOException { public DrawingLibraryConflictException(DrawingLibraryRecord current) : base(current.Archived ? $"Library drawing '{current.Name}' is archived or was changed (current revision {current.Revision}). Reload before saving." : $"Library drawing '{current.Name}' was changed by someone else (current revision {current.Revision}). Reload before saving.") { Current = current; } /// The drawing's current server record. public DrawingLibraryRecord Current { get; } }