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};
29use crate::vars::template::Template;
30
31use super::faas;
32
33pub struct AzureFunctionsPlugin {
36 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 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 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 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 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
146fn 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
178fn 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 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 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}