mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 05:01:05 -04:00
The server becomes the system of record for shared drawings (cut type, thickness, material and grade, customer, notes). Schema v4 adds `drawings` (current row) and `drawing_revisions` (immutable) to the same SQLite file under the existing monitor; the nest tables are untouched and a v3 upgrade keeps every nest row byte-identical. `/api/drawings` follows the nest revision contract: client-allocated ids, If-Match on every existing-drawing write (428/400/409 with no state change), archive instead of delete, and one transaction for promotion plus snapshot. Geometry is an opaque package (a zero-plate .nest) the server never parses. Enum fields accept single names only, because the JSON enum converter would otherwise accept numbers and comma combinations. Material, grade and customer filters match SharedListNames keys so spacing and case cannot split a drawing family; thickness is stored and compared at four decimals. Shared bounds live in OpenNest.Data so the client can refuse the same requests before sending. If-Match parsing moves to one helper used by nests and drawings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
225 lines
9.7 KiB
C#
225 lines
9.7 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 OPENNEST_DB, default ./data/nests.db (a Docker volume).
|
|
var databasePath =
|
|
args.FirstOrDefault(a => a.StartsWith("--database=", StringComparison.Ordinal))?.Split('=', 2)[1]
|
|
?? Environment.GetEnvironmentVariable("OPENNEST_DB")
|
|
?? Path.Combine("data", "nests.db");
|
|
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());
|
|
|
|
// 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;
|
|
return Results.Ok(db.Insert(id, record, file!));
|
|
});
|
|
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>(); }
|
|
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.");
|
|
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.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."));
|
|
}
|
|
|
|
var metadataPart = form["metadata"].FirstOrDefault();
|
|
if (string.IsNullOrWhiteSpace(metadataPart))
|
|
return (null, null, Results.BadRequest("Missing 'metadata' part."));
|
|
|
|
NestRecord? record;
|
|
try
|
|
{
|
|
record = JsonSerializer.Deserialize<NestRecord>(metadataPart, MultipartJson.Options);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return (null, null, Results.BadRequest("Invalid metadata JSON."));
|
|
}
|
|
|
|
if (record is null)
|
|
return (null, null, Results.BadRequest("Missing metadata."));
|
|
|
|
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);
|
|
}
|
|
|
|
internal static class MultipartJson
|
|
{
|
|
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
|
};
|
|
}
|
|
|
|
// Entry-point type for in-memory integration tests (WebApplicationFactory<Program>).
|
|
public partial class Program { }
|