Files
OpenNest/OpenNest.Server/Program.cs
T
ajandClaude Opus 5.5 be057bf337 feat(server): store versioned drawing sets
A drawing set is a named list of library drawings with quantities (a kit,
an assembly, a recurring order) that will later be released into one nest
per material. This is the server half: sets get the same revision,
If-Match and archive contract as /api/drawings so the client and tests
follow a known shape.

Schema v7 adds drawing_sets, drawing_set_revisions and
drawing_set_members. Members reference drawings by id and mean each
drawing's current revision, so reads join the current drawing record:
the client can group members by material without one request per drawing,
and the joined record is ignored on save so a read record can be sent
back unchanged. Every change keeps its own member list, and
/api/drawings/{id}/sets answers "used in" from current revisions only.

The drawing query's enum, integer and history-paging parsing is now shared
so both APIs refuse bad input identically.

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

211 lines
9.8 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using OpenNest.Data;
using OpenNest.Server;
var builder = WebApplication.CreateBuilder(args);
// Same wire contract as RemoteNestRepository: camelCase, enum-as-string.
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = jsonOptions.PropertyNamingPolicy;
options.SerializerOptions.PropertyNameCaseInsensitive = true;
foreach (var converter in jsonOptions.Converters)
options.SerializerOptions.Converters.Add(converter);
});
// Storage path: --database=<path> or --database <path>, then OPENNEST_DB, default ./data/nests.db (a Docker volume).
var databasePath = ServerOptions.DatabasePath(args, Environment.GetEnvironmentVariable(ServerOptions.DatabaseVariable));
builder.Services.AddSingleton(_ => new NestDatabase(databasePath));
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
var app = builder.Build();
// Open the database now so an unusable data path fails startup, not the first request.
app.Services.GetRequiredService<NestDatabase>();
app.MapGet("/healthz", (NestDatabase db) => db.IsHealthy()
? Results.Ok(new { status = "ok" })
: Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable));
// Full enumeration (backup manifests, restore checks and the container smoke).
app.MapGet("/api/nests", (NestDatabase db) => Results.Ok(db.List()));
// Bounded, SQL-filtered page for browsing.
app.MapGet("/api/nests/query", (HttpRequest request, NestDatabase db) =>
NestQueryRequest.TryParse(request.Query, out var query, out var error)
? Results.Ok(db.Query(query))
: Results.BadRequest(error));
app.MapGet("/api/nests/{id:guid}", (Guid id, NestDatabase db) =>
db.Get(id) is { } record ? Results.Ok(record) : Results.NotFound());
app.MapGet("/api/nests/{id:guid}/file", (Guid id, NestDatabase db) =>
db.GetFile(id) is { } file ? Results.File(file, "application/zip", $"{id}.nest") : Results.NotFound());
// Next generated nest name for a year; one counter per year shared by every client.
app.MapPost("/api/nest-names/next", (int year, NestDatabase db) =>
{
if (year is < NestNames.MinYear or > NestNames.MaxYear)
return Results.BadRequest($"year must be between {NestNames.MinYear} and {NestNames.MaxYear}.");
var number = db.AllocateNestNumber(year);
return Results.Ok(new NestNameAllocation { Year = year, Number = number, Name = NestNames.Format(year, number) });
});
// POST/PUT multipart: "metadata" JSON part + "file" part (the .nest archive).
app.MapPost("/api/nests", async (HttpContext http, NestDatabase db) =>
{
var (record, file, error) = await ReadMultipart(http);
if (error is not null)
return error;
var id = record!.Id == Guid.Empty ? Guid.NewGuid() : record.Id;
try { return Results.Ok(db.Insert(id, record, file!)); }
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
});
app.MapGet("/api/nests/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
ReadHistory(id, request, db));
app.MapGet("/api/nests/{id:guid}/revisions/{revision:int}/file", (Guid id, int revision, NestDatabase db) =>
revision < 1 ? Results.BadRequest("Invalid revision.") :
db.GetRevisionFile(id, revision) is { } file ? Results.File(file, "application/zip", $"{id}-r{revision}.nest") : Results.NotFound());
app.MapPut("/api/nests/{id:guid}/file", async (Guid id, HttpContext http, NestDatabase db) =>
{
var precondition = IfMatch.Read(http.Request, out var expected);
if (precondition is not null) return precondition;
var (record, file, error) = await ReadMultipart(http);
if (error is not null) return error;
try
{
var updated = db.Update(id, record!, file!, expected);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}
catch (RevisionConflictException conflict)
{
return Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
}
});
app.MapPut("/api/nests/{id:guid}/metadata", async (Guid id, HttpRequest request, NestDatabase db) =>
{
var error = IfMatch.Read(request, out var expected);
if (error is not null) return error;
NestRecord? record;
try { record = await request.ReadFromJsonAsync<NestRecord>(NestMetadata.Options); }
catch (Exception ex) when (ex is JsonException or InvalidOperationException or BadHttpRequestException)
{ return Results.BadRequest("Invalid metadata JSON."); }
if (record is null) return Results.BadRequest("Missing metadata.");
if (NestMetadata.GetError(record) is { } invalid) return Results.BadRequest(invalid);
try
{
var updated = db.UpdateMetadata(id, record, expected);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
});
app.MapDelete("/api/nests/{id:guid}", (Guid id, HttpRequest request, NestDatabase db) =>
ChangeVisibility(id, true, request, db));
app.MapPost("/api/nests/deleted/{id:guid}/recover", (Guid id, HttpRequest request, NestDatabase db) =>
ChangeVisibility(id, false, request, db));
app.MapGet("/api/nests/deleted/query", (HttpRequest request, NestDatabase db) =>
NestQueryRequest.TryParse(request.Query, out var query, out var error)
? Results.Ok(db.Query(query, deleted: true)) : Results.BadRequest(error));
app.MapGet("/api/nests/deleted/{id:guid}", (Guid id, NestDatabase db) =>
db.Get(id, deleted: true) is { } record ? Results.Ok(record) : Results.NotFound());
app.MapGet("/api/nests/deleted/{id:guid}/file", (Guid id, NestDatabase db) =>
db.GetFile(id, deleted: true) is { } file ? Results.File(file, "application/zip") : Results.NotFound());
app.MapGet("/api/nests/deleted/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
ReadHistory(id, request, db, deleted: true));
app.MapGet("/api/nests/deleted/{id:guid}/revisions/{revision:int}/file", (Guid id, int revision, NestDatabase db) =>
revision < 1 ? Results.BadRequest("Invalid revision.") :
db.GetRevisionFile(id, revision, deleted: true) is { } file ? Results.File(file, "application/zip") : Results.NotFound());
app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, int revision, HttpRequest request, NestDatabase db) =>
{
var error = IfMatch.Read(request, out var expected);
if (error is not null) return error;
if (revision < 1) return Results.BadRequest("Invalid revision.");
try
{
var restored = db.Restore(id, revision, expected);
return restored is null ? Results.NotFound() : Results.Ok(restored);
}
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
});
app.MapDrawingLibrary();
app.MapDrawingSets();
app.Run();
static IResult ChangeVisibility(Guid id, bool deleted, HttpRequest request, NestDatabase db)
{
var error = IfMatch.Read(request, out var expected);
if (error is not null) return error;
try
{
var changed = db.SetDeleted(id, deleted, expected);
return changed is null ? Results.NotFound() : Results.Ok(changed);
}
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
}
static IResult ReadHistory(Guid id, HttpRequest request, NestDatabase db, bool deleted = false)
{
var offset = 0;
var limit = 100;
foreach (var parameter in request.Query)
{
if (parameter.Value.Count != 1 || !int.TryParse(parameter.Value[0], out var value))
return Results.BadRequest("Invalid history pagination.");
switch (parameter.Key)
{
case "offset": offset = value; break;
case "limit": limit = value; break;
default: return Results.BadRequest("Unknown history parameter.");
}
}
if (offset < 0 || limit is < 1 or > 200) return Results.BadRequest("Invalid history pagination.");
return db.History(id, offset, limit, deleted) is { } page ? Results.Ok(page) : Results.NotFound();
}
// Shared multipart reader for upload endpoints. Returns a (record, file, error) triple.
static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultipart(HttpContext http)
{
if (!http.Request.HasFormContentType)
return (null, null, Results.BadRequest("Expected multipart/form-data with metadata and file parts."));
IFormCollection form;
try
{
form = await http.Request.ReadFormAsync();
}
catch (Exception ex) when (ex is InvalidDataException || (ex is IOException && ex is not BadHttpRequestException))
{
// A truncated or malformed multipart body is a client error. BadHttpRequestException
// (for example, an oversized body) keeps the status code Kestrel assigns it.
return (null, null, Results.BadRequest("Malformed multipart body."));
}
if (!NestMetadata.TryRead(form["metadata"].FirstOrDefault(), out var record, out var metadataError))
return (null, null, Results.BadRequest(metadataError));
var filePart = form.Files["file"];
if (filePart is null || filePart.Length == 0)
return (null, null, Results.BadRequest("Missing or empty 'file' part."));
using var stream = filePart.OpenReadStream();
using var buffer = new MemoryStream((int)filePart.Length);
await stream.CopyToAsync(buffer);
return (record, buffer.ToArray(), null);
}
// Entry-point type for in-memory integration tests (WebApplicationFactory<Program>).
public partial class Program { }