feat(import): resolve references and schema composition

This commit is contained in:
2026-08-29 08:54:26 +03:00
parent 6c2a3712d8
commit 55209a9bbc
46 changed files with 4848 additions and 437 deletions
@@ -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;
}