featherbit/plugins/native/
azure_functions.rs1use 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
32pub 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 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 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 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
140fn 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
179fn 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 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 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}