Mijn aanvragen (B): store document bytes + content endpoint

- StoredDocument gains ContentType + byte[] Content; POST /uploads now captures
  the file bytes (in-memory, reset on restart — POC).
- GET /uploads/{documentId}/content streams the bytes: inline for pdf/image
  (browser preview), attachment otherwise (download). 404 for unknown ids
  (covers the demo-* simulation sentinels, which have no bytes).
- Bytes are never serialized into a JSON response; only this endpoint streams them.
- Tests: content served back with type inline for pdf, 404 for unknown. 56/56 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 11:36:28 +02:00
parent 8c3f4c22ee
commit 7b6aac394b
3 changed files with 40 additions and 7 deletions

View File

@@ -1,13 +1,14 @@
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// Stored document metadata. The demo deliberately keeps NO file content — only /// Stored document: metadata + bytes. The demo holds bytes IN-MEMORY (reset on
/// metadata — which is enough for status/delete/link and avoids holding PII bytes /// restart) purely so re-opened wizards can preview/download what was uploaded — a
/// in memory. A real backend persists the bytes to blob storage keyed by DocumentId. /// real backend persists them to blob storage keyed by DocumentId. Bytes are never
/// serialized into a JSON response; only the dedicated content endpoint streams them.
/// </summary> /// </summary>
public sealed record StoredDocument( public sealed record StoredDocument(
string DocumentId, string LocalId, string CategoryId, string WizardId, string DocumentId, string LocalId, string CategoryId, string WizardId,
string FileName, long SizeBytes, string Owner, DateTimeOffset UploadedAt) string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt)
{ {
public bool Linked { get; set; } public bool Linked { get; set; }
} }
@@ -28,9 +29,9 @@ public static class DocumentStore
private static readonly List<AuditEntry> _audit = new(); private static readonly List<AuditEntry> _audit = new();
private static readonly object _gate = new(); private static readonly object _gate = new();
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, long sizeBytes, string owner) public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
{ {
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, sizeBytes, owner, DateTimeOffset.UtcNow); var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
lock (_gate) _docs[doc.DocumentId] = doc; lock (_gate) _docs[doc.DocumentId] = doc;
Audit("upload", doc.DocumentId, categoryId, owner); Audit("upload", doc.DocumentId, categoryId, owner);
return doc; return doc;

View File

@@ -107,11 +107,25 @@ api.MapPost("/uploads", async (HttpRequest request) =>
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length); var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400); if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.Length, DocumentStore.DemoOwner); using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner);
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId)); return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
}) })
.ExcludeFromDescription(); .ExcludeFromDescription();
// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline
// for pdf/image (browser renders it), attachment otherwise (download).
api.MapGet("/uploads/{documentId}/content", (string documentId) =>
{
var doc = DocumentStore.Get(documentId);
if (doc is null) return Results.NotFound();
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// Poll-on-return: which of these client localIds have arrived at the BFF. // Poll-on-return: which of these client localIds have arrived at the BFF.
api.MapGet("/uploads/status", (string? localIds) => api.MapGet("/uploads/status", (string? localIds) =>
{ {

View File

@@ -164,6 +164,24 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown"); Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown");
} }
[Fact]
public async Task Upload_content_is_served_back_with_its_type_inline_for_pdf()
{
var doc = await Upload(Guid.NewGuid().ToString());
var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content");
res.EnsureSuccessStatusCode();
Assert.Equal("application/pdf", res.Content.Headers.ContentType!.MediaType);
Assert.Equal(new byte[] { 1, 2, 3 }, await res.Content.ReadAsByteArrayAsync());
// pdf/image → inline (no attachment disposition) so the browser previews it
Assert.NotEqual("attachment", res.Content.Headers.ContentDisposition?.DispositionType);
}
[Fact]
public async Task Upload_content_404_for_unknown_document()
{
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).StatusCode);
}
[Fact] [Fact]
public async Task Upload_rejects_wrong_type() public async Task Upload_rejects_wrong_type()
{ {