Skip to content

Commit

Permalink
Dispatch HTTP self requests directly to components instead of via n…
Browse files Browse the repository at this point in the history
…etwork stack

Signed-off-by: itowlson <ivan.towlson@fermyon.com>
  • Loading branch information
itowlson committed Jan 19, 2024
1 parent bbbb59b commit 200baf0
Show file tree
Hide file tree
Showing 7 changed files with 275 additions and 75 deletions.
4 changes: 3 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions crates/outbound-http/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ doctest = false

[dependencies]
anyhow = "1.0"
http = "0.2"
http = "1.0.0"
reqwest = { version = "0.11", features = ["gzip"] }
spin-app = { path = "../app", optional = true }
spin-core = { path = "../core", optional = true }
spin-http = { path = "../http", optional = true }
spin-locked-app = { path = "../locked-app" }
spin-outbound-networking = { path = "../outbound-networking" }
spin-world = { path = "../world", optional = true }
terminal = { path = "../terminal" }
tracing = { workspace = true }
url = "2.2.1"
wasmtime-wasi-http = { workspace = true }

[features]
default = ["runtime"]
runtime = ["dep:spin-app", "dep:spin-core", "dep:spin-world"]
runtime = ["dep:spin-app", "dep:spin-core", "dep:spin-http", "dep:spin-world"]
104 changes: 88 additions & 16 deletions crates/outbound-http/src/host_impl.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use anyhow::Result;
use http::HeaderMap;
use reqwest::Client;
use spin_core::async_trait;
use spin_outbound_networking::{AllowedHostsConfig, OutboundUrl};
Expand All @@ -13,12 +12,71 @@ use spin_world::v1::{
pub struct OutboundHttp {
/// List of hosts guest modules are allowed to make requests to.
pub allowed_hosts: AllowedHostsConfig,
/// During an incoming HTTP request, origin is set to the host of that incoming HTTP request.
/// This is used to direct outbound requests to the same host when allowed.
pub origin: String,
/// Used to dispatch outbound `self` requests directly to a component.
pub self_dispatcher: HttpSelfDispatcher,
client: Option<Client>,
}

#[derive(Default, Clone)]
pub enum HttpSelfDispatcher {
#[default]
NotHttp,
Handler(std::sync::Arc<Box<dyn HttpRequestHandler + Send + Sync>>),
}

#[async_trait]
pub trait HttpRequestHandler {
async fn handle(
&self,
mut req: http::Request<wasmtime_wasi_http::body::HyperIncomingBody>,
scheme: http::uri::Scheme,
addr: std::net::SocketAddr,
) -> anyhow::Result<http::Response<wasmtime_wasi_http::body::HyperIncomingBody>>;
}

impl HttpSelfDispatcher {
pub fn new(handler: &std::sync::Arc<Box<dyn HttpRequestHandler + Send + Sync>>) -> Self {
Self::Handler(handler.clone())
}

async fn dispatch(&self, request: Request) -> Result<Response, HttpError> {
match self {
Self::NotHttp => {
tracing::error!("Cannot send request to {}: same-application requests are supported only for applications with HTTP triggers", request.uri);
Err(HttpError::RuntimeError)
}
Self::Handler(handler) => {
let mut reqbuilder = http::Request::builder()
.uri(request.uri)
.method(http_method_from(request.method));
for (hname, hval) in request.headers {
reqbuilder = reqbuilder.header(hname, hval);
}
let req = reqbuilder
.body(match request.body {
Some(b) => spin_http::body::full(b.into()),
None => spin_http::body::empty(),
})
.map_err(|_| HttpError::RuntimeError)?;
let scheme = http::uri::Scheme::HTTPS;
let addr = std::net::SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
0,
);
let resp = handler
.handle(req, scheme, addr)
.await
.map_err(|_| HttpError::RuntimeError)?;
Ok(Response {
status: resp.status().as_u16(),
headers: None,
body: None,
})
}
}
}
}

impl OutboundHttp {
/// Check if guest module is allowed to send request to URL, based on the list of
/// allowed hosts defined by the runtime. If the url passed in is a relative path,
Expand Down Expand Up @@ -53,13 +111,13 @@ impl outbound_http::Host for OutboundHttp {
return Err(HttpError::DestinationNotAllowed);
}

let method = method_from(req.method);
if req.uri.starts_with('/') {
return self.self_dispatcher.dispatch(req).await;
}

let method = reqwest_method_from(req.method);

let abs_url = if req.uri.starts_with('/') {
format!("{}{}", self.origin, req.uri)
} else {
req.uri.clone()
};
let abs_url = req.uri.clone();

let req_url = reqwest::Url::parse(&abs_url).map_err(|_| HttpError::InvalidUrl)?;

Expand Down Expand Up @@ -111,7 +169,7 @@ fn log_reqwest_error(err: reqwest::Error) -> HttpError {
HttpError::RuntimeError
}

fn method_from(m: Method) -> http::Method {
fn http_method_from(m: Method) -> http::Method {
match m {
Method::Get => http::Method::GET,
Method::Post => http::Method::POST,
Expand All @@ -123,6 +181,18 @@ fn method_from(m: Method) -> http::Method {
}
}

fn reqwest_method_from(m: Method) -> reqwest::Method {
match m {
Method::Get => reqwest::Method::GET,
Method::Post => reqwest::Method::POST,
Method::Put => reqwest::Method::PUT,
Method::Delete => reqwest::Method::DELETE,
Method::Patch => reqwest::Method::PATCH,
Method::Head => reqwest::Method::HEAD,
Method::Options => reqwest::Method::OPTIONS,
}
}

async fn response_from_reqwest(res: reqwest::Response) -> Result<Response, HttpError> {
let status = res.status().as_u16();
let headers = response_headers(res.headers()).map_err(|_| HttpError::RuntimeError)?;
Expand All @@ -141,18 +211,20 @@ async fn response_from_reqwest(res: reqwest::Response) -> Result<Response, HttpE
})
}

fn request_headers(h: Headers) -> anyhow::Result<HeaderMap> {
let mut res = HeaderMap::new();
fn request_headers(h: Headers) -> anyhow::Result<reqwest::header::HeaderMap> {
let mut res = reqwest::header::HeaderMap::new();
for (k, v) in h {
res.insert(
http::header::HeaderName::try_from(k)?,
http::header::HeaderValue::try_from(v)?,
reqwest::header::HeaderName::try_from(k)?,
reqwest::header::HeaderValue::try_from(v)?,
);
}
Ok(res)
}

fn response_headers(h: &HeaderMap) -> anyhow::Result<Option<Vec<(String, String)>>> {
fn response_headers(
h: &reqwest::header::HeaderMap,
) -> anyhow::Result<Option<Vec<(String, String)>>> {
let mut res: Vec<(String, String)> = vec![];

for (k, v) in h {
Expand Down
2 changes: 2 additions & 0 deletions crates/outbound-http/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ mod host_impl;

#[cfg(feature = "runtime")]
pub use host_component::OutboundHttpComponent;
#[cfg(feature = "runtime")]
pub use host_impl::{HttpRequestHandler, HttpSelfDispatcher};

use spin_locked_app::MetadataKey;

Expand Down
75 changes: 47 additions & 28 deletions crates/trigger-http/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ use futures::TryFutureExt;
use http::{HeaderName, HeaderValue};
use http_body_util::BodyExt;
use hyper::{Request, Response};
use outbound_http::OutboundHttpComponent;
use spin_core::async_trait;
use spin_core::wasi_2023_10_18::exports::wasi::http::incoming_handler::IncomingHandler as IncomingHandler2023_10_18;
use spin_core::wasi_2023_11_10::exports::wasi::http::incoming_handler::IncomingHandler as IncomingHandler2023_11_10;
Expand All @@ -20,7 +19,15 @@ use tokio::{sync::oneshot, task};
use wasmtime_wasi_http::{proxy::Proxy, WasiHttpView};

#[derive(Clone)]
pub struct HttpHandlerExecutor;
pub struct HttpHandlerExecutor {
trigger: HttpTrigger,
}

impl HttpHandlerExecutor {
pub fn new(trigger: HttpTrigger) -> Self {
Self { trigger }
}
}

#[async_trait]
impl HttpExecutor for HttpHandlerExecutor {
Expand All @@ -43,7 +50,7 @@ impl HttpExecutor for HttpHandlerExecutor {
unreachable!()
};

set_http_origin_from_request(&mut store, engine, &req);
self.set_self_dispatcher(&mut store, engine);

let resp = match HandlerType::from_exports(instance.exports(&mut store)) {
Some(HandlerType::Wasi) => {
Expand All @@ -69,6 +76,18 @@ impl HttpExecutor for HttpHandlerExecutor {
}
}

#[async_trait]
impl outbound_http::HttpRequestHandler for HttpTrigger {
async fn handle(
&self,
req: http::Request<wasmtime_wasi_http::body::HyperIncomingBody>,
scheme: http::uri::Scheme,
addr: std::net::SocketAddr,
) -> anyhow::Result<http::Response<wasmtime_wasi_http::body::HyperIncomingBody>> {
self.handle(req, scheme, addr).await
}
}

impl HttpHandlerExecutor {
pub async fn execute_spin(
mut store: Store,
Expand Down Expand Up @@ -317,6 +336,31 @@ impl HttpHandlerExecutor {

Ok(())
}

fn set_self_dispatcher(&self, store: &mut Store, engine: &TriggerAppEngine<HttpTrigger>) {
if let Some(outbound_http_handle) = engine
.engine
.find_host_component_handle::<Arc<outbound_http::OutboundHttpComponent>>()
{
let outbound_http_data = store
.host_components_data()
.get_or_insert(outbound_http_handle);
let allowed_hosts = outbound_http_data.allowed_hosts.clone();

// The reason this uses a Box and the WASI one uses an Arc is that we don't want
// the `outbound_http` crate to depend on `http-trigger` so we have to put it through
// a trait. But TODO: try to unify these.
let http_handler = Box::new(self.trigger.clone());
outbound_http_data.self_dispatcher =
outbound_http::HttpSelfDispatcher::new(&Arc::new(http_handler));

let arc_http_handler = Arc::new(self.trigger.clone());
store.as_mut().data_mut().as_mut().self_dispatcher =
crate::WasiHttpSelfDispatcher::new(arc_http_handler);
store.as_mut().data_mut().as_mut().allowed_hosts =
allowed_hosts;
}
}
}

/// Whether this handler uses the custom Spin http handler interface for wasi-http
Expand Down Expand Up @@ -345,31 +389,6 @@ impl HandlerType {
}
}

fn set_http_origin_from_request(
store: &mut Store,
engine: &TriggerAppEngine<HttpTrigger>,
req: &Request<Body>,
) {
if let Some(authority) = req.uri().authority() {
if let Some(scheme) = req.uri().scheme_str() {
let origin = format!("{}://{}", scheme, authority);
if let Some(outbound_http_handle) = engine
.engine
.find_host_component_handle::<Arc<OutboundHttpComponent>>()
{
let outbound_http_data = store
.host_components_data()
.get_or_insert(outbound_http_handle);

outbound_http_data.origin = origin.clone();
store.as_mut().data_mut().as_mut().allowed_hosts =
outbound_http_data.allowed_hosts.clone();
}
store.as_mut().data_mut().as_mut().origin = Some(origin);
}
}
}

fn contextualise_err(e: anyhow::Error) -> anyhow::Error {
if e.to_string()
.contains("failed to find function export `canonical_abi_free`")
Expand Down
Loading

0 comments on commit 200baf0

Please sign in to comment.