From 47ab3919fcdd0f93bbceec6c502a1a9a35df9ecc Mon Sep 17 00:00:00 2001 From: Ole Kristian Losvik Date: Sat, 1 Aug 2026 18:03:09 +0100 Subject: [PATCH] Api: Describe the deployment in CapabilityStatement.implementation Implementation.description in fhir/metadata now comes from CapabilityStatementSettings. Overridable through with env: CapabilityStatementSettings:ImplementationDescription --- docs/server/api-configuration.md | 8 ++++ .../charts/app/templates/api-deployment.yaml | 4 ++ infra/helm/charts/app/values.yaml | 4 ++ .../CapabilityStatementSettings.cs | 19 ++++++++ .../CapabilityStatementExtensions.cs | 5 +- src/Ignis.Api/Program.cs | 3 +- .../ProfileAwareCapabilityStatementService.cs | 22 ++++++++- tests/Ignis.Api.Tests/FhirControllerTests.cs | 46 +++++++++++++++++++ 8 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 src/Ignis.Api/Configuration/CapabilityStatementSettings.cs diff --git a/docs/server/api-configuration.md b/docs/server/api-configuration.md index 2fd6d995..afeecf9e 100644 --- a/docs/server/api-configuration.md +++ b/docs/server/api-configuration.md @@ -98,6 +98,14 @@ FHIR conformance packages the structural profile validator loads for `$validate` | -------------------------------------------- | -------- | ------- | ------------------------ | | `ProfileValidationSettings:PackageDirectory` | no | empty | PVC mount in Kubernetes. | +## CapabilityStatementSettings + +What `/fhir/metadata` says this deployment is, as `CapabilityStatement.implementation.description`. The default warns against personal data and deliberately claims nothing about retention or visibility, since those differ per deployment — a server holding real data has to describe itself. + +| Key | Required | Default | Notes | +| ------------------------------------------------------- | -------- | ------------------ | ------------------------------------------- | +| `CapabilityStatementSettings:ImplementationDescription` | no | test-server notice | Empty leaves Spark's own description alone. | + ## Serilog Console logging. Defaults emit human-readable output; switch to compact JSON for log aggregators. diff --git a/infra/helm/charts/app/templates/api-deployment.yaml b/infra/helm/charts/app/templates/api-deployment.yaml index 08a7b262..efca793a 100644 --- a/infra/helm/charts/app/templates/api-deployment.yaml +++ b/infra/helm/charts/app/templates/api-deployment.yaml @@ -81,6 +81,10 @@ spec: - name: ProfileValidationSettings__PackageDirectory value: /fhir-packages {{- end }} + {{- with .Values.api.capabilityStatement.implementationDescription }} + - name: CapabilityStatementSettings__ImplementationDescription + value: {{ . | quote }} + {{- end }} {{- with .Values.api.extraEnv }} {{- toYaml . | nindent 12 }} {{- end }} diff --git a/infra/helm/charts/app/values.yaml b/infra/helm/charts/app/values.yaml index bf46f649..9494e9a3 100644 --- a/infra/helm/charts/app/values.yaml +++ b/infra/helm/charts/app/values.yaml @@ -48,6 +48,10 @@ api: registry: "https://packages.fhir.org" # e.g. ["hl7.fhir.no.basis@2.2.0", "hl7.fhir.eu.base@2.0.0"] packages: [] + # What /fhir/metadata says this deployment is. Empty keeps the app's default + # test-server notice. + capabilityStatement: + implementationDescription: "" # Hostnames for API HTTPRoute. Leave empty to keep the API internal-only. hostnames: [] # Override the AllowedHosts allow-list passed to ASP.NET HostFiltering. diff --git a/src/Ignis.Api/Configuration/CapabilityStatementSettings.cs b/src/Ignis.Api/Configuration/CapabilityStatementSettings.cs new file mode 100644 index 00000000..ff2b2b82 --- /dev/null +++ b/src/Ignis.Api/Configuration/CapabilityStatementSettings.cs @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026, Incendi + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +namespace Ignis.Api.Configuration; + +/// Bound from the CapabilityStatementSettings section: what /fhir/metadata says +/// this deployment is. +public sealed class CapabilityStatementSettings +{ + /// + /// CapabilityStatement.implementation.description. The default claims only what holds for an + /// unconfigured deployment, so a server holding real data has to say so deliberately. + /// + public string ImplementationDescription { get; set; } = + "Development and test FHIR server. Do not send real patient data or any personal information."; +} diff --git a/src/Ignis.Api/Extensions/CapabilityStatementExtensions.cs b/src/Ignis.Api/Extensions/CapabilityStatementExtensions.cs index 78be3653..d2a8a668 100644 --- a/src/Ignis.Api/Extensions/CapabilityStatementExtensions.cs +++ b/src/Ignis.Api/Extensions/CapabilityStatementExtensions.cs @@ -6,9 +6,11 @@ using Hl7.Fhir.Model; +using Ignis.Api.Configuration; using Ignis.Api.Services.Validation; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using Spark.Engine; using Spark.Engine.Core; @@ -37,7 +39,8 @@ public static IServiceCollection AddProfileAwareCapabilityStatement(this IServic provider.GetRequiredService(), provider.GetRequiredService(), FHIRVersion.N4_0_1), - provider.GetRequiredService())); + provider.GetRequiredService(), + provider.GetRequiredService>().Value)); return services; } diff --git a/src/Ignis.Api/Program.cs b/src/Ignis.Api/Program.cs index b2d40ef3..c7f4ad41 100644 --- a/src/Ignis.Api/Program.cs +++ b/src/Ignis.Api/Program.cs @@ -96,7 +96,8 @@ builder.Services.Configure(builder.Configuration.GetSection("ProfileValidationSettings")); builder.Services.AddProfileValidation(); -// Advertise package profiles under CapabilityStatement.supportedProfile. +// Advertise package profiles under CapabilityStatement.supportedProfile. +builder.Services.Configure(builder.Configuration.GetSection("CapabilityStatementSettings")); builder.Services.AddProfileAwareCapabilityStatement(); // Register terminology ($expand of ValueSets) diff --git a/src/Ignis.Api/Services/Validation/ProfileAwareCapabilityStatementService.cs b/src/Ignis.Api/Services/Validation/ProfileAwareCapabilityStatementService.cs index bc6cef4c..4e25d97a 100644 --- a/src/Ignis.Api/Services/Validation/ProfileAwareCapabilityStatementService.cs +++ b/src/Ignis.Api/Services/Validation/ProfileAwareCapabilityStatementService.cs @@ -6,6 +6,8 @@ using Hl7.Fhir.Model; +using Ignis.Api.Configuration; + using Spark.Engine.Service.FhirServiceExtensions; namespace Ignis.Api.Services.Validation; @@ -18,14 +20,17 @@ public sealed class ProfileAwareCapabilityStatementService : ICapabilityStatemen { private readonly CapabilityStatementService _inner; private readonly ISupportedProfileCatalog _catalog; + private readonly CapabilityStatementSettings _settings; private readonly Lazy _enriched; public ProfileAwareCapabilityStatementService( CapabilityStatementService inner, - ISupportedProfileCatalog catalog) + ISupportedProfileCatalog catalog, + CapabilityStatementSettings settings) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); _enriched = new Lazy(Build); } @@ -39,6 +44,8 @@ private CapabilityStatement Build() // We own the inner exclusively and build once behind the Lazy var statement = _inner.GetSparkCapabilityStatement(); + DescribeImplementation(statement); + foreach (var rest in statement.Rest) AdvertiseOperations(rest); @@ -58,6 +65,19 @@ private CapabilityStatement Build() return statement; } + /// + /// Adds a description of the deployment to CapabilityStatement.implementation.description if configured. + /// + private void DescribeImplementation(CapabilityStatement statement) + { + if (string.IsNullOrWhiteSpace(_settings.ImplementationDescription)) + return; + + // description is 1..1 once implementation is present + statement.Implementation ??= new CapabilityStatement.ImplementationComponent(); + statement.Implementation.Description = _settings.ImplementationDescription; + } + private static void AdvertiseOperations(CapabilityStatement.RestComponent rest) { rest.Operation.Add(new CapabilityStatement.OperationComponent diff --git a/tests/Ignis.Api.Tests/FhirControllerTests.cs b/tests/Ignis.Api.Tests/FhirControllerTests.cs index 0a24a7dd..03d416e6 100644 --- a/tests/Ignis.Api.Tests/FhirControllerTests.cs +++ b/tests/Ignis.Api.Tests/FhirControllerTests.cs @@ -13,6 +13,8 @@ using Hl7.Fhir.Model; using Hl7.Fhir.Serialization; +using Ignis.Api.Configuration; + using Xunit; // Avoid clash with Hl7.Fhir.Model.Task @@ -79,6 +81,50 @@ public async Task Metadata_ReturnsCapabilityStatement() resource.Should().BeOfType(); } + [Fact] + public async Task Metadata_Implementation_CarriesTheDeploymentNotice() + { + // Spark builds its own statement, so the notice has to survive the enrichment. + var response = await _anonymousClient.GetAsync("/fhir/metadata", CT); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var capability = _deserializer.Deserialize( + await response.Content.ReadAsStringAsync(CT)); + + capability.Implementation.Should().NotBeNull(); + capability.Implementation.Description.Should() + .Be(new CapabilityStatementSettings().ImplementationDescription); + } + + [Fact] + public async Task Metadata_Implementation_UsesTheConfiguredDescription() + { + // A typo in the config section name would silently fall back to the default notice, so + // bind through a real env var (the only source minimal hosting sees; see IntegrationFixture). + const string configured = "Pilot deployment holding synthetic data only."; + Environment.SetEnvironmentVariable( + "CapabilityStatementSettings__ImplementationDescription", configured); + try + { + using var factory = _fixture.Factory.WithWebHostBuilder(_ => { }); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/fhir/metadata", CT); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var capability = _deserializer.Deserialize( + await response.Content.ReadAsStringAsync(CT)); + + capability.Implementation.Should().NotBeNull(); + capability.Implementation.Description.Should().Be(configured); + } + finally + { + Environment.SetEnvironmentVariable( + "CapabilityStatementSettings__ImplementationDescription", null); + } + } + [Fact] public async Task Metadata_SupportedProfile_ListsPackageConstraintProfiles() {