feat(server): add OpenNest.Server SQLite nest storage API

SQLite-backed (Microsoft.Data.Sqlite, WAL) minimal API storing NestRecord
metadata plus the .nest archive as a BLOB. Endpoints: GET/POST /api/nests,
GET /api/nests/{id}[/file], PUT /api/nests/{id}/file, PUT
/api/nests/{id}/metadata, DELETE /api/nests/{id}, GET /healthz. Multipart
upload contract matches RemoteNestRepository (metadata JSON part + file
part). Added to OpenNest.sln, builds standalone on Linux. Dockerfile
publishes to a runtime image listening on :8090 with a /app/data volume
for the SQLite file. docs/nest-storage.md documents the wire contract,
endpoints and deployment.

Full curl round trip verified manually against a running instance:
upload (server-assigned id + computed fileSize), list, get, byte-exact
file download, metadata-only update (archive unchanged), file update
(new archive persisted), 404s for unknown ids, delete, post-delete 404.
This commit is contained in:
aj
2026-09-29 16:21:17 -04:00
parent 10ed3d3a30
commit 437d659c9d
6 changed files with 449 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# Build from the repository root: docker build -f OpenNest.Server/Dockerfile -t opennest-server .
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY OpenNest.Server/OpenNest.Server.csproj OpenNest.Server/
COPY OpenNest.Data/OpenNest.Data.csproj OpenNest.Data/
RUN dotnet restore OpenNest.Server/OpenNest.Server.csproj
COPY OpenNest.Server/ OpenNest.Server/
COPY OpenNest.Data/ OpenNest.Data/
RUN dotnet publish OpenNest.Server/OpenNest.Server.csproj -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app .
# SQLite database + .nest blobs live here; mount a volume at this path.
VOLUME /app/data
ENV OPENNEST_DB=/app/data/nests.db
ENV ASPNETCORE_URLS=http://+:8090
EXPOSE 8090
ENTRYPOINT ["dotnet", "OpenNest.Server.dll"]
+180
View File
@@ -0,0 +1,180 @@
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// SQLite-backed nest storage: metadata columns plus the .nest archive as a BLOB.
/// The database is created on first use. Records are keyed by client-generated ids
/// so a PC that loses its local id mapping can still re-upload under a new id;
/// updates keep the id.
/// </summary>
public sealed class NestDatabase : IDisposable
{
private readonly SqliteConnection _connection;
public NestDatabase(string databasePath)
{
var directory = Path.GetDirectoryName(Path.GetFullPath(databasePath));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
_connection = new SqliteConnection($"Data Source={databasePath}");
_connection.Open();
Execute("""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS nests (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
customer TEXT NOT NULL DEFAULT '',
dateCreated TEXT NOT NULL,
dateModified TEXT NOT NULL,
material TEXT NOT NULL DEFAULT '',
thickness REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'Quote',
plateCount INTEGER NOT NULL DEFAULT 0,
partCount INTEGER NOT NULL DEFAULT 0,
comments TEXT NOT NULL DEFAULT '',
madeBy TEXT NOT NULL DEFAULT '',
fileSize INTEGER NOT NULL DEFAULT 0,
savedAt TEXT NOT NULL,
file BLOB NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_nests_savedAt ON nests(savedAt DESC);
""");
}
public IReadOnlyList<NestRecord> List()
{
using var command = _connection.CreateCommand();
command.CommandText =
$"SELECT {RecordColumns} FROM nests ORDER BY savedAt DESC";
var records = new List<NestRecord>();
using var reader = command.ExecuteReader();
while (reader.Read())
records.Add(ReadRecord(reader));
return records;
}
public NestRecord? Get(Guid id)
{
using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id";
command.Parameters.AddWithValue("$id", id.ToString());
using var reader = command.ExecuteReader();
return reader.Read() ? ReadRecord(reader) : null;
}
public byte[]? GetFile(Guid id)
{
using var command = _connection.CreateCommand();
command.CommandText = "SELECT file FROM nests WHERE id = $id";
command.Parameters.AddWithValue("$id", id.ToString());
var value = command.ExecuteScalar();
return value is byte[] bytes ? bytes : null;
}
public NestRecord Insert(Guid id, NestRecord record, byte[] file)
{
using var command = _connection.CreateCommand();
command.CommandText = """
INSERT INTO nests (id, name, customer, dateCreated, dateModified, material,
thickness, status, plateCount, partCount, comments, madeBy, fileSize,
savedAt, file)
VALUES ($id, $name, $customer, $dateCreated, $dateModified, $material,
$thickness, $status, $plateCount, $partCount, $comments, $madeBy,
$fileSize, $savedAt, $file)
""";
AddRecordParameters(command, id, record, file, updateFile: true);
command.ExecuteNonQuery();
return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
}
public NestRecord? Update(Guid id, NestRecord record, byte[]? file)
{
if (Get(id) is null)
return null;
using var command = _connection.CreateCommand();
var fileClause = file is null ? "" : ", file = $file, fileSize = $fileSize";
command.CommandText = $"""
UPDATE nests SET
name = $name, customer = $customer, dateCreated = $dateCreated,
dateModified = $dateModified, material = $material,
thickness = $thickness, status = $status, plateCount = $plateCount,
partCount = $partCount, comments = $comments, madeBy = $madeBy,
savedAt = $savedAt{fileClause}
WHERE id = $id
""";
AddRecordParameters(command, id, record, file, updateFile: file is not null);
command.ExecuteNonQuery();
return Get(id);
}
public bool Delete(Guid id)
{
using var command = _connection.CreateCommand();
command.CommandText = "DELETE FROM nests WHERE id = $id";
command.Parameters.AddWithValue("$id", id.ToString());
return command.ExecuteNonQuery() > 0;
}
public void Dispose() => _connection.Dispose();
private void Execute(string sql)
{
using var command = _connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
private const string RecordColumns = """
id, name, customer, dateCreated, dateModified, material, thickness,
status, plateCount, partCount, comments, madeBy, fileSize, savedAt
""";
private static void AddRecordParameters(
SqliteCommand command, Guid id, NestRecord record, byte[]? file, bool updateFile)
{
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$name", record.Name);
command.Parameters.AddWithValue("$customer", record.Customer);
command.Parameters.AddWithValue("$dateCreated", record.DateCreated.ToString("o"));
command.Parameters.AddWithValue("$dateModified", record.DateModified.ToString("o"));
command.Parameters.AddWithValue("$material", record.Material);
command.Parameters.AddWithValue("$thickness", record.Thickness);
command.Parameters.AddWithValue("$status", record.Status.ToString());
command.Parameters.AddWithValue("$plateCount", record.PlateCount);
command.Parameters.AddWithValue("$partCount", record.PartCount);
command.Parameters.AddWithValue("$comments", record.Comments);
command.Parameters.AddWithValue("$madeBy", record.MadeBy);
command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o"));
if (updateFile)
{
command.Parameters.AddWithValue("$file", file!);
command.Parameters.AddWithValue("$fileSize", file!.LongLength);
}
}
private static NestRecord ReadRecord(SqliteDataReader reader)
{
return new NestRecord
{
Id = Guid.Parse(reader.GetString(0)),
Name = reader.GetString(1),
Customer = reader.GetString(2),
DateCreated = DateTime.Parse(reader.GetString(3)),
DateModified = DateTime.Parse(reader.GetString(4)),
Material = reader.GetString(5),
Thickness = reader.GetDouble(6),
Status = Enum.TryParse<NestStatus>(reader.GetString(7), true, out var s)
? s : NestStatus.Quote,
PlateCount = (int)reader.GetInt64(8),
PartCount = (int)reader.GetInt64(9),
Comments = reader.GetString(10),
MadeBy = reader.GetString(11),
FileSize = reader.GetInt64(12),
SavedAt = DateTime.Parse(reader.GetString(13)),
};
}
}
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Server</RootNamespace>
<AssemblyName>OpenNest.Server</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
</ItemGroup>
</Project>
+129
View File
@@ -0,0 +1,129 @@
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();
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
app.MapGet("/api/nests", (NestDatabase db) => Results.Ok(db.List()));
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.MapPut("/api/nests/{id:guid}/file", async (Guid id, HttpContext http, NestDatabase db) =>
{
var (record, file, error) = await ReadMultipart(http);
if (error is not null)
return error;
var updated = db.Update(id, record!, file);
return updated is null ? Results.NotFound() : Results.Ok(updated);
});
app.MapPut("/api/nests/{id:guid}/metadata", async (Guid id, HttpContext http, NestDatabase db) =>
{
NestRecord? record;
try
{
record = await http.Request.ReadFromJsonAsync<NestRecord>();
}
catch (JsonException)
{
return Results.BadRequest("Invalid metadata JSON.");
}
if (record is null)
return Results.BadRequest("Missing metadata.");
// Keep the stored archive's size; metadata updates never touch the file.
var updated = db.Update(id, record, file: null);
return updated is null ? Results.NotFound() : Results.Ok(updated);
});
app.MapDelete("/api/nests/{id:guid}", (Guid id, NestDatabase db) =>
db.Delete(id) ? Results.NoContent() : Results.NotFound());
app.Run();
// 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."));
var form = await http.Request.ReadFormAsync();
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) },
};
}
+14
View File
@@ -48,6 +48,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.FrontEnd.Tests", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Reporting", "OpenNest.Reporting\OpenNest.Reporting.csproj", "{5C3708F5-DD69-436D-B412-7FF360DDB3DC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server", "OpenNest.Server\OpenNest.Server.csproj", "{A2FC654E-8F13-4AD7-BED2-455F2B690961}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -298,6 +300,18 @@ Global
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|x64.Build.0 = Release|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|x86.ActiveCfg = Release|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|x86.Build.0 = Release|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Debug|x64.ActiveCfg = Debug|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Debug|x64.Build.0 = Debug|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Debug|x86.ActiveCfg = Debug|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Debug|x86.Build.0 = Debug|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|Any CPU.Build.0 = Release|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x64.ActiveCfg = Release|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x64.Build.0 = Release|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.ActiveCfg = Release|Any CPU
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+84
View File
@@ -0,0 +1,84 @@
# Nest storage: File mode vs. Database mode
The desktop app can save nests two ways:
- **File mode** (default, unchanged behavior): Save/Save As write a `.nest` ZIP
archive to disk via the normal file dialog. See [nest-file-format.md](nest-file-format.md).
- **Database mode**: Save uploads the nest to a central `OpenNest.Server`
instance shared by every shop PC, instead of writing a local file. A separate
**File > Export .nest...** command is always available (in both modes) for
producing a local file to share or back up.
The mode and server address are stored per-PC at `%APPDATA%\OpenNest\storage.json`
(`OpenNest.Data.NestStorageSettings`), defaulting to File mode so existing
installs are unaffected until an operator opts in via **File > Storage Mode...**.
## Wire contract
`OpenNest.Data.NestRecord` is the metadata DTO shared by the client
(`RemoteNestRepository`) and the server (`OpenNest.Server`). JSON is camelCase
with enums serialized as strings (`JsonSerializerDefaults.Web` +
`JsonStringEnumConverter(JsonNamingPolicy.CamelCase)`), matching the rest of
`OpenNest.Data`'s local JSON stores.
| Field | Type | Notes |
|---|---|---|
| `id` | guid | Client-generated on first save; kept on updates. |
| `name` | string | |
| `customer` | string | |
| `dateCreated` / `dateModified` | datetime | Set by the client from the nest's own metadata. |
| `material` | string | |
| `thickness` | number | |
| `status` | `"quote"` \| `"toBeCut"` \| `"hasBeenCut"` | Default `quote`. |
| `plateCount` / `partCount` | integer | Computed client-side at save time (non-cutoff parts only). |
| `comments` | string | |
| `madeBy` | string | |
| `fileSize` | integer | Server-computed; ignored on upload. |
| `savedAt` | datetime | Server-computed; ignored on upload. |
## Endpoints
| Method | Path | Body | Response |
|---|---|---|---|
| GET | `/healthz` | — | `{ "status": "ok" }` |
| GET | `/api/nests` | — | `NestRecord[]`, newest `savedAt` first |
| GET | `/api/nests/{id}` | — | `NestRecord` or 404 |
| GET | `/api/nests/{id}/file` | — | `.nest` archive bytes (`application/zip`) or 404 |
| POST | `/api/nests` | multipart: `metadata` (JSON `NestRecord`) + `file` (`.nest` bytes) | `NestRecord` with server-assigned `id` when the client sends an empty guid |
| PUT | `/api/nests/{id}/file` | multipart: `metadata` + `file` | Updated `NestRecord`, or 404 if `id` is unknown |
| PUT | `/api/nests/{id}/metadata` | JSON `NestRecord` | Updated `NestRecord` (archive untouched), or 404 |
| DELETE | `/api/nests/{id}` | — | 204, or 404 |
There is no authentication; this is a LAN-only service. Do not expose it
outside the shop network without adding one.
## Storage
`OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with
`journal_mode=WAL`. Each row holds the metadata columns plus the `.nest`
archive as a `BLOB`. The database file path comes from `--database=<path>`,
then `OPENNEST_DB`, defaulting to `./data/nests.db`.
## Running
Locally:
```sh
dotnet run --project OpenNest.Server/OpenNest.Server.csproj
```
Listens on `ASPNETCORE_URLS` (default Kestrel ports) unless overridden.
Docker (built from the repository root so it can see `OpenNest.Data`):
```sh
docker build -f OpenNest.Server/Dockerfile -t opennest-server .
docker run -d -p 8090:8090 -v opennest-data:/app/data opennest-server
```
The image listens on `:8090` and stores `nests.db` under `/app/data`, which
should be a named volume or bind mount so nests survive container recreation.
Point the desktop app's **File > Storage Mode...** server URL at
`http://<host>:8090` (no trailing slash required; `RemoteNestRepository`
trims it).