Files
OpenNest/OpenNest.Data/RemoteNestRepository.cs
T
ajandClaude Opus 5.5 7ce5e73a82 refactor: share duplicated paging, HTTP and helper code
Roslyn duplicate scans found the same code copied across layers:
- Data: the three browse sessions share BrowseSession<TPage, TSort>;
  the remote clients share NestServerHttp (JSON options, If-Match,
  error text).
- WinForms: five paged lists share BrowsePager for loading text,
  navigation, sort glyphs and failure messages. The Add Drawings to
  Release, Drawing Library and set browser dialogs now dispose their
  browse sessions.
- Server: NestQueryRequest reuses DrawingLibraryRequest's helpers;
  revision history queries share count and paging binds.
- Engine: RemnantPlateNester delegates to DefaultPlateNester;
  CuttingPlanService reuses JointCuttingPlanSearch.Map.
- Core: LineArc and CleanHole lead-ins share LineArcApproach with
  unchanged arithmetic order, so output is bit-identical.
- IO: Dxf.GetGeometry overloads share one reader; .nest JSON test
  helpers move to NestArchiveJson.

Also fixes the production compiler warnings (CS8632, CS8603, CS0108)
and moves a test off an obsolete forwarding API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 08:45:36 -04:00

323 lines
17 KiB
C#

using System.Globalization;
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;
/// <summary>
/// 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.
/// </summary>
public sealed class RemoteNestRepository : INestRepository, IDisposable
{
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly Uri _baseUri;
public RemoteNestRepository(string baseUrl)
: this(new HttpClient(), baseUrl)
{
_ownsClient = true;
}
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
public RemoteNestRepository(HttpClient httpClient, string baseUrl)
{
_httpClient = httpClient;
_ownsClient = false;
_baseUri = ServerUrl.ParseBase(baseUrl, nameof(baseUrl));
}
private Uri Url(string relative) => new(_baseUri, relative);
public async Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url("api/nests"), cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, "list nests", cancellationToken);
var items = await response.Content
.ReadFromJsonAsync<List<NestRecord>>(NestServerHttp.JsonOptions, cancellationToken);
return items ?? new List<NestRecord>();
}
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default) =>
QueryCoreAsync(query, false, cancellationToken);
public Task<NestPage> QueryDeletedAsync(NestQuery query, CancellationToken cancellationToken = default) =>
QueryCoreAsync(query, true, cancellationToken);
private async Task<NestPage> QueryCoreAsync(NestQuery query, bool deleted, CancellationToken cancellationToken)
{
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((deleted ? "api/nests/deleted/query?" : "api/nests/query?") + BuildQueryString(query)), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new IOException(
"This nest server does not support filtered browsing (HTTP 404). Update the nest server to this OpenNest version.");
}
await NestServerHttp.EnsureSuccessAsync(response, "query nests", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<NestPage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty page.");
if (page.Items.Count > query.Limit)
{
throw new IOException(
$"Nest server returned {page.Items.Count} records for a page limited to {query.Limit}.");
}
return page;
}
// Filters are sent only when set, so plain browsing still works against servers without them.
private static string BuildQueryString(NestQuery query)
{
var text = "search=" + Uri.EscapeDataString(query.NormalizedSearch)
+ "&sort=" + JsonNamingPolicy.CamelCase.ConvertName(query.Sort.ToString())
+ "&order=" + (query.Descending ? "desc" : "asc")
+ "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture)
+ "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture);
if (query.Status is { } status)
text += "&status=" + JsonNamingPolicy.CamelCase.ConvertName(status.ToString());
if (query.Material is not null)
text += "&material=" + Uri.EscapeDataString(query.Material);
if (query.MaterialGrade is not null)
text += "&materialGrade=" + Uri.EscapeDataString(query.MaterialGrade);
if (query.Thickness is { } thickness)
text += "&thickness=" + thickness.ToString("R", CultureInfo.InvariantCulture);
return text;
}
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
GetMetadataCoreAsync(id, false, cancellationToken);
public Task<NestRecord?> GetDeletedMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
GetMetadataCoreAsync(id, true, cancellationToken);
private async Task<NestRecord?> GetMetadataCoreAsync(Guid id, bool deleted, CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await NestServerHttp.EnsureSuccessAsync(response, $"get nest {id}", cancellationToken);
return await response.Content.ReadFromJsonAsync<NestRecord>(NestServerHttp.JsonOptions, cancellationToken);
}
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default) =>
GetFileCoreAsync(id, false, cancellationToken);
public Task<byte[]?> GetDeletedFileAsync(Guid id, CancellationToken cancellationToken = default) =>
GetFileCoreAsync(id, true, cancellationToken);
private async Task<byte[]?> GetFileCoreAsync(Guid id, bool deleted, CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/file"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await NestServerHttp.EnsureSuccessAsync(response, $"download nest {id}", cancellationToken);
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
public async Task<NestRecord> 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 NestServerHttp.EnsureSuccessAsync(response, $"upload nest '{record.Name}'", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public async Task<int> AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default)
{
if (year is < NestNames.MinYear or > NestNames.MaxYear)
throw new ArgumentOutOfRangeException(nameof(year));
using var response = await _httpClient.PostAsync(
Url("api/nest-names/next?year=" + year.ToString(CultureInfo.InvariantCulture)), null, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new NotSupportedException("This nest server does not allocate nest names (HTTP 404).");
await NestServerHttp.EnsureSuccessAsync(response, "allocate a nest name", cancellationToken);
var allocation = await response.Content.ReadFromJsonAsync<NestNameAllocation>(NestServerHttp.JsonOptions, cancellationToken);
if (allocation is null || allocation.Year != year || allocation.Number < 1)
throw new IOException("Nest server returned an invalid nest name allocation.");
return allocation.Number;
}
public async Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
{
return await UpdateFileAsync(id, nestFile, record, record.Revision, cancellationToken);
}
public async Task<NestRecord> UpdateMetadataAsync(
Guid id, NestRecord record, CancellationToken cancellationToken = default)
{
return await UpdateMetadataAsync(id, record, record.Revision, cancellationToken);
}
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
throw new IOException("Deletion requires guarded soft-delete support and is not available yet. Upgrade the client before deleting a server nest.");
}
public Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) => GetHistoryAsync(id, offset, limit, false, cancellationToken);
public Task<NestRevisionPage> GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) => GetHistoryAsync(id, offset, limit, true, cancellationToken);
private async Task<NestRevisionPage> GetHistoryAsync(Guid id, int offset, int limit, bool deleted,
CancellationToken cancellationToken)
{
ValidateId(id);
if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
if (limit < 1 || limit > NestRevisionPage.MaxLimit) throw new ArgumentOutOfRangeException(nameof(limit));
using var response = await _httpClient.GetAsync(
Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new IOException("Nest history is unavailable or this nest no longer exists. Upgrade the nest server if it does not support version history, then reload.");
await NestServerHttp.EnsureSuccessAsync(response, $"read nest history {id}", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<NestRevisionPage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty 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 history page.");
return page;
}
public Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) => GetHistoricalFileAsync(id, revisionNumber, false, cancellationToken);
public Task<byte[]?> GetDeletedRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) => GetHistoricalFileAsync(id, revisionNumber, true, cancellationToken);
private async Task<byte[]?> GetHistoricalFileAsync(Guid id, int revisionNumber, bool deleted,
CancellationToken cancellationToken)
{
ValidateId(id);
ValidateRevision(revisionNumber);
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions/{revisionNumber.ToString(CultureInfo.InvariantCulture)}/file"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
await NestServerHttp.EnsureSuccessAsync(response, $"download nest {id} revision {revisionNumber}", cancellationToken);
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
public async Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default)
{
ValidateId(id);
if (expectedRevision < 1)
throw new IOException("This nest has no known revision. Upgrade the nest server and reload before saving, or Save As a new nest.");
// A legacy server can ignore If-Match and overwrite data. Prove history support
// before sending any mutation; never infer revision 1 from a legacy read.
var history = await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
if (history.Items.Count == 0)
throw new IOException("Nest server returned no revision history. Upgrade the nest server and reload before saving.");
using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/file"));
NestServerHttp.AddIfMatch(request, expectedRevision);
request.Content = BuildMultipart(nestFile, record);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"update nest {id}; reload the current version or Save As after a conflict", cancellationToken);
var saved = await ReadRecord(response, cancellationToken);
if (saved.Id != id || saved.Revision < expectedRevision)
throw new IOException("Nest server returned an invalid saved revision. Reload to reconcile before saving again.");
return saved;
}
public async Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default)
{
ValidateId(id);
ValidateRevision(expectedRevision);
await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/metadata"));
NestServerHttp.AddIfMatch(request, expectedRevision);
request.Content = JsonContent.Create(record, options: NestServerHttp.JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"edit nest {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public async Task<NestRecord> RestoreAsync(Guid id, int sourceRevision, int expectedRevision,
CancellationToken cancellationToken = default)
{
ValidateId(id);
ValidateRevision(sourceRevision);
ValidateRevision(expectedRevision);
await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Post,
Url($"api/nests/{id}/revisions/{sourceRevision.ToString(CultureInfo.InvariantCulture)}/restore"));
NestServerHttp.AddIfMatch(request, expectedRevision);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"restore nest {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public Task<NestRecord> DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
ChangeVisibilityAsync(id, expectedRevision, true, cancellationToken);
public Task<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
ChangeVisibilityAsync(id, expectedRevision, false, cancellationToken);
private async Task<NestRecord> ChangeVisibilityAsync(Guid id, int expectedRevision, bool deleted,
CancellationToken cancellationToken)
{
ValidateId(id);
ValidateRevision(expectedRevision);
// A legacy server cannot be trusted to preserve history on DELETE.
await GetHistoryAsync(id, 0, 1, !deleted, cancellationToken);
using var request = new HttpRequestMessage(deleted ? HttpMethod.Delete : HttpMethod.Post,
Url(deleted ? $"api/nests/{id}" : $"api/nests/deleted/{id}/recover"));
NestServerHttp.AddIfMatch(request, expectedRevision);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"change nest visibility {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
private static void ValidateId(Guid id)
{
if (id == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(id));
}
private static void ValidateRevision(int revisionNumber)
{
if (revisionNumber < 1) throw new ArgumentOutOfRangeException(nameof(revisionNumber));
}
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, NestServerHttp.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<NestRecord> ReadRecord(
HttpResponseMessage response, CancellationToken cancellationToken)
{
var record = await response.Content.ReadFromJsonAsync<NestRecord>(NestServerHttp.JsonOptions, cancellationToken);
return record ?? throw new IOException("Nest server returned an empty record.");
}
}