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) },
};
}