Skip to content
Open
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: 1 addition & 0 deletions .claude/skills/run-hfs-server/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ HFS_SERVER_PORT=3000 HFS_LOG_LEVEL=debug cargo run --bin hfs
| `HFS_LOG_LEVEL` | `info` | Log level: error, warn, info, debug, trace |
| `HFS_BASE_URL` | `http://localhost:8080` | Base URL for Location headers and Bundle links |
| `HFS_DATA_DIR` | `./data` | FHIR data directory, including search parameters |
| `HFS_SEARCH_PARAM_CACHE_TTL` | `3600` | Seconds between refreshes of the in-memory SearchParameter registry from storage; a param POSTed to one cluster node becomes visible to others within this interval. `0` disables the refresh. |

## Limits

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ AWS_REGION=us-east-1 \
| `HFS_BASE_URL` | `http://localhost:8080` | Base URL used in `Location` headers and Bundle links |
| `HFS_DATABASE_URL` | `fhir.db` | Database URL (SQLite path or PostgreSQL connection string) |
| `HFS_DATA_DIR` | `./data` | Directory containing FHIR data files (search parameters) |
| `HFS_SEARCH_PARAM_CACHE_TTL` | `3600` | Seconds between refreshes of the in-memory SearchParameter registry from storage; a param POSTed to one cluster node becomes visible to others within this interval. `0` disables the refresh. |
| `HFS_DEFAULT_FHIR_VERSION` | `R4` | FHIR version (R4, R4B, R5, R6) |
| `HFS_LOG_LEVEL` | `info` | Log level (error, warn, info, debug, trace) |

Expand Down
1 change: 1 addition & 0 deletions book/src/configuration/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ All server behavior is controlled through environment variables. No configuratio
| `HFS_LOG_LEVEL` | `info` | Log level: `error`, `warn`, `info`, `debug`, `trace` |
| `HFS_BASE_URL` | `http://localhost:8080` | Base URL for Location headers and Bundle links |
| `HFS_DATA_DIR` | `./data` | Path to FHIR data directory (search parameters) |
| `HFS_SEARCH_PARAM_CACHE_TTL` | `3600` | Seconds between refreshes of the in-memory SearchParameter registry from storage. In a cluster, a SearchParameter POSTed to one node becomes visible to the others within this interval. `0` disables the periodic refresh. |

## Limits

Expand Down
175 changes: 175 additions & 0 deletions crates/fhir/src/search/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,75 @@ impl SearchParameterLoader {
Ok(params)
}

/// Returns the raw SearchParameter resources from the FHIR spec bundle,
/// as stored-shape JSON.
///
/// This is the seeding companion to
/// [`load_from_spec_file`](Self::load_from_spec_file): the registry wants
/// parsed definitions, but seeding storage wants the original resource
/// JSON (ids, metadata, and fields the definition model drops). Only
/// entries that also parse as definitions are returned, so storage never
/// holds a parameter the registry would reject.
pub fn load_spec_resources(&self, data_dir: &Path) -> Result<Vec<Value>, LoaderError> {
let path = data_dir.join(self.spec_filename());
let content =
std::fs::read_to_string(&path).map_err(|e| LoaderError::ConfigLoadFailed {
path: path.display().to_string(),
message: e.to_string(),
})?;
let json: Value =
serde_json::from_str(&content).map_err(|e| LoaderError::ConfigLoadFailed {
path: path.display().to_string(),
message: format!("Invalid JSON: {}", e),
})?;

let mut resources = Vec::new();
if let Some(entries) = json.get("entry").and_then(|e| e.as_array()) {
for entry in entries {
if let Some(resource) = entry.get("resource")
&& resource.get("resourceType").and_then(|t| t.as_str())
== Some("SearchParameter")
&& self.parse_resource(resource).is_ok()
{
resources.push(resource.clone());
}
}
}
Ok(resources)
}

/// Renders a definition as a minimal FHIR SearchParameter resource.
///
/// Used to seed storage with the embedded fallback parameters, which are
/// constructed programmatically and have no source JSON. The resource id
/// is the last segment of the canonical URL (`.../Resource-id` → id
/// `Resource-id`), matching the spec bundle's convention.
pub fn definition_to_fhir_resource(def: &SearchParameterDefinition) -> Value {
let id = def.url.rsplit('/').next().unwrap_or(&def.code);
let mut resource = serde_json::json!({
"resourceType": "SearchParameter",
"id": id,
"url": def.url,
"name": def.name.clone().unwrap_or_else(|| def.code.clone()),
"status": def.status.to_fhir_status(),
"code": def.code,
"base": def.base,
"type": def.param_type.to_string(),
"expression": def.expression,
});
let fields = resource.as_object_mut().expect("literal object");
if let Some(description) = &def.description {
fields.insert("description".into(), Value::String(description.clone()));
}
if let Some(target) = &def.target {
fields.insert(
"target".into(),
Value::Array(target.iter().cloned().map(Value::String).collect()),
);
}
resource
}

/// Loads custom SearchParameter files from the data directory.
///
/// Scans the data directory for JSON files that are not the standard
Expand Down Expand Up @@ -692,6 +761,112 @@ mod tests {
assert!(matches!(result, Err(LoaderError::MissingField { field, .. }) if field == "url"));
}

/// The workspace data directory holding `search-parameters-r4.json`.
fn workspace_data_dir() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data")
}

#[test]
fn test_load_spec_resources_returns_stored_shape_json() {
let loader = SearchParameterLoader::new(FhirVersion::R4);
let resources = loader
.load_spec_resources(&workspace_data_dir())
.expect("load spec resources");

assert!(
resources.len() > 1300,
"expected the full R4 spec set, got {}",
resources.len()
);
// Every entry is a SearchParameter carrying its bundle id — that id is
// what makes seeding idempotent (a duplicate `create` fails).
for resource in &resources {
assert_eq!(
resource.get("resourceType").and_then(|t| t.as_str()),
Some("SearchParameter")
);
assert!(
resource.get("id").and_then(|id| id.as_str()).is_some(),
"spec resource is missing an id: {resource}"
);
}
}

#[test]
fn test_load_spec_resources_missing_dir_errors() {
let loader = SearchParameterLoader::new(FhirVersion::R4);
let result = loader.load_spec_resources(Path::new("/nonexistent/data/dir"));
assert!(matches!(result, Err(LoaderError::ConfigLoadFailed { .. })));
}

#[test]
fn test_definition_to_fhir_resource() {
// A reference parameter with description and targets exercises both
// optional-field branches.
let mut def = SearchParameterDefinition::new(
"http://example.org/fhir/SearchParameter/Patient-organization",
"organization",
SearchParamType::Reference,
"Patient.managingOrganization",
)
.with_base(vec!["Patient"])
.with_targets(vec!["Organization"]);
def.description = Some("The organization managing the patient".to_string());

let resource = SearchParameterLoader::definition_to_fhir_resource(&def);
assert_eq!(
resource.get("resourceType").and_then(|t| t.as_str()),
Some("SearchParameter")
);
// Id is the last URL segment, matching the spec bundle convention.
assert_eq!(
resource.get("id").and_then(|i| i.as_str()),
Some("Patient-organization")
);
assert_eq!(
resource.get("code").and_then(|c| c.as_str()),
Some("organization")
);
assert_eq!(
resource.get("type").and_then(|t| t.as_str()),
Some("reference")
);
assert_eq!(
resource.get("description").and_then(|d| d.as_str()),
Some("The organization managing the patient")
);
assert_eq!(
resource.get("target").and_then(|t| t.as_array()),
Some(&vec![Value::String("Organization".to_string())])
);
// The rendered resource round-trips back into a parseable definition.
assert!(loader_can_parse(&resource));

// A parameter with no description/target omits those fields entirely.
let minimal = SearchParameterLoader::definition_to_fhir_resource(
&SearchParameterDefinition::new(
"http://hl7.org/fhir/SearchParameter/Resource-id",
"_id",
SearchParamType::Token,
"id",
)
.with_base(vec!["Resource"]),
);
assert_eq!(
minimal.get("id").and_then(|i| i.as_str()),
Some("Resource-id")
);
assert!(minimal.get("description").is_none());
assert!(minimal.get("target").is_none());
assert!(loader_can_parse(&minimal));
}

fn loader_can_parse(resource: &Value) -> bool {
SearchParameterLoader::new(FhirVersion::R4)
.parse_resource(resource)
.is_ok()
}

#[test]
fn test_transform_as_to_oftype() {
assert_eq!(
Expand Down
57 changes: 57 additions & 0 deletions crates/fhir/src/search/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,29 @@ impl SearchParameterRegistry {
Ok(())
}

/// Removes every parameter registered from `source`, returning how many
/// were removed.
///
/// This is the rebuild primitive for a storage-backed refresh: drop all
/// `Stored` definitions, then re-register what storage currently holds.
/// Slots shared with other sources keep their remaining candidates, so a
/// spec parameter shadowed by a since-deleted stored override resumes
/// resolving searches.
pub fn unregister_source(&mut self, source: SearchParameterSource) -> usize {
let urls: Vec<String> = self
.params_by_url
.iter()
.filter(|(_, p)| p.source == source)
.map(|(url, _)| url.clone())
.collect();
for url in &urls {
// The URL came straight out of the index; a NotFound here would
// mean the two indexes disagree, which unregister() prevents.
let _ = self.unregister(url);
}
urls.len()
}

/// Returns all resource types that have registered parameters.
pub fn resource_types(&self) -> Vec<String> {
self.params_by_type.keys().cloned().collect()
Expand Down Expand Up @@ -930,6 +953,40 @@ mod tests {
);
}

/// `unregister_source` is the storage-refresh rebuild primitive (#235):
/// dropping all `Stored` params must promote any spec param they were
/// shadowing, and leave other sources untouched.
#[test]
fn unregister_source_removes_only_that_source_and_promotes_shadowed() {
let mut registry = SearchParameterRegistry::new();
registry.register(spec_code_param()).unwrap();
let stored_override = SearchParameterDefinition::new(
"http://acme.health/fhir/SearchParameter/observation-code-stored",
"code",
SearchParamType::Token,
"Observation.code",
)
.with_base(vec!["Observation"])
.with_source(SearchParameterSource::Stored);
registry.register(stored_override.clone()).unwrap();
assert_eq!(
registry.get_param("Observation", "code").unwrap().url,
stored_override.url
);

let removed = registry.unregister_source(SearchParameterSource::Stored);

assert_eq!(removed, 1);
assert_eq!(registry.len(), 1);
// The spec param the stored override was shadowing resolves again.
assert_eq!(
registry.get_param("Observation", "code").unwrap().url,
SPEC_URL
);
// Removing a source with no registrations is a no-op.
assert_eq!(registry.unregister_source(SearchParameterSource::Stored), 0);
}

#[test]
fn update_status_still_applies_to_the_winner() {
let mut registry = SearchParameterRegistry::new();
Expand Down
1 change: 1 addition & 0 deletions crates/hfs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Options:
| `HFS_LOG_LEVEL` | info | Log level (error, warn, info, debug, trace) |
| `DATABASE_URL` | fhir.db | Database connection string |
| `HFS_DATA_DIR` | ./data | Path to FHIR data directory (search parameters) |
| `HFS_SEARCH_PARAM_CACHE_TTL` | 3600 | Seconds between refreshes of the in-memory SearchParameter registry from storage; a param POSTed to one cluster node becomes visible to others within this interval. `0` disables the refresh. |
| `HFS_MAX_BODY_SIZE` | 10485760 | Max request body size (bytes; applies to the decompressed body for compressed requests) |
| `HFS_REQUEST_TIMEOUT` | 30 | Request timeout (seconds) |
| `HFS_ENABLE_CORS` | true | Enable CORS |
Expand Down
Loading