Files
OpenNest/OpenNest.Server/DrawingSetRoutes.cs
T
ajandClaude Opus 5.5 44666b289d feat(server): record set releases and the nests they made
Releasing a drawing set makes one nest per material group, so a kit run
spans several nests. The server now records which set revision was
released, the multiplier and the nests it made, so a run can be tracked
across its nests and a nest can say where it came from.

The client uploads every nest first and posts the release last: a failure
part-way leaves ordinary nests and no release, never a release pointing at
missing nests. For the same reason a release is accepted for an older set
revision or an archived set, since the nests already exist by then. The
release id is client-allocated and a nest belongs to at most one release,
so a retried post gets 409 with the release that already holds it.

Releases are append-only and read joined to each nest's current metadata,
so status and deletion are always current. The tables join schema v7, as
the set tables have not shipped yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:27:27 -04:00

157 lines
7.8 KiB
C#

using System.Text;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// <c>/api/sets</c>: named, versioned lists of library drawings with quantities, and the
/// append-only records of their releases into nests. Both travel as JSON; existing-set
/// writes follow the drawing If-Match contract (428 missing, 400 malformed, 409 stale or
/// wrong state with the current record).
/// </summary>
internal static class DrawingSetRoutes
{
public static void MapDrawingSets(this IEndpointRouteBuilder app)
{
app.MapGet("/api/sets/query", (HttpRequest request, NestDatabase db) =>
DrawingSetRequest.TryParseQuery(request.Query, out var query, out var error)
? Results.Ok(db.QueryDrawingSets(query))
: Results.BadRequest(error));
app.MapGet("/api/sets/{id:guid}", (Guid id, NestDatabase db) =>
db.GetDrawingSet(id) is { } record ? Results.Ok(record) : Results.NotFound());
app.MapGet("/api/sets/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
!DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error)
? Results.BadRequest(error)
: db.DrawingSetHistory(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound());
app.MapGet("/api/sets/{id:guid}/revisions/{revision:int}", (Guid id, int revision, NestDatabase db) =>
revision < 1 ? Results.BadRequest("Invalid revision.") :
db.GetDrawingSetRevision(id, revision) is { } record ? Results.Ok(record) : Results.NotFound());
app.MapGet("/api/drawings/{id:guid}/sets", (Guid id, HttpRequest request, NestDatabase db) =>
!DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error)
? Results.BadRequest(error)
: db.DrawingSetsContaining(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound());
// JSON set with members; the client allocates the id.
app.MapPost("/api/sets", async (HttpRequest request, NestDatabase db) =>
{
var (record, error) = await ReadSet(request);
if (error is not null) return error;
if (record!.Id == Guid.Empty)
return Results.BadRequest("id is required: allocate it before the first save.");
try { return Results.Ok(db.InsertDrawingSet(record.Id, record)); }
catch (DrawingSetConflictException conflict) { return Conflict(conflict); }
catch (ArgumentException invalid) { return Results.BadRequest(invalid.Message); }
});
app.MapPut("/api/sets/{id:guid}", async (Guid id, HttpRequest request, NestDatabase db) =>
{
var precondition = IfMatch.Read(request, out var expected);
if (precondition is not null) return precondition;
var (record, error) = await ReadSet(request);
if (error is not null) return error;
if (record!.Id != Guid.Empty && record.Id != id)
return Results.BadRequest("Set id does not match the route.");
try
{
var updated = db.UpdateDrawingSet(id, record, expected);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}
catch (DrawingSetConflictException conflict) { return Conflict(conflict); }
catch (ArgumentException invalid) { return Results.BadRequest(invalid.Message); }
});
app.MapPost("/api/sets/{id:guid}/archive", (Guid id, HttpRequest request, NestDatabase db) =>
SetArchived(id, true, request, db));
app.MapPost("/api/sets/{id:guid}/restore", (Guid id, HttpRequest request, NestDatabase db) =>
SetArchived(id, false, request, db));
// Posted after every nest is uploaded; the client allocates the release id.
app.MapPost("/api/sets/{id:guid}/releases", async (Guid id, HttpRequest request, NestDatabase db) =>
{
var (release, error) = await ReadRelease(request);
if (error is not null) return error;
if (release!.Id == Guid.Empty)
return Results.BadRequest("id is required: allocate it before posting the release.");
if (release.SetId != Guid.Empty && release.SetId != id)
return Results.BadRequest("Release setId does not match the route.");
try
{
var stored = db.InsertSetRelease(id, release);
return stored is null ? Results.NotFound() : Results.Ok(stored);
}
catch (SetReleaseConflictException conflict)
{
return Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
}
catch (ArgumentException invalid) { return Results.BadRequest(invalid.Message); }
});
app.MapGet("/api/sets/{id:guid}/releases", (Guid id, HttpRequest request, NestDatabase db) =>
!DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error)
? Results.BadRequest(error)
: db.SetReleases(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound());
app.MapGet("/api/nests/{id:guid}/release", (Guid id, NestDatabase db) =>
db.ReleaseForNest(id) is { } release ? Results.Ok(release) : Results.NotFound());
}
private static IResult SetArchived(Guid id, bool archived, HttpRequest request, NestDatabase db)
{
var precondition = IfMatch.Read(request, out var expected);
if (precondition is not null) return precondition;
try
{
var changed = db.SetDrawingSetArchived(id, archived, expected);
return changed is null ? Results.NotFound() : Results.Ok(changed);
}
catch (DrawingSetConflictException conflict) { return Conflict(conflict); }
}
private static IResult Conflict(DrawingSetConflictException conflict) =>
Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
private static async Task<(DrawingSetRecord? Record, IResult? Error)> ReadSet(HttpRequest request)
{
var (json, error) = await ReadJson(request, "set");
if (error is not null) return (null, error);
return DrawingSetRequest.TryReadSet(json, out var record, out var invalid)
? (record, null)
: (null, Results.BadRequest(invalid));
}
private static async Task<(SetReleaseRecord? Release, IResult? Error)> ReadRelease(HttpRequest request)
{
var (json, error) = await ReadJson(request, "release");
if (error is not null) return (null, error);
return DrawingSetRequest.TryReadRelease(json, out var release, out var invalid)
? (release, null)
: (null, Results.BadRequest(invalid));
}
private static async Task<(string? Json, IResult? Error)> ReadJson(HttpRequest request, string what)
{
if (!request.HasJsonContentType())
return (null, Results.BadRequest($"Expected an application/json {what}."));
if (request.ContentLength > DrawingSetRules.MaxBodyBytes)
return (null, Results.BadRequest($"The {what} is too large."));
// Bounded read: a body without a length, or longer than it claims, stops at the limit.
using var body = new MemoryStream();
var chunk = new byte[81920];
int read;
while ((read = await request.Body.ReadAsync(chunk, request.HttpContext.RequestAborted)) > 0)
{
if (body.Length + read > DrawingSetRules.MaxBodyBytes)
return (null, Results.BadRequest($"The {what} is too large."));
body.Write(chunk, 0, read);
}
return (Encoding.UTF8.GetString(body.GetBuffer(), 0, (int)body.Length), null);
}
}