Skip to main content

featherbit/plugins/native/
faas.rs

1//! Shared helpers for the serverless / function-as-a-service upstream plugins
2//! (`azure-functions`, `openfunction`, `openwhisk`).
3//!
4//! These plugins all invoke an external FaaS endpoint and return its reply as
5//! the gateway response. The helpers here centralize the request-forwarding
6//! shape (method, headers, query, target URL) and the outbound-error → status
7//! mapping so each plugin only adds its own auth header and response handling.
8
9use std::collections::HashMap;
10
11use crate::context::Context;
12use crate::outbound::OutboundError;
13
14/// The forwarded request parts for a FaaS callout: header list, target URL, and
15/// HTTP method.
16pub type ForwardParts = (Vec<(String, String)>, String, http::Method);
17
18/// Builds the forwarded request parts for a FaaS callout: the header list
19/// (client headers minus hop-by-hop, with `Host` overridden to the endpoint),
20/// the fully-qualified target URL (`function_uri` path plus the client's query
21/// string), and the client's HTTP method.
22///
23/// Returns an error string when `function_uri` cannot be parsed or has no host.
24pub fn forward_parts(function_uri: &str, ctx: &Context) -> Result<ForwardParts, String> {
25    let parsed: http::Uri = function_uri
26        .parse()
27        .map_err(|e| format!("invalid function_uri '{}': {}", function_uri, e))?;
28    let authority = parsed
29        .authority()
30        .map(|a| a.as_str().to_string())
31        .ok_or_else(|| format!("function_uri '{}' has no host", function_uri))?;
32    let scheme = parsed.scheme_str().unwrap_or("https");
33    let path = if parsed.path().is_empty() {
34        "/"
35    } else {
36        parsed.path()
37    };
38
39    let mut headers: Vec<(String, String)> = Vec::new();
40    for (name, values) in &ctx.request.headers {
41        let lname = name.to_ascii_lowercase();
42        if matches!(lname.as_str(), "host" | "connection" | "content-length") {
43            continue;
44        }
45        for value in values {
46            headers.push((lname.clone(), value.clone()));
47        }
48    }
49    headers.push(("host".to_string(), authority.clone()));
50
51    // Prefer the client's query string; fall back to any query on function_uri.
52    let query = query_string(&ctx.request.query_params);
53    let query = if query.is_empty() {
54        parsed.query().unwrap_or("").to_string()
55    } else {
56        query
57    };
58    let url = if query.is_empty() {
59        format!("{}://{}{}", scheme, authority, path)
60    } else {
61        format!("{}://{}{}?{}", scheme, authority, path, query)
62    };
63
64    let method: http::Method = ctx.request.method.parse().unwrap_or(http::Method::POST);
65
66    Ok((headers, url, method))
67}
68
69/// Encodes the request query parameters as a `&`-joined `name=value` string,
70/// sorted for stable output. Names and values are percent-encoded.
71fn query_string(query: &HashMap<String, Vec<String>>) -> String {
72    let mut pairs: Vec<(String, String)> = Vec::new();
73    for (name, values) in query {
74        for value in values {
75            pairs.push((uri_encode(name), uri_encode(value)));
76        }
77    }
78    pairs.sort();
79    pairs
80        .iter()
81        .map(|(k, v)| format!("{}={}", k, v))
82        .collect::<Vec<_>>()
83        .join("&")
84}
85
86/// Percent-encodes per RFC 3986, keeping the unreserved set intact.
87fn uri_encode(s: &str) -> String {
88    let mut out = String::with_capacity(s.len());
89    for b in s.bytes() {
90        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
91            out.push(b as char);
92        } else {
93            out.push_str(&format!("%{:02X}", b));
94        }
95    }
96    out
97}
98
99/// Maps an [`OutboundError`] to a client-facing `(status, message)` pair used
100/// by the FaaS plugins' error ports.
101pub fn classify_error(plugin: &str, err: &OutboundError) -> (u16, String) {
102    match err {
103        OutboundError::Timeout(d) => (504, format!("{} callout timed out after {:?}", plugin, d)),
104        OutboundError::InvalidRequest(m) => (502, format!("{} request build error: {}", plugin, m)),
105        OutboundError::Transport(m) => (503, format!("{} callout failed: {}", plugin, m)),
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
113    use bytes::Bytes;
114
115    fn ctx() -> Context {
116        Context {
117            request: GatewayRequest {
118                method: "POST".to_string(),
119                path: "/orig".to_string(),
120                host: "gw".to_string(),
121                scheme: "http".to_string(),
122                headers: HashMap::new(),
123                query_params: HashMap::new(),
124                body: Bytes::new(),
125                remote_addr: "1.2.3.4:5".to_string(),
126                protocol: Protocol::Http1,
127            },
128            response: GatewayResponse {
129                status_code: 0,
130                headers: HashMap::new(),
131                body: Bytes::new(),
132            },
133            message: HashMap::new(),
134            errors: Vec::new(),
135        }
136    }
137
138    #[test]
139    fn test_forward_parts_url_and_host() {
140        let mut c = ctx();
141        c.request
142            .headers
143            .insert("x-test".to_string(), vec!["v".to_string()]);
144        c.request
145            .query_params
146            .insert("b".to_string(), vec!["2".to_string()]);
147        c.request
148            .query_params
149            .insert("a".to_string(), vec!["1".to_string()]);
150        let (headers, url, method) = forward_parts("https://host.example.com/api/fn", &c).unwrap();
151        assert_eq!(url, "https://host.example.com/api/fn?a=1&b=2");
152        assert_eq!(method, http::Method::POST);
153        let get = |n: &str| {
154            headers
155                .iter()
156                .find(|(k, _)| k == n)
157                .map(|(_, v)| v.as_str())
158        };
159        assert_eq!(get("host"), Some("host.example.com"));
160        assert_eq!(get("x-test"), Some("v"));
161    }
162
163    #[test]
164    fn test_forward_parts_rejects_bad_uri() {
165        assert!(forward_parts("not a uri", &ctx()).is_err());
166        assert!(forward_parts("/no-host", &ctx()).is_err());
167    }
168}