Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/Ignis.Api/Configuration/ImportSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026, Incendi <info@incendi.no>
*
* SPDX-License-Identifier: BSD-3-Clause
*/

namespace Ignis.Api.Configuration;

/// <summary>
/// Settings for archive import; bound from the <c>ImportSettings</c>
/// configuration section.
/// </summary>
public sealed class ImportSettings
{
/// <summary>
/// Max upload size for <c>$archive-import</c> in bytes. Applied per-request
/// via <c>ImportRequestSizeLimitFilter</c>; opts up from the global
/// Kestrel default. Default 50 MiB.
/// </summary>
public long MaxUploadSizeBytes { get; set; } = 50 * 1024 * 1024;
}
91 changes: 71 additions & 20 deletions src/Ignis.Api/Controllers/ImportController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

using Ignis.Api.Configuration;
using Ignis.Api.Extensions;
using Ignis.Api.Filters;
using Ignis.Api.Services.BackgroundTasks;
using Ignis.Api.Services.Import;
using Ignis.Auth.Authorization;

Expand All @@ -27,43 +29,92 @@ namespace Ignis.Api.Controllers;
[Route("fhir"), ApiController]
[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
public class ImportController(
IImportService importService,
BackgroundTaskQueue backgroundTaskQueue,
IOptions<FeatureSettings> featureSettings,
ILogger<ImportController> logger) : ControllerBase
{
// One import at a time: the drainer is single-reader and uploads sit in
// memory until processed, so a second caller gets 429 instead of stacking
// another buffer.
private static readonly SemaphoreSlim _importSlot = new(initialCount: 1, maxCount: 1);

/// <summary>
/// Imports an archive (zip) of JSON-serialized FHIR resources.
/// Requires the <c>operations.import</c> scope and the
/// <c>FeatureManagement:AllowImport</c> flag to be enabled — otherwise the
/// endpoint responds with <c>503 Service Unavailable</c>.
/// Returns <c>202 Accepted</c> with an <see cref="OperationOutcome"/>
/// carrying the operation id; subsequent progress, completion, or error is
/// reported via the operations hub. Archive parsing and ingestion are not
/// yet implemented — the service stub publishes an error event on the hub.
/// Imports a zip archive of JSON-serialized FHIR resources. Requires the
/// <c>operations.import</c> scope and <c>FeatureManagement:AllowImport</c>;
/// otherwise responds with <c>503</c>. Returns <c>202</c> with an
/// <see cref="OperationOutcome"/> carrying the operation id; the archive
/// is buffered, queued, and progress/completion/error is reported via the
/// operations hub.
/// </summary>
[HttpPost("$archive-import"), Tags("Operations")]
[Authorize(Policy = OperationsPolicies.Import)]
[Consumes("multipart/form-data")]
[ServiceFilter<ImportRequestSizeLimitFilter>]
public async Task<FhirResponse> ArchiveImport([FromForm] IFormFile file)
{
if (!featureSettings.Value.AllowImport)
return Respond.WithError(
HttpStatusCode.ServiceUnavailable,
"Archive import is not enabled on this server.");

var operationId = Guid.NewGuid();
logger.LogInformation(
"Archive import requested by {Subject} (operation {OperationId}, {Bytes} bytes).",
User.FindFirst(OpenIddictConstants.Claims.Subject)?.Value ?? "unknown",
operationId,
file.Length);
if (!_importSlot.Wait(0))
{
logger.LogInformation(
"Archive import rejected (already in progress) for {Subject}.",
User.FindFirst(OpenIddictConstants.Claims.Subject)?.Value ?? "unknown");
return Respond.WithError(
HttpStatusCode.TooManyRequests,
"Another archive import is already in progress. Try again when it completes.");
}

// Worker takes over the slot and the buffer once queued; until then the controller owns both.
MemoryStream? buffer = null;
var handedOffToWorker = false;
try
{
var operationId = Guid.NewGuid();
logger.LogInformation(
"Archive import requested by {Subject} (operation {OperationId}, {Bytes} bytes).",
User.FindFirst(OpenIddictConstants.Claims.Subject)?.Value ?? "unknown",
operationId,
file.Length);

// IFormFile closes with the request scope; worker reads after response.
buffer = new MemoryStream();
await using (var stream = file.OpenReadStream())
await stream.CopyToAsync(buffer, HttpContext.RequestAborted);
buffer.Position = 0;
Comment thread
losolio marked this conversation as resolved.

await importService.ImportArchiveAsync(operationId);
await backgroundTaskQueue.QueueAsync(async (services, _) =>
{
try
{
await using (buffer)
{
var importer = services.GetRequiredService<IImportService>();
await importer.ImportZipArchiveAsync(operationId, buffer);
}
}
finally
{
_importSlot.Release();
}
});
handedOffToWorker = true;

var outcome = new OperationOutcome()
.WithOperationId(operationId)
.AddInformationIssue("Import accepted; progress will be reported via the operations hub.");
var outcome = new OperationOutcome()
.WithOperationId(operationId)
.AddInformationIssue("Import accepted; progress will be reported via the operations hub.");

return Respond.WithResource(StatusCodes.Status202Accepted, outcome);
return Respond.WithResource(StatusCodes.Status202Accepted, outcome);
}
finally
{
if (!handedOffToWorker)
{
_importSlot.Release();
buffer?.Dispose();
}
}
}
}
39 changes: 39 additions & 0 deletions src/Ignis.Api/Filters/ImportRequestSizeLimitFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026, Incendi <info@incendi.no>
*
* SPDX-License-Identifier: BSD-3-Clause
*/

using Ignis.Api.Configuration;

using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;

namespace Ignis.Api.Filters;

/// <summary>
/// Resource filter that overrides the request's max body size from
/// <see cref="ImportSettings.MaxUploadSizeBytes"/>. Runs before model
/// binding reads the body, so the limit applies to the upload itself.
/// </summary>
public sealed class ImportRequestSizeLimitFilter : IAsyncResourceFilter
{
private readonly long _maxBytes;

public ImportRequestSizeLimitFilter(IOptions<ImportSettings> options)
{
ArgumentNullException.ThrowIfNull(options);
_maxBytes = options.Value.MaxUploadSizeBytes;
}

public Task OnResourceExecutionAsync(
ResourceExecutingContext context,
ResourceExecutionDelegate next)
{
var feature = context.HttpContext.Features.Get<IHttpMaxRequestBodySizeFeature>();
if (feature is { IsReadOnly: false })
feature.MaxRequestBodySize = _maxBytes;
return next();
}
}
5 changes: 5 additions & 0 deletions src/Ignis.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

using Ignis.Api.Configuration;
using Ignis.Api.Extensions;
using Ignis.Api.Filters;
using Ignis.Api.Hubs;
using Ignis.Api.Services.BackgroundTasks;
using Ignis.Api.Services.Import;
Expand Down Expand Up @@ -51,6 +52,10 @@
// Bind feature flags
builder.Services.Configure<FeatureSettings>(builder.Configuration.GetSection("FeatureManagement"));

// Bind import settings + per-action size filter used by $archive-import
builder.Services.Configure<ImportSettings>(builder.Configuration.GetSection("ImportSettings"));
builder.Services.AddScoped<ImportRequestSizeLimitFilter>();

// Bind forwarded headers settings
// Middleware is only added if at least one of KnownProxies or KnownNetworks are configured.
var forwardedHeadersSettings = new ForwardedHeadersSettings();
Expand Down
7 changes: 4 additions & 3 deletions src/Ignis.Api/Services/Import/IImportService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ namespace Ignis.Api.Services.Import;
public interface IImportService
{
/// <summary>
/// Imports an archive (zip) of JSON-serialized FHIR resources.
/// Archive parsing, extraction limits, and resource ingestion are not yet implemented.
/// Reads a zip archive and reports the number of entries it contains via
/// the operations hub. Resource parsing and ingestion are out of scope for
/// the current slice; later slices will extend the implementation.
/// </summary>
Task ImportArchiveAsync(Guid operationId);
Task ImportZipArchiveAsync(Guid operationId, Stream archive);
}
79 changes: 72 additions & 7 deletions src/Ignis.Api/Services/Import/ImportService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* SPDX-License-Identifier: BSD-3-Clause
*/

using System.IO.Compression;

using Ignis.Api.Services.Operations;

namespace Ignis.Api.Services.Import;
Expand All @@ -12,14 +14,77 @@ public sealed class ImportService(
IOperationProgressNotifier notifier,
ILogger<ImportService> logger) : IImportService
{
public async Task ImportArchiveAsync(Guid operationId)
public async Task ImportZipArchiveAsync(Guid operationId, Stream archive)
{
logger.LogInformation(
"Archive import requested but not yet implemented (operation {OperationId}).",
operationId);
try
{
using var zip = new ZipArchive(archive, ZipArchiveMode.Read, leaveOpen: true);
var entryCount = zip.Entries.Count;

logger.LogInformation(
"Archive opened (operation {OperationId}, {EntryCount} entries).",
operationId, entryCount);

await notifier.ProgressAsync(
operationId,
$"Found {entryCount} entries in archive.",
new OperationProgress(Current: 0, Total: entryCount))
.ConfigureAwait(false);

var current = 0;
foreach (var entry in zip.Entries)
{
current++;
await IngestEntryAsync(operationId, entry).ConfigureAwait(false);
await notifier.ProgressAsync(
operationId,
$"Processed {TruncateName(entry.FullName)}",
Comment thread
losolio marked this conversation as resolved.
new OperationProgress(Current: current, Total: entryCount))
.ConfigureAwait(false);
}

await notifier
.CompletedAsync(operationId, $"Enumerated {entryCount} entries.")
.ConfigureAwait(false);
}
catch (Exception ex) when (
ex is InvalidDataException or
ArgumentOutOfRangeException or
EndOfStreamException)
Comment thread
losolio marked this conversation as resolved.
{
logger.LogWarning(
ex,
"Archive could not be opened (operation {OperationId}).",
operationId);

await notifier
.ErrorAsync(operationId, "Archive import is not yet implemented.")
.ConfigureAwait(false);
await notifier
.ErrorAsync(operationId, "Not able to parse uploaded zip archive.")
.ConfigureAwait(false);
}
catch (Exception ex)
{
// Catch-all so the hub always emits an Error event; otherwise the client waits indefinitely.
logger.LogError(
ex,
"Unexpected error during archive import (operation {OperationId}).",
operationId);

await notifier
.ErrorAsync(operationId, "Unexpected error while importing archive.")
.ConfigureAwait(false);
}
}

// Stub — will be replaced by FHIR parsing + write in a later slice.
private Task IngestEntryAsync(Guid operationId, ZipArchiveEntry entry)
{
logger.LogDebug(
"Entry stub (operation {OperationId}): {Name} ({Size} bytes).",
operationId, TruncateName(entry.FullName), entry.Length);
return Task.CompletedTask;
}

// Cap names before they hit logs or hub events — zip permits arbitrarily long names.
private static string TruncateName(string name) =>
name.Length > 200 ? name[..200] + "…" : name;
}
3 changes: 3 additions & 0 deletions src/Ignis.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"WriteTo": [{ "Name": "Console" }]
},
"AllowedHosts": "localhost",
"ImportSettings": {
"MaxUploadSizeBytes": 52428800
},
"StoreSettings": {
"ConnectionString": "mongodb://localhost:27017/ignis"
},
Expand Down
Loading