Skip to content

Commit 0be9d27

Browse files
LEGLINK-788: Update Patient Bundle Upload (#1781)
* checkin * Rabbit Comment * Checkin * Checkin * Potential fix for pull request finding 'CodeQL / Log entries created from user input' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Rabbit Comments * Fix some unit tests --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 8d260c6 commit 0be9d27

18 files changed

Lines changed: 1803 additions & 97 deletions

DotNet/Automation.UI/Automation.UI.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
</ItemGroup>
2424

2525
<ItemGroup>
26+
<PackageReference Include="Azure.Storage.Blobs" />
2627
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
2728
</ItemGroup>
2829

DotNet/Automation.UI/Controllers/ScenariosController.cs

Lines changed: 250 additions & 16 deletions
Large diffs are not rendered by default.

DotNet/Automation.UI/Program.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using LantanaGroup.Link.Shared.Settings;
1212
using Microsoft.AspNetCore.DataProtection;
1313
using Microsoft.AspNetCore.DataProtection.KeyManagement;
14+
using Microsoft.AspNetCore.Http.Features;
1415
using Microsoft.AspNetCore.HttpOverrides;
1516
using Microsoft.IdentityModel.Tokens;
1617
using MongoDB.Driver;
@@ -36,6 +37,7 @@
3637

3738
// -- Bind options --
3839
builder.Services.Configure<AutomationConfig>(builder.Configuration.GetSection("Automation"));
40+
builder.Services.Configure<ImportedBundleBlobStorageSettings>(builder.Configuration.GetSection(ImportedBundleBlobStorageSettings.Key));
3941

4042
var lokiUrl = builder.Configuration["Loki:Url"];
4143
if (string.IsNullOrWhiteSpace(lokiUrl))
@@ -225,6 +227,7 @@ static IReadOnlyCollection<string> BuildValidAudiences(string configuredAudience
225227
});
226228

227229
builder.Services.AddSingleton<MongoIndexManager>();
230+
builder.Services.AddSingleton<IImportedBundleContentStore, AzureBlobImportedBundleContentStore>();
228231
builder.Services.AddSingleton<ISnapshotStore, MongoSnapshotStore>();
229232
builder.Services.AddSingleton<IScenarioStore, MongoScenarioStore>();
230233
builder.Services.AddSingleton<IQueryPlanTemplateStore, MongoQueryPlanTemplateStore>();
@@ -255,6 +258,7 @@ static IReadOnlyCollection<string> BuildValidAudiences(string configuredAudience
255258
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.Seeding.IApiHealthSeedContextAccessor, Automation.UI.Services.ApiHealth.Seeding.ApiHealthSeedContextAccessor>();
256259
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.Seeding.IApiHealthSeedOrchestrator, Automation.UI.Services.ApiHealth.Seeding.ApiHealthSeedOrchestrator>();
257260
builder.Services.AddHostedService<ScenarioRunStartupRecoveryService>();
261+
builder.Services.AddHostedService<ImportedBundleBlobMigrationService>();
258262
builder.Services.AddHostedService<Automation.UI.Services.ApiHealth.ApiHealthStartupRecoveryService>();
259263
builder.Services.AddHttpClient("ApiHealthTest");
260264
builder.Services.AddHealthChecks();
@@ -278,6 +282,14 @@ static IReadOnlyCollection<string> BuildValidAudiences(string configuredAudience
278282
builder.Services.AddHostedService<NormalizationSuiteSeedService>();
279283
builder.Services.AddHostedService<OrganizationResourceMapTemplateSeedService>();
280284

285+
// Allow large imported-patient bundle uploads in the Automation UI.
286+
builder.Services.Configure<FormOptions>(options =>
287+
{
288+
options.MultipartBodyLengthLimit = long.MaxValue;
289+
options.ValueLengthLimit = int.MaxValue;
290+
options.MultipartHeadersLengthLimit = int.MaxValue;
291+
});
292+
281293
// -- Seed synthetic runs for dashboard verification.
282294
// Gated on config (Dashboard:SeedFakeRuns). Used for Debugging Dashbhoard.
283295
if (builder.Configuration.GetValue<bool?>("Dashboard:SeedFakeRuns") ?? false)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
using System.Runtime.CompilerServices;
2+
3+
[assembly: InternalsVisibleTo("ServiceTests")]
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using Azure.Storage.Blobs;
2+
using Azure.Storage.Blobs.Models;
3+
using Microsoft.Extensions.Options;
4+
using System.Text;
5+
6+
namespace Automation.UI.Services.Persistence;
7+
8+
public sealed class AzureBlobImportedBundleContentStore : IImportedBundleContentStore
9+
{
10+
private readonly ImportedBundleBlobStorageSettings _settings;
11+
private readonly BlobContainerClient _container;
12+
13+
public AzureBlobImportedBundleContentStore(IOptions<ImportedBundleBlobStorageSettings> settings)
14+
{
15+
_settings = settings.Value;
16+
17+
if (string.IsNullOrWhiteSpace(_settings.ConnectionString))
18+
throw new InvalidOperationException("InternalBlobStorage:ConnectionString is required for imported bundle storage.");
19+
if (string.IsNullOrWhiteSpace(_settings.BlobContainerName))
20+
throw new InvalidOperationException("InternalBlobStorage:BlobContainerName is required for imported bundle storage.");
21+
22+
_container = new BlobContainerClient(_settings.ConnectionString, _settings.BlobContainerName);
23+
}
24+
25+
public async Task<StoredImportedBundleContent> StoreAsync(Guid bundleId, string contentHash, string bundleJson, CancellationToken ct = default)
26+
{
27+
if (string.IsNullOrWhiteSpace(bundleJson))
28+
throw new InvalidOperationException("Bundle JSON is required.");
29+
30+
await _container.CreateIfNotExistsAsync(cancellationToken: ct);
31+
32+
var blobName = BuildBlobName(bundleId, contentHash);
33+
var bytes = Encoding.UTF8.GetBytes(bundleJson);
34+
var blob = _container.GetBlobClient(blobName);
35+
36+
var exists = await blob.ExistsAsync(ct);
37+
if (exists.Value)
38+
{
39+
var props = await blob.GetPropertiesAsync(cancellationToken: ct);
40+
return new StoredImportedBundleContent(blobName, props.Value.ContentLength);
41+
}
42+
43+
using var stream = new MemoryStream(bytes, writable: false);
44+
await blob.UploadAsync(stream, overwrite: true, cancellationToken: ct);
45+
await blob.SetHttpHeadersAsync(new BlobHttpHeaders
46+
{
47+
ContentType = "application/fhir+json"
48+
}, cancellationToken: ct);
49+
50+
return new StoredImportedBundleContent(blobName, bytes.LongLength);
51+
}
52+
53+
public async Task<string?> ReadAsync(ImportedBundleDocument bundle, CancellationToken ct = default)
54+
{
55+
if (!string.IsNullOrWhiteSpace(bundle.BundleBlobName))
56+
{
57+
var blob = _container.GetBlobClient(bundle.BundleBlobName);
58+
var exists = await blob.ExistsAsync(ct);
59+
if (!exists.Value)
60+
return null;
61+
62+
var download = await blob.DownloadContentAsync(ct);
63+
return download.Value.Content.ToString();
64+
}
65+
66+
return bundle.BundleJson;
67+
}
68+
69+
public async Task DeleteAsync(ImportedBundleDocument bundle, CancellationToken ct = default)
70+
{
71+
if (string.IsNullOrWhiteSpace(bundle.BundleBlobName))
72+
return;
73+
74+
await _container.DeleteBlobIfExistsAsync(bundle.BundleBlobName, cancellationToken: ct);
75+
}
76+
77+
private string BuildBlobName(Guid bundleId, string contentHash)
78+
{
79+
var root = string.IsNullOrWhiteSpace(_settings.BlobRoot)
80+
? "automation/imported-bundles"
81+
: _settings.BlobRoot.Trim('/');
82+
83+
return $"{root}/{bundleId:N}-{contentHash}.json";
84+
}
85+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Automation.UI.Services.Persistence;
2+
3+
public sealed record StoredImportedBundleContent(string BlobName, long ByteCount);
4+
5+
public interface IImportedBundleContentStore
6+
{
7+
Task<StoredImportedBundleContent> StoreAsync(Guid bundleId, string contentHash, string bundleJson, CancellationToken ct = default);
8+
Task<string?> ReadAsync(ImportedBundleDocument bundle, CancellationToken ct = default);
9+
Task DeleteAsync(ImportedBundleDocument bundle, CancellationToken ct = default);
10+
}

0 commit comments

Comments
 (0)