feat(import): resolve references and schema composition
This commit is contained in:
@@ -25,7 +25,7 @@ use serde_json::Value;
|
||||
use tracing::{Instrument, Span};
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
use crate::{RestAdapterError, RestRequest, RestResponse};
|
||||
use crate::{ExternalReferenceFetchError, RestAdapterError, RestRequest, RestResponse};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RestAdapter {
|
||||
@@ -33,6 +33,18 @@ pub struct RestAdapter {
|
||||
policy: OutboundHttpPolicy,
|
||||
}
|
||||
|
||||
/// A deliberately separate, GET-only boundary for materializing external
|
||||
/// OpenAPI documents. It has no execution metrics, tracing propagation, or
|
||||
/// request construction semantics from [`RestAdapter`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExternalReferenceFetcher {
|
||||
client: Result<Client, Arc<str>>,
|
||||
policy: OutboundHttpPolicy,
|
||||
allowed_url_prefixes: Vec<String>,
|
||||
max_response_bytes: usize,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OutboundHttpPolicy {
|
||||
allowed_hosts: Vec<String>,
|
||||
@@ -56,15 +68,7 @@ impl RestAdapter {
|
||||
}
|
||||
|
||||
pub fn with_policy(policy: OutboundHttpPolicy) -> Self {
|
||||
let resolver = Arc::new(PolicyDnsResolver {
|
||||
policy: policy.clone(),
|
||||
});
|
||||
let client = Client::builder()
|
||||
.redirect(redirect::Policy::none())
|
||||
.no_proxy()
|
||||
.dns_resolver(resolver)
|
||||
.build()
|
||||
.map_err(|error| Arc::<str>::from(error.to_string()));
|
||||
let client = outbound_client(&policy);
|
||||
|
||||
Self { client, policy }
|
||||
}
|
||||
@@ -195,6 +199,153 @@ impl RestAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalReferenceFetcher {
|
||||
pub fn try_new(
|
||||
policy: OutboundHttpPolicy,
|
||||
allowed_url_prefixes: Vec<String>,
|
||||
max_response_bytes: usize,
|
||||
timeout: Duration,
|
||||
) -> Result<Self, ExternalReferenceFetchError> {
|
||||
if max_response_bytes == 0 || timeout.is_zero() {
|
||||
return Err(ExternalReferenceFetchError::InvalidConfiguration);
|
||||
}
|
||||
let allowed_url_prefixes = allowed_url_prefixes
|
||||
.into_iter()
|
||||
.map(|prefix| canonical_external_reference_prefix(&prefix))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(Self {
|
||||
client: outbound_client(&policy),
|
||||
policy,
|
||||
allowed_url_prefixes,
|
||||
max_response_bytes,
|
||||
timeout,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetches one document with a bounded, headerless `GET`.
|
||||
///
|
||||
/// URL fragments are stripped because they address JSON Pointer targets in
|
||||
/// the fetched document rather than a network resource.
|
||||
pub async fn get(&self, url: &str) -> Result<Vec<u8>, ExternalReferenceFetchError> {
|
||||
if self.allowed_url_prefixes.is_empty() {
|
||||
return Err(ExternalReferenceFetchError::Disabled);
|
||||
}
|
||||
let mut url =
|
||||
reqwest::Url::parse(url).map_err(|_| ExternalReferenceFetchError::InvalidUrl)?;
|
||||
url.set_fragment(None);
|
||||
if !self
|
||||
.allowed_url_prefixes
|
||||
.iter()
|
||||
.any(|prefix| matches_external_reference_prefix(&url, prefix))
|
||||
{
|
||||
return Err(ExternalReferenceFetchError::TargetNotAllowed);
|
||||
}
|
||||
self.policy
|
||||
.validate_url(&url)
|
||||
.map_err(external_policy_error)?;
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.map_err(|_| ExternalReferenceFetchError::InvalidConfiguration)?;
|
||||
let response = client
|
||||
.get(url)
|
||||
.timeout(self.timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(external_transport_error)?;
|
||||
let status = response.status();
|
||||
if status.is_redirection() {
|
||||
return Err(ExternalReferenceFetchError::RedirectNotAllowed);
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(ExternalReferenceFetchError::UnexpectedStatus {
|
||||
status: status.as_u16(),
|
||||
});
|
||||
}
|
||||
read_external_response_bytes(response, self.max_response_bytes).await
|
||||
}
|
||||
}
|
||||
|
||||
fn outbound_client(policy: &OutboundHttpPolicy) -> Result<Client, Arc<str>> {
|
||||
let resolver = Arc::new(PolicyDnsResolver {
|
||||
policy: policy.clone(),
|
||||
});
|
||||
Client::builder()
|
||||
.redirect(redirect::Policy::none())
|
||||
.no_proxy()
|
||||
.dns_resolver(resolver)
|
||||
.build()
|
||||
.map_err(|error| Arc::<str>::from(error.to_string()))
|
||||
}
|
||||
|
||||
fn canonical_external_reference_prefix(
|
||||
prefix: &str,
|
||||
) -> Result<String, ExternalReferenceFetchError> {
|
||||
let url = reqwest::Url::parse(prefix)
|
||||
.map_err(|_| ExternalReferenceFetchError::InvalidConfiguration)?;
|
||||
if !matches!(url.scheme(), "http" | "https")
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(ExternalReferenceFetchError::InvalidConfiguration);
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn matches_external_reference_prefix(url: &reqwest::Url, prefix: &str) -> bool {
|
||||
let url = url.as_str();
|
||||
if !url.starts_with(prefix) {
|
||||
return false;
|
||||
}
|
||||
let Some(next) = url.as_bytes().get(prefix.len()) else {
|
||||
return true;
|
||||
};
|
||||
prefix.ends_with('/') || matches!(next, b'/' | b'?')
|
||||
}
|
||||
|
||||
fn external_policy_error(error: RestAdapterError) -> ExternalReferenceFetchError {
|
||||
match error {
|
||||
RestAdapterError::TargetNotAllowed { .. } => ExternalReferenceFetchError::TargetNotAllowed,
|
||||
_ => ExternalReferenceFetchError::InvalidConfiguration,
|
||||
}
|
||||
}
|
||||
|
||||
fn external_transport_error(error: reqwest::Error) -> ExternalReferenceFetchError {
|
||||
ExternalReferenceFetchError::Transport {
|
||||
timeout: error.is_timeout(),
|
||||
connect: error.is_connect(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_external_response_bytes(
|
||||
response: reqwest::Response,
|
||||
max_response_bytes: usize,
|
||||
) -> Result<Vec<u8>, ExternalReferenceFetchError> {
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > max_response_bytes as u64)
|
||||
{
|
||||
return Err(ExternalReferenceFetchError::ResponseTooLarge {
|
||||
limit_bytes: max_response_bytes,
|
||||
});
|
||||
}
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(external_transport_error)?;
|
||||
if bytes.len().saturating_add(chunk.len()) > max_response_bytes {
|
||||
return Err(ExternalReferenceFetchError::ResponseTooLarge {
|
||||
limit_bytes: max_response_bytes,
|
||||
});
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn upstream_outcome(error: &RestAdapterError) -> UpstreamOutcome {
|
||||
match error {
|
||||
RestAdapterError::UnexpectedStatus { status, .. } if (400..500).contains(status) => {
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ExternalReferenceFetchError {
|
||||
#[error("external references are disabled")]
|
||||
Disabled,
|
||||
#[error("external reference URL is invalid")]
|
||||
InvalidUrl,
|
||||
#[error("external reference target is not allowed")]
|
||||
TargetNotAllowed,
|
||||
#[error("external reference redirects are not allowed")]
|
||||
RedirectNotAllowed,
|
||||
#[error("external reference response exceeds the configured limit of {limit_bytes} bytes")]
|
||||
ResponseTooLarge { limit_bytes: usize },
|
||||
#[error("external reference endpoint returned status {status}")]
|
||||
UnexpectedStatus { status: u16 },
|
||||
#[error("external reference request failed")]
|
||||
Transport { timeout: bool, connect: bool },
|
||||
#[error("external reference fetch configuration is invalid")]
|
||||
InvalidConfiguration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RestAdapterError {
|
||||
#[error("invalid base url: {url}")]
|
||||
|
||||
@@ -8,8 +8,8 @@ use crank_core::{
|
||||
ProtocolAdapterError, RestTarget, RuntimeRequestContext, Target,
|
||||
};
|
||||
|
||||
pub use client::{OutboundHttpPolicy, RestAdapter};
|
||||
pub use error::RestAdapterError;
|
||||
pub use client::{ExternalReferenceFetcher, OutboundHttpPolicy, RestAdapter};
|
||||
pub use error::{ExternalReferenceFetchError, RestAdapterError};
|
||||
pub use model::{RestRequest, RestResponse};
|
||||
|
||||
#[async_trait]
|
||||
|
||||
Reference in New Issue
Block a user