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
8 changes: 8 additions & 0 deletions docs/server/api-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions infra/helm/charts/app/templates/api-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
4 changes: 4 additions & 0 deletions infra/helm/charts/app/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions src/Ignis.Api/Configuration/CapabilityStatementSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026, Incendi <info@incendi.no>
*
* SPDX-License-Identifier: BSD-3-Clause
*/

namespace Ignis.Api.Configuration;

/// <summary>Bound from the <c>CapabilityStatementSettings</c> section: what <c>/fhir/metadata</c> says
/// this deployment is.</summary>
public sealed class CapabilityStatementSettings
{
/// <summary>
/// <c>CapabilityStatement.implementation.description</c>. The default claims only what holds for an
/// unconfigured deployment, so a server holding real data has to say so deliberately.
/// </summary>
public string ImplementationDescription { get; set; } =
"Development and test FHIR server. Do not send real patient data or any personal information.";
}
5 changes: 4 additions & 1 deletion src/Ignis.Api/Extensions/CapabilityStatementExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -37,7 +39,8 @@ public static IServiceCollection AddProfileAwareCapabilityStatement(this IServic
provider.GetRequiredService<IFhirModel>(),
provider.GetRequiredService<ServerVersion>(),
FHIRVersion.N4_0_1),
provider.GetRequiredService<ISupportedProfileCatalog>()));
provider.GetRequiredService<ISupportedProfileCatalog>(),
provider.GetRequiredService<IOptions<CapabilityStatementSettings>>().Value));

return services;
}
Expand Down
3 changes: 2 additions & 1 deletion src/Ignis.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@
builder.Services.Configure<ProfileValidationSettings>(builder.Configuration.GetSection("ProfileValidationSettings"));
builder.Services.AddProfileValidation();

// Advertise package profiles under CapabilityStatement.supportedProfile.
// Advertise package profiles under CapabilityStatement.supportedProfile.
builder.Services.Configure<CapabilityStatementSettings>(builder.Configuration.GetSection("CapabilityStatementSettings"));
builder.Services.AddProfileAwareCapabilityStatement();

// Register terminology ($expand of ValueSets)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

using Hl7.Fhir.Model;

using Ignis.Api.Configuration;

using Spark.Engine.Service.FhirServiceExtensions;

namespace Ignis.Api.Services.Validation;
Expand All @@ -18,14 +20,17 @@ public sealed class ProfileAwareCapabilityStatementService : ICapabilityStatemen
{
private readonly CapabilityStatementService _inner;
private readonly ISupportedProfileCatalog _catalog;
private readonly CapabilityStatementSettings _settings;
private readonly Lazy<CapabilityStatement> _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<CapabilityStatement>(Build);
}

Expand All @@ -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);

Expand All @@ -58,6 +65,19 @@ private CapabilityStatement Build()
return statement;
}

/// <summary>
/// Adds a description of the deployment to <c>CapabilityStatement.implementation.description</c> if configured.
/// </summary>
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
Expand Down
46 changes: 46 additions & 0 deletions tests/Ignis.Api.Tests/FhirControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,6 +81,50 @@ public async Task Metadata_ReturnsCapabilityStatement()
resource.Should().BeOfType<CapabilityStatement>();
}

[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<CapabilityStatement>(
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<CapabilityStatement>(
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()
{
Expand Down
Loading