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]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod integration {
|
||||
mod client;
|
||||
mod external_reference_fetcher;
|
||||
mod outbound_security;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{Router, http::StatusCode, response::Redirect, routing::get};
|
||||
use crank_adapter_rest::{
|
||||
ExternalReferenceFetchError, ExternalReferenceFetcher, OutboundHttpPolicy,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_references_are_default_off_before_any_request() {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let base_url = spawn_server(Arc::clone(&requests)).await;
|
||||
let fetcher = ExternalReferenceFetcher::try_new(
|
||||
OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||
Vec::new(),
|
||||
1024,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = fetcher
|
||||
.get(&format!("{base_url}/document"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, ExternalReferenceFetchError::Disabled));
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetcher_uses_prefix_and_actual_address_policy_then_returns_bounded_bytes() {
|
||||
let base_url = spawn_server(Arc::new(AtomicUsize::new(0))).await;
|
||||
let fetcher = ExternalReferenceFetcher::try_new(
|
||||
OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||
vec![base_url.clone()],
|
||||
8,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fetcher
|
||||
.get(&format!("{base_url}/document#/components/schemas/A"))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"openapi".to_vec()
|
||||
);
|
||||
let error = fetcher.get(&format!("{base_url}/large")).await.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
ExternalReferenceFetchError::ResponseTooLarge { limit_bytes: 8 }
|
||||
));
|
||||
|
||||
let private_without_explicit_outbound_allow = ExternalReferenceFetcher::try_new(
|
||||
OutboundHttpPolicy::default(),
|
||||
vec![base_url.clone()],
|
||||
1024,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.unwrap();
|
||||
let error = private_without_explicit_outbound_allow
|
||||
.get(&format!("{base_url}/document"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
ExternalReferenceFetchError::TargetNotAllowed
|
||||
));
|
||||
|
||||
let exact_path_fetcher = ExternalReferenceFetcher::try_new(
|
||||
OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||
vec![format!("{base_url}/document")],
|
||||
1024,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.unwrap();
|
||||
let error = exact_path_fetcher
|
||||
.get(&format!("{base_url}/document-unrelated"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
ExternalReferenceFetchError::TargetNotAllowed
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetcher_rejects_redirects_and_userinfo_without_exposing_the_url() {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let base_url = spawn_server(Arc::clone(&requests)).await;
|
||||
let fetcher = ExternalReferenceFetcher::try_new(
|
||||
OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||
vec![base_url.clone()],
|
||||
1024,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = fetcher
|
||||
.get(&format!("{base_url}/redirect"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
ExternalReferenceFetchError::RedirectNotAllowed
|
||||
));
|
||||
|
||||
let userinfo_url = base_url.replacen("http://", "http://user:credential@", 1);
|
||||
let error = fetcher
|
||||
.get(&format!("{userinfo_url}/document"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
ExternalReferenceFetchError::TargetNotAllowed
|
||||
));
|
||||
let rendered = format!("{error:?} {error}");
|
||||
assert!(!rendered.contains("credential"));
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
async fn spawn_server(requests: Arc<AtomicUsize>) -> String {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/document",
|
||||
get({
|
||||
let requests = Arc::clone(&requests);
|
||||
move || {
|
||||
let requests = Arc::clone(&requests);
|
||||
async move {
|
||||
requests.fetch_add(1, Ordering::SeqCst);
|
||||
(StatusCode::OK, "openapi")
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route("/large", get(|| async { "response too large" }))
|
||||
.route("/redirect", get(|| async { Redirect::to("/document") }));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
format!("http://{address}")
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use std::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
@@ -14,7 +15,9 @@ use axum::{
|
||||
http::StatusCode,
|
||||
routing::{any, post},
|
||||
};
|
||||
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
|
||||
use crank_adapter_rest::{
|
||||
ExternalReferenceFetcher, OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest,
|
||||
};
|
||||
use crank_core::{HttpMethod, ProtocolAdapterError, RestTarget};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
@@ -94,7 +97,7 @@ async fn proxy_environment_is_ignored_by_default() {
|
||||
let adapter = RestAdapter::default();
|
||||
let request = json_request(json!({"payload": "proxy-env-canary"}));
|
||||
let rest_target = RestTarget {
|
||||
base_url: target,
|
||||
base_url: target.clone(),
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/capture".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
@@ -102,6 +105,17 @@ async fn proxy_environment_is_ignored_by_default() {
|
||||
let _ = adapter.execute(&rest_target, &request).await.expect_err(
|
||||
"unresolvable target should fail locally instead of being sent through proxy env",
|
||||
);
|
||||
let fetcher = ExternalReferenceFetcher::try_new(
|
||||
OutboundHttpPolicy::default(),
|
||||
vec!["http://public.example.test/".to_owned()],
|
||||
1024,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.expect("valid external reference fetcher");
|
||||
let _ = fetcher
|
||||
.get(&target)
|
||||
.await
|
||||
.expect_err("external reference fetcher must not use proxy environment variables");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user