mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-13 22:45:10 -05:00
Finding a part on the nest server meant scrolling 40k drawings: the library could only match an exact thickness or customer and knew nothing about a drawing's size or holes. The library browser gets a Filter dialog with inclusive ranges, and the query API takes them. The server never parses packages and stays off IO, so the server worker now measures each package beside its nest jobs (bounding sides, net area, closed cutouts inside the outline) and stores the summary keyed by package hash (schema v19). Identical geometry is measured once, an unreadable package is recorded once instead of retried forever, and size or hole limits match only measured drawings. Thickness ranges compare in inches across units and keep the library's rule that a drawing saved without a thickness suits any. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
17 KiB
C#
341 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Talks to the drawing library on the nest server (<c>/api/drawings</c>). Uploads are
|
|
/// multipart: a "metadata" JSON part and a "package" part (a drawing package). Requests
|
|
/// are checked against <see cref="DrawingLibraryRules"/> before sending. A server without
|
|
/// the library raises <see cref="ServerTooOldException"/>; this client never falls back
|
|
/// to another store. Other failures surface as <see cref="IOException"/>.
|
|
/// </summary>
|
|
public sealed class RemoteDrawingLibrary : NestServerClient, IDrawingLibraryStore
|
|
{
|
|
public RemoteDrawingLibrary(string baseUrl)
|
|
: base(new HttpClient(), baseUrl, ownsClient: true)
|
|
{
|
|
}
|
|
|
|
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
|
|
public RemoteDrawingLibrary(HttpClient httpClient, string baseUrl)
|
|
: base(httpClient, baseUrl, ownsClient: false)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores revision 1 under <c>record.Id</c>, which the caller allocates first so an
|
|
/// ambiguous failure can be reconciled by id. An existing id raises
|
|
/// <see cref="DrawingLibraryConflictException"/> with the stored record.
|
|
/// </summary>
|
|
public async Task<DrawingLibraryRecord> 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 NestServerHttp.EnsureSuccessAsync(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<DrawingLibraryPage> 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 NestServerHttp.EnsureSuccessAsync(response, "browse the drawing library", cancellationToken);
|
|
var page = await response.Content.ReadFromJsonAsync<DrawingLibraryPage>(NestServerHttp.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;
|
|
}
|
|
|
|
/// <summary>The current record, including an archived one, or null for an unknown id.</summary>
|
|
public async Task<DrawingLibraryRecord?> 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 NestServerHttp.EnsureSuccessAsync(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.");
|
|
}
|
|
|
|
/// <summary>The current package bytes, or null for an unknown id.</summary>
|
|
public Task<byte[]?> GetPackageAsync(Guid id, CancellationToken cancellationToken = default)
|
|
{
|
|
ValidateId(id);
|
|
return GetBytesAsync($"api/drawings/{id}/file", $"download library drawing {id}", cancellationToken);
|
|
}
|
|
|
|
/// <summary>One page of immutable revisions, newest first, or null for an unknown id.</summary>
|
|
public async Task<DrawingLibraryPage?> GetRevisionsAsync(Guid id, int offset = 0,
|
|
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default)
|
|
{
|
|
ValidateId(id);
|
|
NestServerHttp.ValidatePaging(offset, limit, DrawingLibraryRules.MaxRevisionLimit);
|
|
using var response = await _httpClient.GetAsync(
|
|
Url($"api/drawings/{id}/revisions?{NestServerHttp.Paging(offset, limit)}"), cancellationToken);
|
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
{
|
|
await ThrowIfServerTooOld(cancellationToken);
|
|
return null;
|
|
}
|
|
await NestServerHttp.EnsureSuccessAsync(response, $"read library drawing history {id}", cancellationToken);
|
|
var page = await response.Content.ReadFromJsonAsync<DrawingLibraryPage>(NestServerHttp.JsonOptions, cancellationToken)
|
|
?? throw new IOException("Nest server returned an empty drawing history page.");
|
|
if (page.Items is null || !NestServerHttp.IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
|
|
|| !NestServerHttp.IsRevisionHistory(page.Items, id, item => item.Id, item => item.Revision))
|
|
throw new IOException("Nest server returned an invalid or unbounded drawing history page.");
|
|
return page;
|
|
}
|
|
|
|
/// <summary>The exact package of one revision, or null when the drawing or revision is unknown.</summary>
|
|
public Task<byte[]?> 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);
|
|
}
|
|
|
|
/// <summary>Saves new geometry and metadata as the next revision when <paramref name="expectedRevision"/> is current.</summary>
|
|
public async Task<DrawingLibraryRecord> 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;
|
|
}
|
|
|
|
/// <summary>Saves new metadata, keeping the geometry, as the next revision.</summary>
|
|
public async Task<DrawingLibraryRecord> 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: NestServerHttp.JsonOptions);
|
|
return await SendGuarded(request, id, expectedRevision, $"edit library drawing {id}", cancellationToken);
|
|
}
|
|
|
|
public Task<DrawingLibraryRecord> ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
|
|
SetArchivedAsync(id, expectedRevision, "archive", cancellationToken);
|
|
|
|
public Task<DrawingLibraryRecord> RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
|
|
SetArchivedAsync(id, expectedRevision, "restore", cancellationToken);
|
|
|
|
private async Task<DrawingLibraryRecord> 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));
|
|
NestServerHttp.AddIfMatch(request, expectedRevision);
|
|
return request;
|
|
}
|
|
|
|
private async Task<DrawingLibraryRecord> 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 NestServerHttp.EnsureSuccessAsync(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<byte[]?> 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 NestServerHttp.EnsureSuccessAsync(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.EffectiveCustomer);
|
|
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.CutFirst is { } cutFirst)
|
|
text.Append("&cutFirst=").Append(cutFirst ? "true" : "false");
|
|
if (query.IncludeArchived)
|
|
text.Append("&includeArchived=true");
|
|
AppendRanges(text, query.Filter);
|
|
return text.ToString();
|
|
}
|
|
|
|
// Only the limits that are set, so an unfiltered query stays readable by older servers.
|
|
private static void AppendRanges(StringBuilder text, DrawingLibraryFilter filter)
|
|
{
|
|
AppendNumber(text, "minThickness", filter.MinThickness);
|
|
AppendNumber(text, "maxThickness", filter.MaxThickness);
|
|
if (!filter.IncludeBlankThickness)
|
|
text.Append("&includeBlankThickness=false");
|
|
AppendNumber(text, "minLength", filter.MinLength);
|
|
AppendNumber(text, "maxLength", filter.MaxLength);
|
|
AppendNumber(text, "minWidth", filter.MinWidth);
|
|
AppendNumber(text, "maxWidth", filter.MaxWidth);
|
|
AppendNumber(text, "minHoles", filter.MinHoles);
|
|
AppendNumber(text, "maxHoles", filter.MaxHoles);
|
|
if (filter.SavedFrom is { } from)
|
|
text.Append("&savedFrom=").Append(from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
|
if (filter.SavedTo is { } to)
|
|
text.Append("&savedTo=").Append(to.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
private static void AppendNumber(StringBuilder text, string key, double? value)
|
|
{
|
|
if (value is { } number)
|
|
text.Append('&').Append(key).Append('=').Append(number.ToString("R", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
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, NestServerHttp.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<DrawingLibraryRecord> ReadRecord(HttpResponseMessage response, CancellationToken cancellationToken)
|
|
{
|
|
var record = await response.Content.ReadFromJsonAsync<DrawingLibraryRecord>(NestServerHttp.JsonOptions, cancellationToken);
|
|
return record ?? throw new IOException("Nest server returned an empty library drawing record.");
|
|
}
|
|
}
|
|
|
|
/// <summary>The nest server predates the drawing library or another feature (it answered HTTP 404).</summary>
|
|
public sealed class ServerTooOldException : IOException
|
|
{
|
|
public ServerTooOldException()
|
|
: this("the drawing library")
|
|
{
|
|
}
|
|
|
|
/// <summary><paramref name="feature"/> names what is missing, for example "drawing sets".</summary>
|
|
public ServerTooOldException(string feature)
|
|
: base($"This nest server does not support {feature} (HTTP 404). Update the nest server to this OpenNest version.")
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>A guarded library write found a newer or different current state; nothing was changed.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>The drawing's current server record.</summary>
|
|
public DrawingLibraryRecord Current { get; }
|
|
}
|