Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,15 @@ jobs:
# For PS scripts, run with Bypass
- name: Start fhirpath-server (Windows)
shell: 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& ''{0}''"'
env:
# The FHIR test cases include mode="tx" tests (txTest01-03) that exercise
# %terminologies. The evaluator has no default terminology server (#217), so
# without this the server errors and the runner marks those tests inconclusive,
# silently dropping them from the suite. Point it at our own HTS rather than the
# public tx.fhir.org, per #183. HTS serves operations at the root (no /r4, /r5).
# If HTS is unreachable the runner reports the tests as skipped, not failed, so
# an outage cannot turn CI red.
FHIRPATH_TERMINOLOGY_SERVER: https://hts.heliossoftware.com
run: |
$exe = Join-Path (Resolve-Path "target\debug").Path "fhirpath-server.exe"
Write-Host "Starting server: $exe"
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/fhirpath/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ tempfile = "3.0"
criterion = { version = "0.5", features = ["html_reports"] }
futures = "0.3"
flate2 = "1" # gzip/deflate test payloads for HTTP compression tests
wiremock = "0.6" # in-process terminology server stub (tests must not hit the network)

[[bin]]
name = "fhirpath-cli"
Expand Down
14 changes: 9 additions & 5 deletions crates/fhirpath/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,16 @@ This expression identifies systolic blood pressure observations with values abov

FHIRPath provides access to terminology services through a %terminologies object. This implementation supports all standard terminology operations.

**⚠️ IMPORTANT: Default Terminology Servers**
By default, this implementation uses test terminology servers:
- **R4/R4B**: `https://tx.fhir.org/r4/`
- **R5**: `https://tx.fhir.org/r5/`
**⚠️ IMPORTANT: There is no default terminology server**

**DO NOT USE THESE DEFAULT SERVERS IN PRODUCTION!** They are test servers with limited resources and no SLA.
Terminology operations send codes taken from the resource being evaluated — potentially patient data — to the terminology server. The evaluator therefore never contacts a server you did not name: if none is configured, `%terminologies.*` and `memberOf()` fail with an error telling you what to set, rather than silently calling a public server.

You must point it at a terminology server you trust. Some options:

- **[HTS](../hts/README.md)**, the terminology server in this workspace — run it yourself, or use `https://hts.heliossoftware.com`. HTS serves operations at the **root** of its base URL (no `/r4` or `/r5` path segment).
- **`https://tx.fhir.org/r4/`** (or `/r5/`), HL7's public **test** server — fine for experimentation, but it is rate-limited, has no SLA, and should not be sent production data.

The base URL you configure is used verbatim; no FHIR-version path segment is appended to it.

**Configuring a Terminology Server:**
```bash
Expand Down
2 changes: 1 addition & 1 deletion crates/fhirpath/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ pub struct Args {
#[arg(long)]
pub validate: bool,

/// Terminology server URL (for terminology operations)
/// Terminology server base URL, required for %terminologies and memberOf (no default)
#[arg(long)]
pub terminology_server: Option<String>,
}
Expand Down
67 changes: 29 additions & 38 deletions crates/fhirpath/src/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,10 @@ pub struct EvaluationContext {
/// When looking up variables, if not found in current context, search parent chain
pub parent_context: Option<Box<EvaluationContext>>,

/// Terminology server URL for terminology operations
/// If not set, uses default servers based on FHIR version
/// Terminology server base URL for terminology operations.
///
/// If unset, `FHIRPATH_TERMINOLOGY_SERVER` is consulted; there is no default
/// server. See [`get_terminology_server_url`](Self::get_terminology_server_url).
pub terminology_server_url: Option<String>,

/// Debug tracer for step-by-step evaluation tracing.
Expand Down Expand Up @@ -624,40 +626,31 @@ impl EvaluationContext {
self.terminology_server_url = Some(url);
}

/// Gets the terminology server URL with defaults
/// Gets the configured terminology server URL, if any
///
/// Resolution order is the explicitly configured URL (via
/// [`set_terminology_server`](Self::set_terminology_server)), then the
/// `FHIRPATH_TERMINOLOGY_SERVER` environment variable.
///
/// There is deliberately **no default server**. Terminology operations send
/// codes drawn from the resource under evaluation — potentially patient
/// data — to the terminology server, so the engine never contacts a host the
/// caller did not name. When this returns `None`, `%terminologies` functions
/// and `memberOf()` fail with an actionable error rather than silently
/// calling a public server.
///
/// Returns the configured terminology server URL, or the default server
/// based on FHIR version if none is configured. Logs a warning when
/// using default servers.
/// The URL is used verbatim as a base: no FHIR-version path segment is
/// appended, because a terminology server's base URL is opaque (HTS, for
/// example, serves operations at the root).
///
/// # Returns
///
/// The terminology server URL to use
pub fn get_terminology_server_url(&self) -> String {
if let Some(url) = &self.terminology_server_url {
url.clone()
} else if let Ok(url) = std::env::var("FHIRPATH_TERMINOLOGY_SERVER") {
// Check environment variable
url
} else {
// Use default servers based on FHIR version
let default_url = match self.fhir_version.as_str() {
"R4" | "R4B" => "https://tx.fhir.org/r4/",
"R5" | "R6" => "https://tx.fhir.org/r5/", // R6 may use R5 server for now
_ => "https://tx.fhir.org/r4/", // Fallback
};

// TODO: Add proper logging when tracing is integrated
eprintln!(
"WARNING: Using default terminology server '{}' - DO NOT use in production!",
default_url
);
eprintln!(
" Set FHIRPATH_TERMINOLOGY_SERVER environment variable or use --terminology-server option"
);

default_url.to_string()
}
/// The terminology server URL to use, or `None` if none is configured
pub fn get_terminology_server_url(&self) -> Option<String> {
self.terminology_server_url
.clone()
.or_else(|| std::env::var("FHIRPATH_TERMINOLOGY_SERVER").ok())
.filter(|url| !url.trim().is_empty())
}
}

Expand Down Expand Up @@ -1736,11 +1729,9 @@ fn evaluate_term(
"http://hl7.org/fhir/StructureDefinition/patient-birthTime".to_string(),
))
} else if name == "terminologies" {
// Return %terminologies object for terminology operations
use crate::terminology_functions::TerminologyFunctions;
let _terminology = TerminologyFunctions::new(context);

// Create a special object that represents the terminology functions
// Return %terminologies object for terminology operations.
// Resolving the server is deferred to the actual function call so
// that merely naming %terminologies never requires configuration.
let mut map = HashMap::new();
map.insert(
"_terminology_functions".to_string(),
Expand Down Expand Up @@ -2667,7 +2658,7 @@ fn evaluate_invocation(
{
// This is a method call on %terminologies
use crate::terminology_functions::TerminologyFunctions;
let terminology = TerminologyFunctions::new(context);
let terminology = TerminologyFunctions::new(context)?;

// Evaluate arguments
let mut evaluated_args = Vec::with_capacity(args_exprs.len());
Expand Down
2 changes: 1 addition & 1 deletion crates/fhirpath/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ impl Default for ServerConfig {
author,
version,
about = "FHIRPath HTTP server",
long_about = "HTTP server providing FHIRPath expression evaluation for fhirpath-lab integration\n\nEnvironment variables:\n FHIRPATH_SERVER_PORT - Server port (default: 3000)\n FHIRPATH_SERVER_HOST - Server host (default: 127.0.0.1)\n FHIRPATH_LOG_LEVEL - Log level: error, warn, info, debug, trace (default: info)\n FHIRPATH_ENABLE_CORS - Enable CORS: true/false (default: true)\n FHIRPATH_CORS_ORIGINS - Allowed origins (comma-separated, * for any) (default: *)\n FHIRPATH_CORS_METHODS - Allowed methods (comma-separated, * for any) (default: GET,POST,OPTIONS)\n FHIRPATH_CORS_HEADERS - Allowed headers (comma-separated, * for any) (default: common headers)\n FHIRPATH_MAX_BODY_SIZE - Max request body size in bytes, measured after decompression (default: 10485760)\n FHIRPATH_TERMINOLOGY_SERVER - Terminology server URL (default: version-specific test servers)"
long_about = "HTTP server providing FHIRPath expression evaluation for fhirpath-lab integration\n\nEnvironment variables:\n FHIRPATH_SERVER_PORT - Server port (default: 3000)\n FHIRPATH_SERVER_HOST - Server host (default: 127.0.0.1)\n FHIRPATH_LOG_LEVEL - Log level: error, warn, info, debug, trace (default: info)\n FHIRPATH_ENABLE_CORS - Enable CORS: true/false (default: true)\n FHIRPATH_CORS_ORIGINS - Allowed origins (comma-separated, * for any) (default: *)\n FHIRPATH_CORS_METHODS - Allowed methods (comma-separated, * for any) (default: GET,POST,OPTIONS)\n FHIRPATH_CORS_HEADERS - Allowed headers (comma-separated, * for any) (default: common headers)\n FHIRPATH_MAX_BODY_SIZE - Max request body size in bytes, measured after decompression (default: 10485760)\n FHIRPATH_TERMINOLOGY_SERVER - Terminology server base URL (no default; required for %terminologies and memberOf)"
)]
pub struct ServerArgs {
/// Port to bind the server to
Expand Down
14 changes: 13 additions & 1 deletion crates/fhirpath/src/terminology_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@
use reqwest::Client;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::time::Duration;

use crate::error::{FhirPathError, FhirPathResult};
use helios_fhir::FhirVersion;

/// Request timeout for terminology server calls.
///
/// Without this, an unresponsive server hangs the evaluating thread indefinitely
/// (`Client::new()` applies no timeout).
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);

/// Terminology client for making requests to a FHIR terminology server
#[derive(Clone)]
pub struct TerminologyClient {
Expand All @@ -28,8 +35,13 @@ impl TerminologyClient {
/// * `base_url` - The base URL of the terminology server
/// * `fhir_version` - The FHIR version to use for requests
pub fn new(base_url: String, fhir_version: FhirVersion) -> Self {
let client = Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.expect("failed to build terminology HTTP client");

Self {
client: Client::new(),
client,
base_url: base_url.trim_end_matches('/').to_string(),
fhir_version,
}
Expand Down
33 changes: 28 additions & 5 deletions crates/fhirpath/src/terminology_functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,38 @@ pub struct TerminologyFunctions {
client: Arc<TerminologyClient>,
}

/// Error returned when a terminology operation is attempted with no server configured.
///
/// Terminology operations transmit codes taken from the resource under evaluation,
/// so there is no default server to fall back on — the caller must name one.
fn no_terminology_server() -> EvaluationError {
EvaluationError::InvalidOperation(
"No terminology server is configured. Terminology operations (%terminologies.* \
and memberOf()) send codes from the evaluated resource to a terminology server, \
so no default server is used. Set the FHIRPATH_TERMINOLOGY_SERVER environment \
variable, or pass --terminology-server (fhirpath-cli/fhirpath-server). The HFS \
server and SQL-on-FHIR tools propagate HFS_TERMINOLOGY_SERVER and \
SOF_TERMINOLOGY_SERVER respectively."
.to_string(),
)
}

impl TerminologyFunctions {
/// Creates a new terminology functions instance
pub fn new(context: &EvaluationContext) -> Self {
let server_url = context.get_terminology_server_url();
///
/// # Errors
///
/// Returns [`EvaluationError::InvalidOperation`] if no terminology server is
/// configured on the context or via `FHIRPATH_TERMINOLOGY_SERVER`.
pub fn new(context: &EvaluationContext) -> Result<Self, EvaluationError> {
let server_url = context
.get_terminology_server_url()
.ok_or_else(no_terminology_server)?;
let client = TerminologyClient::new(server_url, context.fhir_version);

Self {
Ok(Self {
client: Arc::new(client),
}
})
}

/// Expands a ValueSet
Expand Down Expand Up @@ -505,7 +528,7 @@ pub fn member_of(
value_set_url: &str,
context: &EvaluationContext,
) -> Result<EvaluationResult, EvaluationError> {
let terminology = TerminologyFunctions::new(context);
let terminology = TerminologyFunctions::new(context)?;

// Call validateVS and extract the result
let validation_result = terminology.validate_vs(
Expand Down
Loading