From 920df66087351052f1e5ba872c11827179e04124 Mon Sep 17 00:00:00 2001 From: smunini Date: Mon, 4 May 2026 21:57:05 -0400 Subject: [PATCH 1/3] fix(hts): emit vs-invalid OperationOutcome for compose filters with no value Add HtsError::VsInvalid distinct from InvalidRequest so the FHIR issue code stays `invalid` (HTTP 400) but the tx-issue-type coding becomes `vs-invalid`, matching the HL7 tx-ecosystem `errors/broken-filter-{expand,validate}` fixtures (3 IG tests). The validation runs at the top of `apply_compose_filters`: every ValueSet.compose.include.filter[] entry must carry a non-empty `value`. When missing, return a VsInvalid error with the IG-spec text "The system filter with property =

, op = has no value". Triggered identically by both `$expand` and `$validate-code` on the broken-filter ValueSet, since both go through `apply_compose_filters` during expansion. --- crates/hts/src/backends/sqlite/mod.rs | 1 - crates/hts/src/backends/sqlite/value_set.rs | 19 +++++ crates/hts/src/error.rs | 90 +++++++++++++++------ crates/hts/src/operations/batch.rs | 4 +- 4 files changed, 87 insertions(+), 27 deletions(-) diff --git a/crates/hts/src/backends/sqlite/mod.rs b/crates/hts/src/backends/sqlite/mod.rs index 9a77d4565..e77e2ca3d 100644 --- a/crates/hts/src/backends/sqlite/mod.rs +++ b/crates/hts/src/backends/sqlite/mod.rs @@ -124,7 +124,6 @@ pub struct SqliteTerminologyBackend { /// is served entirely from process memory via the trigram index, bypassing /// `spawn_blocking` and r2d2 pool contention. pub(crate) plain_fts_cache: PlainFtsCache, - } impl SqliteTerminologyBackend { diff --git a/crates/hts/src/backends/sqlite/value_set.rs b/crates/hts/src/backends/sqlite/value_set.rs index e21041689..e7674747e 100644 --- a/crates/hts/src/backends/sqlite/value_set.rs +++ b/crates/hts/src/backends/sqlite/value_set.rs @@ -1858,6 +1858,25 @@ fn apply_compose_filters( _ => return Ok(None), }; + // Validate every filter carries a non-empty `value`. ValueSet.compose. + // include.filter.value is mandatory per the FHIR spec; the HL7 IG + // `errors/broken-filter` fixtures expect a 400 with diagnostic text + // "The system filter with property =

, op = has no value" + // and `tx-issue-type=vs-invalid` whenever it is missing or empty. + for f in filters { + let value_present = f + .get("value") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + if !value_present { + let property = f["property"].as_str().unwrap_or(""); + let op = f["op"].as_str().unwrap_or(""); + return Err(HtsError::VsInvalid(format!( + "The system {system_url} filter with property = {property}, op = {op} has no value" + ))); + } + } + // Partition into property= filters (fast, indexed) and hierarchy filters // (potentially O(N_descendants)). Property filters run in phase 1; hierarchy // filters run in phase 2 and can exploit the bounded candidate set from diff --git a/crates/hts/src/error.rs b/crates/hts/src/error.rs index ab5f5cb4a..e432b28a5 100644 --- a/crates/hts/src/error.rs +++ b/crates/hts/src/error.rs @@ -7,19 +7,21 @@ //! //! ## HTTP mapping //! -//! | Variant | HTTP status | FHIR issue code | -//! |---------|-------------|-----------------| -//! | [`NotFound`] | 404 | `not-found` | -//! | [`NotSupported`] | 501 | `not-supported` | -//! | [`InvalidRequest`] | 400 | `invalid` | -//! | [`Internal`] | 500 | `exception` | -//! | [`StorageError`] | 500 | `exception` | -//! | [`PreconditionFailed`] | 412 | `conflict` | -//! | [`TooCostly`] | 422 | `too-costly` | +//! | Variant | HTTP status | FHIR issue code | tx-issue-type | +//! |---------|-------------|-----------------|---------------| +//! | [`NotFound`] | 404 | `not-found` | `not-found` | +//! | [`NotSupported`] | 501 | `not-supported` | `not-supported` | +//! | [`InvalidRequest`] | 400 | `invalid` | `invalid` | +//! | [`VsInvalid`] | 400 | `invalid` | `vs-invalid` | +//! | [`Internal`] | 500 | `exception` | `exception` | +//! | [`StorageError`] | 500 | `exception` | `exception` | +//! | [`PreconditionFailed`] | 412 | `conflict` | `conflict` | +//! | [`TooCostly`] | 422 | `too-costly` | `too-costly` | //! //! [`NotFound`]: HtsError::NotFound //! [`NotSupported`]: HtsError::NotSupported //! [`InvalidRequest`]: HtsError::InvalidRequest +//! [`VsInvalid`]: HtsError::VsInvalid //! [`Internal`]: HtsError::Internal //! [`StorageError`]: HtsError::StorageError //! [`PreconditionFailed`]: HtsError::PreconditionFailed @@ -57,6 +59,15 @@ pub enum HtsError { #[error("Invalid request: {0}")] InvalidRequest(String), + /// A ValueSet definition is itself invalid — for example a compose filter + /// that omits the required `value`, names an unknown operator, or supplies + /// a regular expression that fails to compile. Maps to HTTP 400 with FHIR + /// issue code `invalid` and a `tx-issue-type=vs-invalid` coding so the + /// HL7 tx-ecosystem fixtures can distinguish ValueSet-definition errors + /// from other 400-class request problems. + #[error("Invalid ValueSet: {0}")] + VsInvalid(String), + /// An unexpected server-side error that is not attributable to the caller. /// Maps to HTTP 500. #[error("Internal error: {0}")] @@ -108,21 +119,52 @@ impl IntoResponse for HtsError { return (StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into_response(); } - let (status, code, diagnostics) = match &self { - HtsError::NotFound(msg) => (StatusCode::NOT_FOUND, "not-found", msg.as_str()), - HtsError::NotSupported(msg) => { - (StatusCode::NOT_IMPLEMENTED, "not-supported", msg.as_str()) - } - HtsError::InvalidRequest(msg) => (StatusCode::BAD_REQUEST, "invalid", msg.as_str()), - HtsError::Internal(msg) => { - (StatusCode::INTERNAL_SERVER_ERROR, "exception", msg.as_str()) - } - HtsError::StorageError(msg) => { - (StatusCode::INTERNAL_SERVER_ERROR, "exception", msg.as_str()) - } - HtsError::PreconditionFailed(msg) => { - (StatusCode::PRECONDITION_FAILED, "conflict", msg.as_str()) + // (status, FHIR-issue-code, tx-issue-type, diagnostics) + // The FHIR issue `code` and the `tx-issue-type` coding are usually + // identical, but VsInvalid splits them: FHIR code stays `invalid` + // (preserving the HTTP-level meaning) while tx-issue-type signals + // `vs-invalid` so the IG validator can route the failure to a + // ValueSet-definition diagnostic. + let (status, code, tx_issue_type, diagnostics) = match &self { + HtsError::NotFound(msg) => ( + StatusCode::NOT_FOUND, + "not-found", + "not-found", + msg.as_str(), + ), + HtsError::NotSupported(msg) => ( + StatusCode::NOT_IMPLEMENTED, + "not-supported", + "not-supported", + msg.as_str(), + ), + HtsError::InvalidRequest(msg) => { + (StatusCode::BAD_REQUEST, "invalid", "invalid", msg.as_str()) } + HtsError::VsInvalid(msg) => ( + StatusCode::BAD_REQUEST, + "invalid", + "vs-invalid", + msg.as_str(), + ), + HtsError::Internal(msg) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "exception", + "exception", + msg.as_str(), + ), + HtsError::StorageError(msg) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "exception", + "exception", + msg.as_str(), + ), + HtsError::PreconditionFailed(msg) => ( + StatusCode::PRECONDITION_FAILED, + "conflict", + "conflict", + msg.as_str(), + ), HtsError::TooCostly(_) => unreachable!("handled above"), }; @@ -138,7 +180,7 @@ impl IntoResponse for HtsError { "details": { "coding": [{ "system": "http://hl7.org/fhir/tools/CodeSystem/tx-issue-type", - "code": code, + "code": tx_issue_type, }], "text": diagnostics, }, diff --git a/crates/hts/src/operations/batch.rs b/crates/hts/src/operations/batch.rs index eb05916fd..37b979e21 100644 --- a/crates/hts/src/operations/batch.rs +++ b/crates/hts/src/operations/batch.rs @@ -42,7 +42,7 @@ fn error_status(e: &HtsError) -> &'static str { match e { HtsError::NotFound(_) => "404", HtsError::NotSupported(_) => "501", - HtsError::InvalidRequest(_) => "400", + HtsError::InvalidRequest(_) | HtsError::VsInvalid(_) => "400", HtsError::Internal(_) | HtsError::StorageError(_) => "500", HtsError::PreconditionFailed(_) => "412", HtsError::TooCostly(_) => "422", @@ -54,7 +54,7 @@ fn error_to_outcome(e: &HtsError) -> Value { let (code, diagnostics) = match e { HtsError::NotFound(msg) => ("not-found", msg.as_str()), HtsError::NotSupported(msg) => ("not-supported", msg.as_str()), - HtsError::InvalidRequest(msg) => ("invalid", msg.as_str()), + HtsError::InvalidRequest(msg) | HtsError::VsInvalid(msg) => ("invalid", msg.as_str()), HtsError::Internal(msg) => ("exception", msg.as_str()), HtsError::StorageError(msg) => ("exception", msg.as_str()), HtsError::PreconditionFailed(msg) => ("conflict", msg.as_str()), From 2f79d3ddc0a7705fa0c3faaf5bca65afa7c38df2 Mon Sep 17 00:00:00 2001 From: smunini Date: Mon, 4 May 2026 22:02:47 -0400 Subject: [PATCH 2/3] fix(hts): emit warning- + used-codesystem in $expand response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HL7 IG `deprecated/expand-*` fixtures (8 IG tests) require: 1. One `used-codesystem` parameter per distinct contributing CodeSystem, formatted as `|` — restored after the perf revert that removed it. 2. A parallel `warning-` entry whenever the source ValueSet or a contributing CodeSystem carries one of: * `extension[].url == structuredefinition-standards-status` with `valueCode` of `deprecated`, `withdrawn`, `draft`, etc. * `experimental: true` → `warning-experimental` * `status: "draft"` → `warning-draft` A new `standards_statuses()` helper centralises the rule application and deduplicates when multiple markers point at the same status code. Performance impact: one CodeSystem search per distinct contributing system on the cache-miss path. Hot-path requests are still served from `state.expand_cache` (pre-serialised bytes), so repeated identical $expand calls pay the cost only once. --- crates/hts/src/operations/expand.rs | 227 +++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/crates/hts/src/operations/expand.rs b/crates/hts/src/operations/expand.rs index 4354f05f4..e4c8691b1 100644 --- a/crates/hts/src/operations/expand.rs +++ b/crates/hts/src/operations/expand.rs @@ -51,8 +51,56 @@ use super::params::{ query_params_to_fhir_params, }; -/// Serialize a single [`ExpansionContains`] entry to a FHIR-compliant JSON value. +/// Collect the standards-status codes that should fire `warning-` +/// expansion parameters for a CodeSystem or ValueSet. /// +/// Surveys three FHIR markers, in this order: +/// +/// 1. The `http://hl7.org/fhir/StructureDefinition/structuredefinition-standards-status` +/// extension's `valueCode` — typically `deprecated`, `withdrawn`, or `draft`. +/// 2. `experimental: true` → emits `experimental` (only on CodeSystem; ValueSets +/// use the same field but the IG fixtures don't ask for `warning-experimental` +/// on a VS-level basis — driven by the contributing CS). +/// 3. `status: "draft"` → emits `draft` (mirrors the standards-status pattern +/// when the resource simply uses FHIR's status field rather than the +/// extension). +/// +/// Returns the deduplicated list of status codes, preserving the order above. +/// The IG fixtures use this list to populate `warning-` entries in +/// `expansion.parameter[]`. +fn standards_statuses(resource: &Value) -> Vec { + let mut out: Vec = Vec::new(); + let mut push_unique = |code: &str| { + if !code.is_empty() && !out.iter().any(|c| c == code) { + out.push(code.to_string()); + } + }; + + if let Some(exts) = resource.get("extension").and_then(|e| e.as_array()) { + for ext in exts { + if ext.get("url").and_then(|u| u.as_str()) + == Some( + "http://hl7.org/fhir/StructureDefinition/structuredefinition-standards-status", + ) + { + if let Some(code) = ext.get("valueCode").and_then(|v| v.as_str()) { + push_unique(code); + } + } + } + } + + if resource.get("experimental").and_then(|v| v.as_bool()) == Some(true) { + push_unique("experimental"); + } + + if resource.get("status").and_then(|v| v.as_str()) == Some("draft") { + push_unique("draft"); + } + + out +} + /// Recursively serializes nested `contains` arrays, so that a hierarchical /// expansion (produced when `hierarchical=true`) is correctly represented as /// nested `contains[]` objects rather than a flat list. @@ -678,6 +726,97 @@ async fn process_expand( } } + // ── used-codesystem + warning- per contributing CodeSystem ─────── + // The IG fixtures expect one `used-codesystem` parameter per distinct + // CodeSystem URL that contributed concepts, with valueUri formatted as + // `|` (or just `` when the version is unknown). When + // a contributing CS is itself flagged as deprecated/withdrawn/draft/ + // experimental — by either the `structuredefinition-standards-status` + // extension, `status: "draft"`, or `experimental: true` — emit a parallel + // `warning-` entry. + // + // The source ValueSet is checked the same way, so a deprecated VS that + // includes an active CS still surfaces a `warning-deprecated` for the VS. + // + // Performance: this issues one CodeSystem search per distinct system. + // Hot-path requests are served from `state.expand_cache`, so the cost is + // amortised across repeated identical $expand calls. We keep the lookup + // inside the cache-miss path (after the cache.read above and before + // cache.write below). + let mut used_systems: Vec<&String> = + resp.contains + .iter() + .map(|c| &c.system) + .fold(Vec::<&String>::new(), |mut acc, s| { + if !acc.contains(&s) { + acc.push(s); + } + acc + }); + used_systems.sort(); + let mut warning_params: Vec = Vec::new(); + for system_url in &used_systems { + let cs = crate::traits::CodeSystemOperations::search( + state.backend(), + &ctx, + crate::types::ResourceSearchQuery { + url: Some((**system_url).clone()), + count: Some(1), + ..Default::default() + }, + ) + .await + .ok() + .and_then(|mut v| v.pop()); + + let cs_version = cs + .as_ref() + .and_then(|c| c.get("version")) + .and_then(|v| v.as_str()) + .map(str::to_string); + let value_uri = match &cs_version { + Some(v) => format!("{system_url}|{v}"), + None => (*system_url).clone(), + }; + emitted_params.push(json!({ + "name": "used-codesystem", + "valueUri": value_uri, + })); + + // Derive any `warning-` entries from the CS's standards-status + // extension, status, and experimental flag. Each rule emits at most + // one warning name; multiple rules can fire in parallel for the same + // CS (e.g. status=draft + experimental=true → both warnings). + if let Some(cs) = cs.as_ref() { + for status_code in standards_statuses(cs) { + warning_params.push(json!({ + "name": format!("warning-{status_code}"), + "valueUri": value_uri, + })); + } + } + } + + // Then add any warning-* derived from the source VS itself. + if let Some(vs) = source_vs.as_ref() { + let vs_url = vs.get("url").and_then(|v| v.as_str()); + let vs_version = vs.get("version").and_then(|v| v.as_str()); + let vs_value_uri = match (vs_url, vs_version) { + (Some(u), Some(v)) => Some(format!("{u}|{v}")), + (Some(u), None) => Some(u.to_string()), + _ => None, + }; + if let Some(uri) = vs_value_uri { + for status_code in standards_statuses(vs) { + warning_params.push(json!({ + "name": format!("warning-{status_code}"), + "valueUri": uri, + })); + } + } + } + emitted_params.extend(warning_params); + // Append any expansion warnings as parameter entries with name=warning. for w in &resp.warnings { emitted_params.push(json!({ "name": "warning", "valueString": w })); @@ -1146,5 +1285,91 @@ mod tests { let resp = post_json(app, "/ValueSet/$expand", body).await; assert_eq!(resp.status(), 400); } + + // ── standards_statuses helper ────────────────────────────────────────────── + // + // These exercise the deprecated/withdrawn/experimental/draft → warning-* + // mapping that drives expansion.parameter emission in process_expand. + + #[test] + fn standards_statuses_picks_extension_status() { + let cs = json!({ + "resourceType": "CodeSystem", + "extension": [{ + "url": "http://hl7.org/fhir/StructureDefinition/structuredefinition-standards-status", + "valueCode": "deprecated" + }], + "status": "active", + "experimental": false + }); + assert_eq!(standards_statuses(&cs), vec!["deprecated".to_string()]); + } + + #[test] + fn standards_statuses_picks_experimental_flag() { + let cs = json!({ + "resourceType": "CodeSystem", + "status": "active", + "experimental": true + }); + assert_eq!(standards_statuses(&cs), vec!["experimental".to_string()]); + } + + #[test] + fn standards_statuses_picks_draft_status() { + let cs = json!({ + "resourceType": "CodeSystem", + "status": "draft", + "experimental": false + }); + assert_eq!(standards_statuses(&cs), vec!["draft".to_string()]); + } + + #[test] + fn standards_statuses_combines_multiple_markers() { + let cs = json!({ + "resourceType": "CodeSystem", + "extension": [{ + "url": "http://hl7.org/fhir/StructureDefinition/structuredefinition-standards-status", + "valueCode": "withdrawn" + }], + "status": "draft", + "experimental": true + }); + // Order: extension first, then experimental, then draft. + assert_eq!( + standards_statuses(&cs), + vec![ + "withdrawn".to_string(), + "experimental".to_string(), + "draft".to_string() + ] + ); + } + + #[test] + fn standards_statuses_returns_empty_for_active_resource() { + let cs = json!({ + "resourceType": "CodeSystem", + "status": "active", + "experimental": false + }); + assert!(standards_statuses(&cs).is_empty()); + } + + #[test] + fn standards_statuses_dedupes_when_extension_matches_status() { + // Both the standards-status extension and the FHIR status field say + // "draft" — emit only one entry. + let cs = json!({ + "resourceType": "CodeSystem", + "extension": [{ + "url": "http://hl7.org/fhir/StructureDefinition/structuredefinition-standards-status", + "valueCode": "draft" + }], + "status": "draft" + }); + assert_eq!(standards_statuses(&cs), vec!["draft".to_string()]); + } } // rebuild marker From 51c17ebfb75a83f0c90099f18f2778e4c5bd1695 Mon Sep 17 00:00:00 2001 From: smunini Date: Mon, 4 May 2026 22:09:50 -0400 Subject: [PATCH 3/3] fix(hts): rotate original display into preferredForLanguage on displayLanguage swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HL7 IG `language/expand-xform-*` fixtures (8 IG tests) plus a follow-on set of `display/validation-*` fixtures expect a richer displayLanguage swap shape: * The matching-language designation value is promoted to top-level `display` (existing behavior). * The original CodeSystem-language display becomes a designation `{language: , use: {system, code: preferredForLanguage}, value: }`. * The duplicate matched designation is removed from the designation list. Also handle the Accept-Language style "hard fallback" form `displayLanguage=de,*; q=0`: when no matching designation exists and the caller forbids fallback (q=0 on the wildcard), drop top-level `display` entirely — but still surface the original CS-default display as a preferredForLanguage designation so consumers can recover it. Implementation: * `parse_display_language()` parses the request value into a `DisplayLangSpec { preferred, hard_fallback }`. * Designation lookup uses BCP 47 / RFC 4647 prefix matching, so a request for `de` matches stored `de-CH` designations. * A single per-system CodeSystem search now drives THREE downstream blocks (displayLanguage swap, used-codesystem emission, and warning- emission) via a shared `cs_by_url` map — net zero extra SQL on the cache-miss path. --- crates/hts/src/operations/expand.rs | 305 +++++++++++++++++++++++++--- 1 file changed, 272 insertions(+), 33 deletions(-) diff --git a/crates/hts/src/operations/expand.rs b/crates/hts/src/operations/expand.rs index e4c8691b1..3abb3760f 100644 --- a/crates/hts/src/operations/expand.rs +++ b/crates/hts/src/operations/expand.rs @@ -283,17 +283,96 @@ fn populate_properties<'a, B: TerminologyBackend>( }) } +/// Parsed `displayLanguage` request parameter. +/// +/// FHIR allows simple language codes (`de`), comma-separated lists with an +/// optional wildcard (`de,*`), and Accept-Language style q-weights +/// (`de,*; q=0`). The HL7 IG `language/expand-xform-*` fixtures distinguish: +/// +/// | Form | preferred | hard_fallback | Meaning | +/// |------|-----------|---------------|---------| +/// | `de` | `de` | `false` | Try de; otherwise keep CS-default display | +/// | `de,*` | `de` | `false` | Same as above (`*` is just an explicit fallback) | +/// | `de,*; q=0` | `de` | `true` | Try de; if missing, drop top-level display | +/// +/// `preferred` is the first non-wildcard tag (the language we want to swap +/// in); `hard_fallback` is `true` when the wildcard carries `q=0`, signalling +/// that no fallback is allowed. +struct DisplayLangSpec { + preferred: String, + hard_fallback: bool, +} + +/// Parse a `displayLanguage` parameter value into a [`DisplayLangSpec`]. +fn parse_display_language(raw: &str) -> Option { + let mut preferred: Option = None; + let mut hard_fallback = false; + + for part in raw.split(',') { + let trimmed = part.trim(); + if trimmed.is_empty() { + continue; + } + // Split q= weight (Accept-Language style): "*; q=0" → tag="*", q=Some(0.0) + let (tag, q) = if let Some((t, rest)) = trimmed.split_once(';') { + let q = rest + .trim() + .strip_prefix("q=") + .or_else(|| rest.trim().strip_prefix("Q=")) + .and_then(|s| s.parse::().ok()); + (t.trim(), q) + } else { + (trimmed, None) + }; + if tag == "*" { + // q=0 on wildcard means "do not fall back to anything" → hard mode. + if q == Some(0.0) { + hard_fallback = true; + } + } else if preferred.is_none() && !tag.is_empty() { + preferred = Some(tag.to_string()); + } + } + + preferred.map(|p| DisplayLangSpec { + preferred: p, + hard_fallback, + }) +} + +/// The HL7 `hl7TermMaintInfra` system + code identifying a designation as +/// the "preferred for language" entry. Used when the displayLanguage swap +/// rotates the CodeSystem's original-language display into the designation +/// list — the IG fixtures expect this `use` coding to flag that entry. +const HL7_TERM_MAINT_INFRA_SYSTEM: &str = "http://terminology.hl7.org/CodeSystem/hl7TermMaintInfra"; + /// Replace each contains[] entry's `display` with a designation matching the -/// requested displayLanguage (when one exists). Mirrors the `lookup()` -/// language-aware behavior. Walks nested `contains[]` recursively. +/// requested displayLanguage. Mirrors the `lookup()` language-aware behavior +/// and walks nested `contains[]` recursively. +/// +/// Per the HL7 IG `language/expand-xform-*` fixtures, when a swap fires we +/// also rotate the original CS-language display into `c.designations` as a +/// `{language: , use: preferredForLanguage, value: }` +/// entry, and remove the now-redundant matching-language designation. The +/// `cs_lang_by_url` map is read from the contributing CodeSystem's top-level +/// `language` field. +/// +/// `hard_fallback` controls behavior when no matching designation exists: +/// `true` drops the top-level display entirely (per the `*; q=0` convention), +/// `false` leaves the original display in place. fn apply_display_language<'a, B: TerminologyBackend>( backend: &'a B, ctx: &'a TenantContext, contains: &'a mut [ExpansionContains], - language: &'a str, + spec: &'a DisplayLangSpec, + cs_lang_by_url: &'a std::collections::HashMap>, ) -> std::pin::Pin + Send + 'a>> { Box::pin(async move { + use crate::types::ExpansionContainsDesignation; use std::collections::HashMap; + let language = spec.preferred.as_str(); + + // Bucket codes per system for a single batched designation lookup. let mut by_system: HashMap<&str, Vec> = HashMap::new(); for c in contains.iter() { by_system @@ -301,25 +380,96 @@ fn apply_display_language<'a, B: TerminologyBackend>( .or_default() .push(c.code.clone()); } - let mut map: HashMap<(String, String), String> = HashMap::new(); + // (system, code) → (designation language tag, designation value). + // Match using BCP 47 / RFC 4647 Lookup: prefer an exact match, then + // accept any designation whose tag starts with the requested tag plus + // a `-` subtag separator (so `de` matches `de-CH` but not `den`). + let mut match_map: HashMap<(String, String), (Option, String)> = HashMap::new(); for (system, codes) in &by_system { if let Ok(ds) = backend.concept_designations(ctx, system, codes).await { for (code, list) in ds { - if let Some(d) = list - .into_iter() - .find(|d| d.language.as_deref() == Some(language)) - { - map.insert(((*system).to_string(), code), d.value); + let exact = list + .iter() + .find(|d| d.language.as_deref() == Some(language)); + let chosen = exact.cloned().or_else(|| { + list.into_iter().find(|d| { + d.language.as_deref().is_some_and(|lang| { + let prefix = format!("{language}-"); + lang.eq_ignore_ascii_case(language) + || lang + .to_ascii_lowercase() + .starts_with(&prefix.to_ascii_lowercase()) + }) + }) + }); + if let Some(d) = chosen { + match_map.insert(((*system).to_string(), code), (d.language, d.value)); } } } } + for c in contains.iter_mut() { - if let Some(v) = map.remove(&(c.system.clone(), c.code.clone())) { - c.display = Some(v); + let cs_lang = cs_lang_by_url.get(&c.system).cloned().flatten(); + let original_display = c.display.clone(); + if let Some((matched_lang, matched_value)) = + match_map.remove(&(c.system.clone(), c.code.clone())) + { + // Swap top-level display for the matching-language designation. + c.display = Some(matched_value.clone()); + + // Drop the source designation we just promoted (matched on + // both language + value to be precise — broader-match designations + // for unrelated codes survive untouched). + c.designations + .retain(|d| !(d.language == matched_lang && d.value == matched_value)); + + // Rotate the former display into designations[] tagged with the + // CS's own language and `use=preferredForLanguage`. Skip when + // the original display would just duplicate the matched value + // (degenerate case where CS-lang == requested lang). + if let Some(orig) = original_display + .filter(|s| !s.is_empty() && cs_lang.as_deref() != Some(language)) + { + let already = c + .designations + .iter() + .any(|d| d.language == cs_lang && d.value == orig); + if !already { + c.designations.push(ExpansionContainsDesignation { + language: cs_lang.clone(), + use_system: Some(HL7_TERM_MAINT_INFRA_SYSTEM.to_string()), + use_code: Some("preferredForLanguage".to_string()), + value: orig, + }); + } + } + } else if spec.hard_fallback { + // No matching designation and the caller forbade fallback — + // drop the top-level display entirely. The IG fixtures still + // surface the original (CS-default) display as a designation + // with `use=preferredForLanguage` so consumers can recover it. + if let Some(orig) = original_display { + if !orig.is_empty() { + let already = c + .designations + .iter() + .any(|d| d.language == cs_lang && d.value == orig); + if !already { + c.designations.push(ExpansionContainsDesignation { + language: cs_lang.clone(), + use_system: Some(HL7_TERM_MAINT_INFRA_SYSTEM.to_string()), + use_code: Some("preferredForLanguage".to_string()), + value: orig, + }); + } + } + } + c.display = None; } + if !c.contains.is_empty() { - apply_display_language(backend, ctx, &mut c.contains, language).await; + apply_display_language(backend, ctx, &mut c.contains, spec, cs_lang_by_url).await; } } }) @@ -547,6 +697,56 @@ async fn process_expand( .await; } + // ── Per-system CodeSystem metadata lookup (one search per distinct URL) ── + // The CS resource is consulted by THREE downstream blocks: + // - apply_display_language (for CS.language → preferredForLanguage) + // - the used-codesystem emission (for CS.version) + // - the warning- emission (for extension/status/experimental) + // + // Centralising the lookup here avoids duplicating the search and keeps + // the call count to one per system on the cache-miss path. + use std::collections::HashMap; + let mut cs_by_url: HashMap> = HashMap::new(); + { + let mut systems: Vec<&String> = + resp.contains + .iter() + .map(|c| &c.system) + .fold(Vec::<&String>::new(), |mut acc, s| { + if !acc.contains(&s) { + acc.push(s); + } + acc + }); + systems.sort(); + for system_url in systems { + let cs = crate::traits::CodeSystemOperations::search( + state.backend(), + &ctx, + crate::types::ResourceSearchQuery { + url: Some(system_url.clone()), + count: Some(1), + ..Default::default() + }, + ) + .await + .ok() + .and_then(|mut v| v.pop()); + cs_by_url.insert(system_url.clone(), cs); + } + } + let cs_lang_by_url: HashMap> = cs_by_url + .iter() + .map(|(url, cs)| { + let lang = cs + .as_ref() + .and_then(|c| c.get("language")) + .and_then(|v| v.as_str()) + .map(str::to_string); + (url.clone(), lang) + }) + .collect(); + // ── displayLanguage: swap display from matching designation ────────────── let display_language = params .iter() @@ -557,8 +757,17 @@ async fn process_expand( .and_then(|v| v.as_str()) .map(str::to_string) }); - if let Some(lang) = display_language.as_deref() { - apply_display_language(state.backend(), &ctx, &mut resp.contains, lang).await; + if let Some(raw) = display_language.as_deref() { + if let Some(spec) = parse_display_language(raw) { + apply_display_language( + state.backend(), + &ctx, + &mut resp.contains, + &spec, + &cs_lang_by_url, + ) + .await; + } } // ── Look up source ValueSet (used for both parameter extension and metadata copy) ── @@ -738,11 +947,8 @@ async fn process_expand( // The source ValueSet is checked the same way, so a deprecated VS that // includes an active CS still surfaces a `warning-deprecated` for the VS. // - // Performance: this issues one CodeSystem search per distinct system. - // Hot-path requests are served from `state.expand_cache`, so the cost is - // amortised across repeated identical $expand calls. We keep the lookup - // inside the cache-miss path (after the cache.read above and before - // cache.write below). + // Reuses the per-system CS metadata that `cs_by_url` collected before the + // displayLanguage swap — no extra SQL round-trips here. let mut used_systems: Vec<&String> = resp.contains .iter() @@ -756,21 +962,9 @@ async fn process_expand( used_systems.sort(); let mut warning_params: Vec = Vec::new(); for system_url in &used_systems { - let cs = crate::traits::CodeSystemOperations::search( - state.backend(), - &ctx, - crate::types::ResourceSearchQuery { - url: Some((**system_url).clone()), - count: Some(1), - ..Default::default() - }, - ) - .await - .ok() - .and_then(|mut v| v.pop()); + let cs = cs_by_url.get(*system_url).and_then(|c| c.as_ref()); let cs_version = cs - .as_ref() .and_then(|c| c.get("version")) .and_then(|v| v.as_str()) .map(str::to_string); @@ -787,7 +981,7 @@ async fn process_expand( // extension, status, and experimental flag. Each rule emits at most // one warning name; multiple rules can fire in parallel for the same // CS (e.g. status=draft + experimental=true → both warnings). - if let Some(cs) = cs.as_ref() { + if let Some(cs) = cs { for status_code in standards_statuses(cs) { warning_params.push(json!({ "name": format!("warning-{status_code}"), @@ -1371,5 +1565,50 @@ mod tests { }); assert_eq!(standards_statuses(&cs), vec!["draft".to_string()]); } + + // ── parse_display_language helper ────────────────────────────────────────── + + #[test] + fn parse_display_language_simple_tag() { + let spec = parse_display_language("de").unwrap(); + assert_eq!(spec.preferred, "de"); + assert!(!spec.hard_fallback); + } + + #[test] + fn parse_display_language_with_explicit_fallback() { + let spec = parse_display_language("de,*").unwrap(); + assert_eq!(spec.preferred, "de"); + assert!(!spec.hard_fallback); + } + + #[test] + fn parse_display_language_hard_mode_q0() { + // Wildcard with q=0 → no fallback allowed. + let spec = parse_display_language("de,*; q=0").unwrap(); + assert_eq!(spec.preferred, "de"); + assert!(spec.hard_fallback); + } + + #[test] + fn parse_display_language_hard_mode_with_extra_whitespace() { + let spec = parse_display_language("de, *; q=0").unwrap(); + assert_eq!(spec.preferred, "de"); + assert!(spec.hard_fallback); + } + + #[test] + fn parse_display_language_picks_first_real_tag() { + let spec = parse_display_language("de-CH,en,*").unwrap(); + assert_eq!(spec.preferred, "de-CH"); + assert!(!spec.hard_fallback); + } + + #[test] + fn parse_display_language_only_wildcard_returns_none() { + // No real preferred tag — caller should treat as "no displayLanguage". + assert!(parse_display_language("*").is_none()); + assert!(parse_display_language("*; q=0").is_none()); + } } // rebuild marker