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};
29
30use super::faas;
31
32/// Forwards the request to an Azure Function and maps its reply into
33/// `Context.response`.
34pub struct AzureFunctionsPlugin {
35    function_uri: String,
36    apikey: Option<String>,
37    clientid: Option<String>,
38    ssl_verify: bool,
39    timeout: Duration,
40    client: Arc<OutboundClient>,
41}
42
43impl AzureFunctionsPlugin {
44    /// Builds the plugin from node config.
45    ///
46    /// Accepted keys:
47    /// - `function_uri` (string, **required**): the Azure Function URL. A
48    ///   missing/empty value is a config-load error.
49    /// - `authorization` (object, optional):
50    ///   - `apikey` (string) — sent as the `x-functions-key` header.
51    ///   - `clientid` (string) — sent as the `x-functions-clientid` header.
52    /// - `ssl_verify` (bool, default `true`): verify TLS certificates.
53    /// - `timeout` (integer ms, default `3000`): whole-call deadline.
54    ///
55    /// ```yaml
56    /// type: azure-functions
57    /// config:
58    ///   function_uri: https://app.azurewebsites.net/api/HttpTrigger
59    ///   authorization:
60    ///     apikey: ${AZURE_FUNCTION_KEY}
61    ///   ssl_verify: true
62    ///   timeout: 3000
63    /// ```
64    pub fn from_config(
65        config: &HashMap<String, serde_json::Value>,
66        resources: &Arc<PluginResources>,
67    ) -> Result<Self, String> {
68        let function_uri = config
69            .get("function_uri")
70            .and_then(|v| v.as_str())
71            .filter(|s| !s.is_empty())
72            .ok_or_else(|| "azure-functions plugin requires 'function_uri'".to_string())?
73            .to_string();
74
75        let authz = config.get("authorization").and_then(|v| v.as_object());
76        let apikey = authz
77            .and_then(|a| a.get("apikey"))
78            .and_then(|v| v.as_str())
79            .filter(|s| !s.is_empty())
80            .map(String::from);
81        let clientid = authz
82            .and_then(|a| a.get("clientid"))
83            .and_then(|v| v.as_str())
84            .filter(|s| !s.is_empty())
85            .map(String::from);
86
87        let ssl_verify = config
88            .get("ssl_verify")
89            .and_then(|v| v.as_bool())
90            .unwrap_or(true);
91
92        let timeout = Duration::from_millis(
93            config
94                .get("timeout")
95                .and_then(|v| v.as_u64())
96                .unwrap_or(3000),
97        );
98
99        Ok(Self {
100            function_uri,
101            apikey,
102            clientid,
103            ssl_verify,
104            timeout,
105            client: resources.outbound.clone(),
106        })
107    }
108
109    /// Builds the outbound request: the client's method/body/query forwarded to
110    /// `function_uri` plus the Azure function key headers.
111    fn build_request(&self, ctx: &Context) -> Result<OutboundRequest, String> {
112        let (mut headers, url, method) = faas::forward_parts(&self.function_uri, ctx)?;
113        let client_has = |name: &str| {
114            ctx.request
115                .headers
116                .keys()
117                .any(|k| k.eq_ignore_ascii_case(name))
118        };
119        // Only set the key headers when the client did not already send them.
120        if !client_has("x-functions-key") && !client_has("x-functions-clientid") {
121            if let Some(apikey) = &self.apikey {
122                headers.push(("x-functions-key".to_string(), apikey.clone()));
123            }
124            if let Some(clientid) = &self.clientid {
125                headers.push(("x-functions-clientid".to_string(), clientid.clone()));
126            }
127        }
128        Ok(OutboundRequest {
129            method,
130            url,
131            headers,
132            body: ctx.request.body.clone(),
133            timeout: self.timeout,
134            ssl_verify: self.ssl_verify,
135            tls: None,
136        })
137    }
138}
139
140/// Copies the FaaS reply into `Context.response`.
141fn apply_response(ctx: &mut Context, response: OutboundResponse) {
142    ctx.response.status_code = response.status;
143    ctx.response.headers = response.headers;
144    ctx.response.body = response.body;
145}
146
147#[async_trait]
148impl Plugin for AzureFunctionsPlugin {
149    fn plugin_type(&self) -> &str {
150        "azure-functions"
151    }
152
153    async fn execute(
154        &self,
155        mut ctx: Context,
156        _named_inputs: &HashMap<String, serde_json::Value>,
157    ) -> PluginResult {
158        let request = match self.build_request(&ctx) {
159            Ok(req) => req,
160            Err(message) => return Err(reject(ctx, 502, message)),
161        };
162
163        match self.client.request(request).await {
164            Ok(response) => {
165                apply_response(&mut ctx, response);
166                Ok(PluginOutput {
167                    context: ctx,
168                    named_outputs: HashMap::new(),
169                })
170            }
171            Err(e) => {
172                let (status, message) = faas::classify_error("azure-functions", &e);
173                Err(reject(ctx, status, message))
174            }
175        }
176    }
177}
178
179/// Builds the `AZURE_FUNCTIONS_CALLOUT_ERROR` rejection carrying the context.
180fn reject(mut ctx: Context, status: u16, message: String) -> PluginExecutionError {
181    ctx.response.status_code = status;
182    PluginExecutionError {
183        context: ctx,
184        error: GatewayError {
185            node_id: String::new(),
186            code: "AZURE_FUNCTIONS_CALLOUT_ERROR".to_string(),
187            message,
188            metadata: HashMap::new(),
189        },
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
197    use bytes::Bytes;
198
199    fn ctx() -> Context {
200        Context {
201            request: GatewayRequest {
202                method: "POST".to_string(),
203                path: "/orig".to_string(),
204                host: "gw".to_string(),
205                scheme: "http".to_string(),
206                headers: HashMap::new(),
207                query_params: HashMap::new(),
208                body: Bytes::from_static(b"payload"),
209                remote_addr: "1.2.3.4:5".to_string(),
210                protocol: Protocol::Http1,
211            },
212            response: GatewayResponse {
213                status_code: 0,
214                headers: HashMap::new(),
215                body: Bytes::new(),
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_client_supplied_key_not_overwritten() {
257        let p = plugin(serde_json::json!({
258            "function_uri": "https://app.azurewebsites.net/api/Trigger",
259            "authorization": { "apikey": "K" }
260        }));
261        let mut c = ctx();
262        c.request.headers.insert(
263            "x-functions-key".to_string(),
264            vec!["client-key".to_string()],
265        );
266        let req = p.build_request(&c).unwrap();
267        let keys: Vec<&str> = req
268            .headers
269            .iter()
270            .filter(|(k, _)| k == "x-functions-key")
271            .map(|(_, v)| v.as_str())
272            .collect();
273        // The plugin must not append its own key on top of the client's.
274        assert_eq!(keys, vec!["client-key"]);
275    }
276
277    #[tokio::test]
278    async fn test_callout_failure_routes_error() {
279        let p = plugin(serde_json::json!({
280            "function_uri": "http://127.0.0.1:1/fn",
281            "timeout": 200
282        }));
283        let err = p.execute(ctx(), &HashMap::new()).await.unwrap_err();
284        assert_eq!(err.error.code, "AZURE_FUNCTIONS_CALLOUT_ERROR");
285        assert!(err.context.response.status_code >= 502);
286    }
287}