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
1 change: 0 additions & 1 deletion crates/hts/src/backends/sqlite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions crates/hts/src/backends/sqlite/value_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> filter with property = <p>, op = <o> 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
Expand Down
90 changes: 66 additions & 24 deletions crates/hts/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")]
Expand Down Expand Up @@ -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"),
};

Expand All @@ -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,
},
Expand Down
4 changes: 2 additions & 2 deletions crates/hts/src/operations/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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()),
Expand Down
Loading