Skip to main content

featherbit/plugins/native/
azure_functions.rs

1//! Azure Functions serverless-upstream plugin (`azure-functions`).
2//!
3//! Port of APISIX's `azure-functions` plugin (3.17). Forwards the request to an
4//! Azure Function endpoint and returns the function's reply as the gateway
5//! response — it **replaces the upstream**, so the node's `success` port should
6//! be wired straight to `client.in`.
7//!
8//! Authorization is via the Azure function key headers: `authorization.apikey`
9//! is sent as `x-functions-key` and `authorization.clientid` as
10//! `x-functions-clientid` (only when the client did not already supply them).
11//!
12//! On a callout failure the node rejects through its `error` port with
13//! `AZURE_FUNCTIONS_CALLOUT_ERROR` (a 502/503/504 depending on the failure).
14//!
15//! # Deviations from APISIX
16//!
17//! - The `plugin_metadata` master-key fallback is not implemented; keys come
18//!   from the node's `authorization` block only.
19
20use async_trait::async_trait;
21use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Duration;
24
25use crate::context::{Context, GatewayError};
26use crate::outbound::{OutboundClient, OutboundRequest, OutboundResponse};
27use crate::plugins::resources::PluginResources;
28use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
29use crate::vars::template::Template;
30
31use super::faas;
32
33/// Forwards the request to an Azure Function and maps its reply into
34/// `Context.response`.
35pub struct AzureFunctionsPlugin {
36    /// Supports `{{namespace.path}}` template references, rendered per
37    /// request.
38    function_uri: Template,
39    apikey: Option<String>,
40    clientid: Option<String>,
41    ssl_verify: bool,
42    timeout: Duration,
43    client: Arc<OutboundClient>,
44}
45
46impl AzureFunctionsPlugin {
47    /// Builds the plugin from node config.
48    ///
49    /// Accepted keys:
50    /// - `function_uri` (string, **required**): the Azure Function URL. A
51    ///   missing/empty value is a config-load error.
52    /// - `authorization` (object, optional):
53    ///   - `apikey` (string) — sent as the `x-functions-key` header.
54    ///   - `clientid` (string) — sent as the `x-functions-clientid` header.
55    /// - `ssl_verify` (bool, default `true`): verify TLS certificates.
56    /// - `timeout` (integer ms, default `3000`): whole-call deadline.
57    ///
58    /// ```yaml
59    /// type: azure-functions
60    /// config:
61    ///   function_uri: https://app.azurewebsites.net/api/HttpTrigger
62    ///   authorization:
63    ///     apikey: ${AZURE_FUNCTION_KEY}
64    ///   ssl_verify: true
65    ///   timeout: 3000
66    /// ```
67    pub fn from_config(
68        config: &HashMap<String, serde_json::Value>,
69        resources: &Arc<PluginResources>,
70    ) -> Result<Self, String> {
71        let function_uri = config
72            .get("function_uri")
73            .and_then(|v| v.as_str())
74            .filter(|s| !s.is_empty())
75            .ok_or_else(|| "azure-functions plugin requires 'function_uri'".to_string())?;
76        // Discard warnings here — the compile-time walk (a later task)
77        // reports well-formed-but-unknown references; execution must not.
78        let function_uri = Template::parse(function_uri).0;
79
80        let authz = config.get("authorization").and_then(|v| v.as_object());
81        let apikey = authz
82            .and_then(|a| a.get("apikey"))
83            .and_then(|v| v.as_str())
84            .filter(|s| !s.is_empty())
85            .map(String::from);
86        let clientid = authz
87            .and_then(|a| a.get("clientid"))
88            .and_then(|v| v.as_str())
89            .filter(|s| !s.is_empty())
90            .map(String::from);
91
92        let ssl_verify = config
93            .get("ssl_verify")
94            .and_then(|v| v.as_bool())
95            .unwrap_or(true);
96
97        let timeout = Duration::from_millis(
98            config
99                .get("timeout")
100                .and_then(|v| v.as_u64())
101                .unwrap_or(3000),
102        );
103
104        Ok(Self {
105            function_uri,
106            apikey,
107            clientid,
108            ssl_verify,
109            timeout,
110            client: resources.outbound.clone(),
111        })
112    }
113
114    /// Builds the outbound request: the client's method/body/query forwarded to
115    /// `function_uri` plus the Azure function key headers.
116    fn build_request(&self, ctx: &Context) -> Result<OutboundRequest, String> {
117        let function_uri = self.function_uri.render(ctx);
118        let (mut headers, url, method) = faas::forward_parts(&function_uri, ctx)?;
119        let client_has = |name: &str| {
120            ctx.request
121                .headers
122                .keys()
123                .any(|k| k.eq_ignore_ascii_case(name))
124        };
125        // Only set the key headers when the client did not already send them.
126        if !client_has("x-functions-key") && !client_has("x-functions-clientid") {
127            if let Some(apikey) = &self.apikey {
128                headers.push(("x-functions-key".to_string(), apikey.clone()));
129            }
130            if let Some(clientid) = &self.clientid {
131                headers.push(("x-functions-clientid".to_string(), clientid.clone()));
132            }
133        }
134        Ok(OutboundRequest {
135            method,
136            url,
137            headers,
138            body: ctx.request.body.clone(),
139            timeout: self.timeout,
140            ssl_verify: self.ssl_verify,
141            tls: None,
142        })
143    }
144}
145
146/// Copies the FaaS reply into `Context.response`.
147fn apply_response(ctx: &mut Context, response: OutboundResponse) {
148    ctx.response.status_code = response.status;
149    ctx.response.headers = response.headers;
150    ctx.response.body = response.body;
151}
152
153#[async_trait]
154impl Plugin for AzureFunctionsPlugin {
155    fn plugin_type(&self) -> &str {
156        "azure-functions"
157    }
158
159    async fn execute(&self, mut ctx: Context) -> PluginResult {
160        let request = match self.build_request(&ctx) {
161            Ok(req) => req,
162            Err(message) => return Err(reject(ctx, 502, message)),
163        };
164
165        match self.client.request(request).await {
166            Ok(response) => {
167                apply_response(&mut ctx, response);
168                Ok(PluginOutput::success(ctx))
169            }
170            Err(e) => {
171                let (status, message) = faas::classify_error("azure-functions", &e);
172                Err(reject(ctx, status, message))
173            }
174        }
175    }
176}
177
178/// Builds the `AZURE_FUNCTIONS_CALLOUT_ERROR` rejection carrying the context.
179fn reject(mut ctx: Context, status: u16, message: String) -> PluginExecutionError {
180    ctx.response.status_code = status;
181    PluginExecutionError {
182        context: ctx,
183        error: GatewayError {
184            node_id: String::new(),
185            code: "AZURE_FUNCTIONS_CALLOUT_ERROR".to_string(),
186            message,
187            metadata: HashMap::new(),
188        },
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
196    use bytes::Bytes;
197
198    fn ctx() -> Context {
199        Context {
200            request: GatewayRequest {
201                method: "POST".to_string(),
202                path: "/orig".to_string(),
203                host: "gw".to_string(),
204                scheme: "http".to_string(),
205                headers: HashMap::new(),
206                query_params: HashMap::new(),
207                body: Bytes::from_static(b"payload"),
208                remote_addr: "1.2.3.4:5".to_string(),
209                protocol: Protocol::Http1,
210            },
211            response: GatewayResponse {
212                status_code: 0,
213                headers: HashMap::new(),
214                body: Bytes::new(),
215                stream: None,
216            },
217            message: HashMap::new(),
218            errors: Vec::new(),
219        }
220    }
221
222    fn plugin(config: serde_json::Value) -> AzureFunctionsPlugin {
223        let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
224        AzureFunctionsPlugin::from_config(&map, &PluginResources::empty()).unwrap()
225    }
226
227    #[test]
228    fn test_requires_function_uri() {
229        assert!(
230            AzureFunctionsPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err()
231        );
232    }
233
234    #[test]
235    fn test_build_request_sets_key_headers_and_url() {
236        let p = plugin(serde_json::json!({
237            "function_uri": "https://app.azurewebsites.net/api/Trigger",
238            "authorization": { "apikey": "K", "clientid": "C" }
239        }));
240        let req = p.build_request(&ctx()).unwrap();
241        assert_eq!(req.url, "https://app.azurewebsites.net/api/Trigger");
242        assert_eq!(req.method, http::Method::POST);
243        let get = |n: &str| {
244            req.headers
245                .iter()
246                .find(|(k, _)| k == n)
247                .map(|(_, v)| v.as_str())
248        };
249        assert_eq!(get("x-functions-key"), Some("K"));
250        assert_eq!(get("x-functions-clientid"), Some("C"));
251        // Host overridden with the function endpoint.
252        assert_eq!(get("host"), Some("app.azurewebsites.net"));
253    }
254
255    #[test]
256    fn test_function_uri_renders_template() {
257        let p = plugin(serde_json::json!({
258            "function_uri": "https://app.azurewebsites.net/api/{{request.headers.x-fn}}"
259        }));
260        let mut c = ctx();
261        c.request
262            .headers
263            .insert("x-fn".to_string(), vec!["Trigger".to_string()]);
264        let req = p.build_request(&c).unwrap();
265        assert_eq!(req.url, "https://app.azurewebsites.net/api/Trigger");
266    }
267
268    #[test]
269    fn test_client_supplied_key_not_overwritten() {
270        let p = plugin(serde_json::json!({
271            "function_uri": "https://app.azurewebsites.net/api/Trigger",
272            "authorization": { "apikey": "K" }
273        }));
274        let mut c = ctx();
275        c.request.headers.insert(
276            "x-functions-key".to_string(),
277            vec!["client-key".to_string()],
278        );
279        let req = p.build_request(&c).unwrap();
280        let keys: Vec<&str> = req
281            .headers
282            .iter()
283            .filter(|(k, _)| k == "x-functions-key")
284            .map(|(_, v)| v.as_str())
285            .collect();
286        // The plugin must not append its own key on top of the client's.
287        assert_eq!(keys, vec!["client-key"]);
288    }
289
290    #[tokio::test]
291    async fn test_callout_failure_routes_error() {
292        let p = plugin(serde_json::json!({
293            "function_uri": "http://127.0.0.1:1/fn",
294            "timeout": 200
295        }));
296        let err = p.execute(ctx()).await.unwrap_err();
297        assert_eq!(err.error.code, "AZURE_FUNCTIONS_CALLOUT_ERROR");
298        assert!(err.context.response.status_code >= 502);
299    }
300}