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:
@@ -1,13 +1,14 @@
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Stored document metadata. The demo deliberately keeps NO file content — only
|
||||
/// metadata — which is enough for status/delete/link and avoids holding PII bytes
|
||||
/// in memory. A real backend persists the bytes to blob storage keyed by DocumentId.
|
||||
/// Stored document: metadata + bytes. The demo holds bytes IN-MEMORY (reset on
|
||||
/// restart) purely so re-opened wizards can preview/download what was uploaded — a
|
||||
/// 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>
|
||||
public sealed record StoredDocument(
|
||||
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; }
|
||||
}
|
||||
@@ -28,9 +29,9 @@ public static class DocumentStore
|
||||
private static readonly List<AuditEntry> _audit = 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;
|
||||
Audit("upload", doc.DocumentId, categoryId, owner);
|
||||
return doc;
|
||||
|
||||
@@ -107,11 +107,25 @@ api.MapPost("/uploads", async (HttpRequest request) =>
|
||||
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
|
||||
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));
|
||||
})
|
||||
.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.
|
||||
api.MapGet("/uploads/status", (string? localIds) =>
|
||||
{
|
||||
|
||||
@@ -164,6 +164,24 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
|
||||
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]
|
||||
public async Task Upload_rejects_wrong_type()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user