use std::collections::{BTreeSet, HashSet};
use std::num::NonZeroUsize;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use futures::stream::{self, StreamExt};
use lru::LruCache;
use serde_json::Value;
use tokio::net::lookup_host;
use url::{Host, Url};
use helios_fhir::{FhirResource, FhirVersion};
use crate::reference_collector::{
collect_reference_strings, collect_references_from_json, collect_resource_keys,
resolves_in_bundle,
};
use crate::remote_resolver::{RemoteResolveConfig, is_blocked_address};
use crate::{SofBundle, parse_json_to_fhir_resource_pub};
pub struct RemoteResolver {
config: RemoteResolveConfig,
cache: Mutex<LruCache<String, Option<Value>>>,
fetched: AtomicUsize,
}
impl RemoteResolver {
pub fn new(config: RemoteResolveConfig) -> Self {
let cap = NonZeroUsize::new(config.cache_max_entries.max(1)).expect("cap >= 1");
Self {
config,
cache: Mutex::new(LruCache::new(cap)),
fetched: AtomicUsize::new(0),
}
}
pub async fn resolve(
&self,
seed_refs: Vec<String>,
local_keys: &BTreeSet<String>,
version: FhirVersion,
) -> Vec<FhirResource> {
if !self.config.is_active() {
return Vec::new();
}
let mut resolved_json: Vec<Value> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut frontier: Vec<String> = seed_refs
.into_iter()
.filter(|r| self.config.fetch_decision(r).is_allowed())
.filter(|r| !resolves_in_bundle(r, local_keys))
.filter(|r| seen.insert(r.clone()))
.collect();
let mut depth = 0;
while depth < self.config.max_depth && !frontier.is_empty() {
depth += 1;
let mut round_json: Vec<Value> = Vec::new();
let mut misses: Vec<String> = Vec::new();
{
let mut cache = self.cache.lock().unwrap();
for reference in frontier.drain(..) {
match cache.get(&reference) {
Some(Some(json)) => round_json.push(json.clone()),
Some(None) => {} None => misses.push(reference),
}
}
}
if !misses.is_empty() {
let fetched = self.fetch_batch(misses).await;
let mut cache = self.cache.lock().unwrap();
for (reference, result) in fetched {
if let Some(json) = &result {
round_json.push(json.clone());
}
cache.put(reference, result);
}
}
if depth < self.config.max_depth {
for json in &round_json {
for reference in collect_references_from_json(json) {
if self.config.fetch_decision(&reference).is_allowed()
&& !resolves_in_bundle(&reference, local_keys)
&& seen.insert(reference.clone())
{
frontier.push(reference);
}
}
}
}
resolved_json.extend(round_json);
}
resolved_json
.into_iter()
.filter_map(
|json| match parse_json_to_fhir_resource_pub(json, version) {
Ok(resource) => Some(resource),
Err(err) => {
tracing::warn!(error = %err, "remote resolve: skipping unparseable resource");
None
}
},
)
.collect()
}
async fn fetch_batch(&self, references: Vec<String>) -> Vec<(String, Option<Value>)> {
let already = self.fetched.load(Ordering::Relaxed);
let remaining = self.config.max_fetches.saturating_sub(already);
if remaining == 0 {
tracing::warn!(
max_fetches = self.config.max_fetches,
"remote resolve: fetch cap reached; skipping further fetches"
);
return references.into_iter().map(|r| (r, None)).collect();
}
let (to_fetch, skipped): (Vec<String>, Vec<String>) = if references.len() > remaining {
tracing::warn!(
skipped = references.len() - remaining,
max_fetches = self.config.max_fetches,
"remote resolve: fetch cap reached; some references not fetched"
);
let mut iter = references.into_iter();
let take: Vec<String> = iter.by_ref().take(remaining).collect();
(take, iter.collect())
} else {
(references, Vec::new())
};
self.fetched.fetch_add(to_fetch.len(), Ordering::Relaxed);
let (client, allowed_hosts) = build_validated_client(&to_fetch, &self.config).await;
let config = &self.config;
let allowed = &allowed_hosts;
let mut results: Vec<(String, Option<Value>)> = stream::iter(to_fetch)
.map(|reference| {
let client = client.clone();
async move {
let json = if host_is_allowed(&reference, allowed) {
fetch_one(&client, &reference, config).await
} else {
None
};
(reference, json)
}
})
.buffer_unordered(config.concurrency)
.collect()
.await;
results.extend(skipped.into_iter().map(|r| (r, None)));
results
}
}
pub async fn prefetch_external_resources(
bundle: &SofBundle,
config: &RemoteResolveConfig,
) -> Vec<FhirResource> {
if !config.is_active() {
return Vec::new();
}
let resolver = RemoteResolver::new(config.clone());
let refs = collect_reference_strings(bundle);
let keys = collect_resource_keys(bundle);
let fetched = resolver.resolve(refs, &keys, bundle.version()).await;
if !fetched.is_empty() {
tracing::info!(
count = fetched.len(),
"remote resolve: prefetched resources"
);
}
fetched
}
async fn build_validated_client(
batch: &[String],
config: &RemoteResolveConfig,
) -> (reqwest::Client, HashSet<String>) {
let mut builder = reqwest::Client::builder()
.timeout(config.timeout)
.redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("helios-sof/", env!("CARGO_PKG_VERSION")));
let mut allowed_hosts: HashSet<String> = HashSet::new();
let mut processed: HashSet<String> = HashSet::new();
for reference in batch {
let Ok(url) = Url::parse(reference) else {
continue;
};
let (Some(host), Some(host_str)) = (url.host(), url.host_str()) else {
continue;
};
let host_key = host_str.to_ascii_lowercase();
if !processed.insert(host_key.clone()) {
continue;
}
let port = url.port_or_known_default().unwrap_or(443);
match host {
Host::Ipv4(_) | Host::Ipv6(_) => {
allowed_hosts.insert(host_key);
}
Host::Domain(name) => match lookup_host((name, port)).await {
Ok(addrs) => {
let addrs: Vec<std::net::SocketAddr> = addrs.collect();
if addrs.is_empty() {
tracing::warn!(host = name, "remote resolve: host did not resolve");
} else if addrs
.iter()
.any(|addr| is_blocked_address(addr.ip(), config.allow_private_addresses))
{
tracing::warn!(
host = name,
"remote resolve: host resolves to a disallowed address; blocked"
);
} else {
builder = builder.resolve_to_addrs(name, &addrs);
allowed_hosts.insert(host_key);
}
}
Err(err) => {
tracing::warn!(host = name, error = %err, "remote resolve: DNS lookup failed")
}
},
}
}
let client = builder.build().unwrap_or_else(|err| {
tracing::warn!(error = %err, "remote resolve: client build failed; using default client");
reqwest::Client::new()
});
(client, allowed_hosts)
}
fn host_is_allowed(reference: &str, allowed_hosts: &HashSet<String>) -> bool {
Url::parse(reference)
.ok()
.and_then(|url| url.host_str().map(|h| h.to_ascii_lowercase()))
.map(|host| allowed_hosts.contains(&host))
.unwrap_or(false)
}
async fn fetch_one(
client: &reqwest::Client,
reference: &str,
config: &RemoteResolveConfig,
) -> Option<Value> {
let url = Url::parse(reference).ok()?;
let host = url.host_str()?.to_ascii_lowercase();
let mut request = client
.get(url.clone())
.header(reqwest::header::ACCEPT, "application/fhir+json");
if let Some(token) = config.bearer_for_host(&host) {
request = request.bearer_auth(token);
}
let response = match request.send().await {
Ok(resp) => resp,
Err(err) => {
tracing::debug!(reference, error = %err, "remote resolve: request failed");
return None;
}
};
if !response.status().is_success() {
tracing::debug!(reference, status = %response.status(), "remote resolve: non-success");
return None;
}
if response
.content_length()
.is_some_and(|len| len as usize > config.max_response_bytes)
{
tracing::warn!(
reference,
"remote resolve: response exceeds size cap (Content-Length)"
);
return None;
}
let bytes = response.bytes().await.ok()?;
if bytes.len() > config.max_response_bytes {
tracing::warn!(
reference,
len = bytes.len(),
"remote resolve: response exceeds size cap"
);
return None;
}
serde_json::from_slice(&bytes).ok()
}