featherbit/plugins/native/
openwhisk.rs1use async_trait::async_trait;
31use base64::engine::general_purpose::STANDARD as BASE64;
32use base64::Engine;
33use bytes::Bytes;
34use std::collections::HashMap;
35use std::sync::Arc;
36use std::time::Duration;
37
38use crate::context::{Context, GatewayError};
39use crate::outbound::{OutboundClient, OutboundRequest, OutboundResponse};
40use crate::plugins::resources::PluginResources;
41use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
42use crate::vars::template::Template;
43
44use super::faas;
45
46pub struct OpenWhiskPlugin {
48 api_host: Template,
59 authorization: String,
62 namespace: Template,
67 package: Option<Template>,
70 action: Template,
73 result: bool,
74 ssl_verify: bool,
75 timeout: Duration,
76 timeout_ms: u64,
78 client: Arc<OutboundClient>,
79}
80
81impl OpenWhiskPlugin {
82 pub fn from_config(
116 config: &HashMap<String, serde_json::Value>,
117 resources: &Arc<PluginResources>,
118 ) -> Result<Self, String> {
119 let api_host = config
120 .get("api_host")
121 .and_then(|v| v.as_str())
122 .filter(|s| !s.is_empty())
123 .ok_or_else(|| "openwhisk plugin requires 'api_host'".to_string())?
124 .to_string();
125 let api_host = Template::parse(&api_host).0;
130
131 let service_token = config
132 .get("service_token")
133 .and_then(|v| v.as_str())
134 .filter(|s| !s.is_empty())
135 .ok_or_else(|| "openwhisk plugin requires 'service_token'".to_string())?;
136 let authorization = format!("Basic {}", BASE64.encode(service_token));
137
138 let action = config
139 .get("action")
140 .and_then(|v| v.as_str())
141 .filter(|s| !s.is_empty())
142 .ok_or_else(|| "openwhisk plugin requires 'action'".to_string())?
143 .to_string();
144 let action = Template::parse(&action).0;
145
146 let namespace = config
147 .get("namespace")
148 .and_then(|v| v.as_str())
149 .filter(|s| !s.is_empty())
150 .unwrap_or("_")
151 .to_string();
152 let namespace = Template::parse(&namespace).0;
153
154 let package = config
155 .get("package")
156 .and_then(|v| v.as_str())
157 .filter(|s| !s.is_empty())
158 .map(|s| Template::parse(s).0);
159
160 let result = config
161 .get("result")
162 .and_then(|v| v.as_bool())
163 .unwrap_or(true);
164
165 let ssl_verify = config
166 .get("ssl_verify")
167 .and_then(|v| v.as_bool())
168 .unwrap_or(true);
169
170 let timeout_ms = config
171 .get("timeout")
172 .and_then(|v| v.as_u64())
173 .unwrap_or(3000);
174
175 Ok(Self {
176 api_host,
177 authorization,
178 namespace,
179 package,
180 action,
181 result,
182 ssl_verify,
183 timeout: Duration::from_millis(timeout_ms),
184 timeout_ms,
185 client: resources.outbound.clone(),
186 })
187 }
188
189 fn endpoint(&self, ctx: &Context) -> String {
201 let api_host = self.api_host.render(ctx);
202 let api_host = api_host.trim_end_matches('/');
203 let namespace = encode_path_segment(&self.namespace.render(ctx));
204 let action = encode_path_segment(&self.action.render(ctx));
205 let package = self
206 .package
207 .as_ref()
208 .map(|p| format!("{}/", encode_path_segment(&p.render(ctx))))
209 .unwrap_or_default();
210 format!(
211 "{}/api/v1/namespaces/{}/actions/{}{}?blocking=true&result={}&timeout={}",
212 api_host, namespace, package, action, self.result, self.timeout_ms
213 )
214 }
215
216 fn build_request(&self, ctx: &Context) -> OutboundRequest {
219 let headers = vec![
220 ("authorization".to_string(), self.authorization.clone()),
221 ("content-type".to_string(), "application/json".to_string()),
222 ];
223 OutboundRequest {
224 method: http::Method::POST,
225 url: self.endpoint(ctx),
226 headers,
227 body: ctx.request.body.clone(),
228 timeout: self.timeout,
229 ssl_verify: self.ssl_verify,
230 tls: None,
231 }
232 }
233}
234
235fn encode_path_segment(s: &str) -> String {
248 let mut out = String::with_capacity(s.len());
249 for b in s.bytes() {
250 match b {
251 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
252 out.push(b as char)
253 }
254 _ => out.push_str(&format!("%{:02X}", b)),
255 }
256 }
257 out
258}
259
260struct MappedResponse {
262 status: u16,
263 headers: HashMap<String, Vec<String>>,
264 body: Bytes,
265}
266
267fn map_response(status: u16, raw: &Bytes) -> Result<MappedResponse, String> {
275 if raw.is_empty() {
276 return Ok(MappedResponse {
277 status,
278 headers: HashMap::new(),
279 body: raw.clone(),
280 });
281 }
282
283 let envelope: serde_json::Value = serde_json::from_slice(raw)
284 .map_err(|e| format!("failed to parse openwhisk response: {}", e))?;
285
286 let mut headers: HashMap<String, Vec<String>> = HashMap::new();
287 if let Some(hdrs) = envelope.get("headers").and_then(|v| v.as_object()) {
288 for (name, value) in hdrs {
289 let value = match value {
290 serde_json::Value::String(s) => s.clone(),
291 other => other.to_string(),
292 };
293 headers
294 .entry(name.to_ascii_lowercase())
295 .or_default()
296 .push(value);
297 }
298 }
299
300 let code = envelope
301 .get("statusCode")
302 .and_then(|v| v.as_u64())
303 .map(|c| c as u16)
304 .unwrap_or(status);
305
306 let body = match envelope.get("body") {
307 Some(serde_json::Value::String(s)) => Bytes::from(s.clone().into_bytes()),
308 Some(other) => Bytes::from(other.to_string().into_bytes()),
309 None => raw.clone(),
310 };
311
312 Ok(MappedResponse {
313 status: code,
314 headers,
315 body,
316 })
317}
318
319#[async_trait]
320impl Plugin for OpenWhiskPlugin {
321 fn plugin_type(&self) -> &str {
322 "openwhisk"
323 }
324
325 async fn execute(&self, mut ctx: Context) -> PluginResult {
326 let request = self.build_request(&ctx);
327
328 let response: OutboundResponse = match self.client.request(request).await {
329 Ok(resp) => resp,
330 Err(e) => {
331 let (status, message) = faas::classify_error("openwhisk", &e);
332 return Err(reject(ctx, status, message));
333 }
334 };
335
336 match map_response(response.status, &response.body) {
337 Ok(mapped) => {
338 ctx.response.status_code = mapped.status;
339 ctx.response.headers = mapped.headers;
340 ctx.response.body = mapped.body;
341 Ok(PluginOutput::success(ctx))
342 }
343 Err(message) => Err(reject(ctx, 503, message)),
344 }
345 }
346}
347
348fn reject(mut ctx: Context, status: u16, message: String) -> PluginExecutionError {
350 ctx.response.status_code = status;
351 PluginExecutionError {
352 context: ctx,
353 error: GatewayError {
354 node_id: String::new(),
355 code: "OPENWHISK_CALLOUT_ERROR".to_string(),
356 message,
357 metadata: HashMap::new(),
358 },
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
366
367 fn ctx() -> Context {
368 Context {
369 request: GatewayRequest {
370 method: "POST".to_string(),
371 path: "/orig".to_string(),
372 host: "gw".to_string(),
373 scheme: "http".to_string(),
374 headers: HashMap::new(),
375 query_params: HashMap::new(),
376 body: Bytes::from_static(b"{\"name\":\"x\"}"),
377 remote_addr: "1.2.3.4:5".to_string(),
378 protocol: Protocol::Http1,
379 },
380 response: GatewayResponse {
381 status_code: 0,
382 headers: HashMap::new(),
383 body: Bytes::new(),
384 stream: None,
385 },
386 message: HashMap::new(),
387 errors: Vec::new(),
388 }
389 }
390
391 fn plugin(config: serde_json::Value) -> OpenWhiskPlugin {
392 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
393 OpenWhiskPlugin::from_config(&map, &PluginResources::empty()).unwrap()
394 }
395
396 fn base_config() -> serde_json::Value {
397 serde_json::json!({
398 "api_host": "https://ow.example.com",
399 "service_token": "user:pass",
400 "namespace": "guest",
401 "action": "hello"
402 })
403 }
404
405 #[test]
406 fn test_requires_api_host_and_token_and_action() {
407 assert!(OpenWhiskPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
408 let mut cfg: HashMap<String, serde_json::Value> =
409 serde_json::from_value(serde_json::json!({ "api_host": "https://x" })).unwrap();
410 assert!(OpenWhiskPlugin::from_config(&cfg, &PluginResources::empty()).is_err());
411 cfg.insert("service_token".to_string(), serde_json::json!("t"));
412 assert!(OpenWhiskPlugin::from_config(&cfg, &PluginResources::empty()).is_err());
414 }
415
416 #[test]
417 fn test_endpoint_and_auth() {
418 let p = plugin(base_config());
419 let req = p.build_request(&ctx());
420 assert_eq!(
421 req.url,
422 "https://ow.example.com/api/v1/namespaces/guest/actions/hello?blocking=true&result=true&timeout=3000"
423 );
424 let authz = req
425 .headers
426 .iter()
427 .find(|(k, _)| k == "authorization")
428 .map(|(_, v)| v.clone())
429 .unwrap();
430 assert_eq!(authz, "Basic dXNlcjpwYXNz");
432 assert_eq!(req.body, Bytes::from_static(b"{\"name\":\"x\"}"));
434 }
435
436 #[test]
437 fn test_endpoint_with_package_and_result_false() {
438 let mut cfg = base_config();
439 cfg["package"] = serde_json::json!("mypkg");
440 cfg["result"] = serde_json::json!(false);
441 let p = plugin(cfg);
442 assert_eq!(
443 p.endpoint(&ctx()),
444 "https://ow.example.com/api/v1/namespaces/guest/actions/mypkg/hello?blocking=true&result=false&timeout=3000"
445 );
446 }
447
448 #[test]
449 fn test_endpoint_renders_namespace_template_per_request() {
450 let mut cfg = base_config();
455 cfg["namespace"] = serde_json::json!("tenant-{{request.headers.x-tenant}}");
456 let p = plugin(cfg);
457
458 let mut request_ctx = ctx();
459 request_ctx
460 .request
461 .headers
462 .insert("x-tenant".to_string(), vec!["acme".to_string()]);
463 assert_eq!(
464 p.endpoint(&request_ctx),
465 "https://ow.example.com/api/v1/namespaces/tenant-acme/actions/hello?blocking=true&result=true&timeout=3000"
466 );
467
468 let mut other_ctx = ctx();
470 other_ctx
471 .request
472 .headers
473 .insert("x-tenant".to_string(), vec!["globex".to_string()]);
474 assert_eq!(
475 p.endpoint(&other_ctx),
476 "https://ow.example.com/api/v1/namespaces/tenant-globex/actions/hello?blocking=true&result=true&timeout=3000"
477 );
478 }
479
480 #[test]
481 fn test_endpoint_literal_namespace_is_byte_identical() {
482 let p = plugin(base_config());
486 let expected =
487 "https://ow.example.com/api/v1/namespaces/guest/actions/hello?blocking=true&result=true&timeout=3000";
488 assert_eq!(p.endpoint(&ctx()), expected);
489
490 let mut other_ctx = ctx();
491 other_ctx.request.host = "totally-different-host".to_string();
492 assert_eq!(p.endpoint(&other_ctx), expected);
493 }
494
495 #[test]
496 fn test_encode_path_segment_passes_literal_values_byte_identical() {
497 assert_eq!(encode_path_segment("guest"), "guest");
500 assert_eq!(encode_path_segment("my_pkg"), "my_pkg");
501 assert_eq!(encode_path_segment("my-pkg.v2"), "my-pkg.v2");
502 assert_eq!(encode_path_segment("_-~.Az09"), "_-~.Az09");
503 }
504
505 #[test]
506 fn test_encode_path_segment_escapes_reserved_bytes() {
507 assert_eq!(
508 encode_path_segment("foo/actions/bar"),
509 "foo%2Factions%2Fbar"
510 );
511 assert_eq!(encode_path_segment("hello?x=1"), "hello%3Fx%3D1");
512 assert_eq!(encode_path_segment("a&b"), "a%26b");
513 assert_eq!(encode_path_segment(" "), "%20");
514 }
515
516 #[test]
517 fn test_endpoint_namespace_slash_cannot_break_out_of_path_segment() {
518 let mut cfg = base_config();
521 cfg["namespace"] = serde_json::json!("{{request.headers.x-tenant}}");
522 let p = plugin(cfg);
523
524 let mut request_ctx = ctx();
525 request_ctx
526 .request
527 .headers
528 .insert("x-tenant".to_string(), vec!["foo/actions/bar".to_string()]);
529 assert_eq!(
530 p.endpoint(&request_ctx),
531 "https://ow.example.com/api/v1/namespaces/foo%2Factions%2Fbar/actions/hello?blocking=true&result=true&timeout=3000"
532 );
533 }
534
535 #[test]
536 fn test_endpoint_action_question_mark_cannot_inject_query_params() {
537 let mut cfg = base_config();
541 cfg["action"] = serde_json::json!("{{request.headers.x-action}}");
542 let p = plugin(cfg);
543
544 let mut request_ctx = ctx();
545 request_ctx
546 .request
547 .headers
548 .insert("x-action".to_string(), vec!["hello?x=1".to_string()]);
549 let endpoint = p.endpoint(&request_ctx);
550 assert_eq!(
551 endpoint,
552 "https://ow.example.com/api/v1/namespaces/guest/actions/hello%3Fx%3D1?blocking=true&result=true&timeout=3000"
553 );
554 assert_eq!(endpoint.matches('?').count(), 1);
556 }
557
558 #[test]
559 fn test_map_response_status_code_and_headers_and_body() {
560 let raw = Bytes::from_static(
561 br#"{"statusCode":201,"headers":{"Content-Type":"application/json"},"body":"hi"}"#,
562 );
563 let mapped = map_response(200, &raw).unwrap();
564 assert_eq!(mapped.status, 201);
565 assert_eq!(mapped.body, Bytes::from_static(b"hi"));
566 assert_eq!(
567 mapped.headers.get("content-type"),
568 Some(&vec!["application/json".to_string()])
569 );
570 }
571
572 #[test]
573 fn test_map_response_falls_back_to_transport_status() {
574 let raw = Bytes::from_static(br#"{"greeting":"hello"}"#);
575 let mapped = map_response(200, &raw).unwrap();
576 assert_eq!(mapped.status, 200);
577 assert_eq!(mapped.body, raw);
579 }
580
581 #[test]
582 fn test_map_response_empty_body_passthrough() {
583 let mapped = map_response(204, &Bytes::new()).unwrap();
584 assert_eq!(mapped.status, 204);
585 assert!(mapped.body.is_empty());
586 }
587
588 #[test]
589 fn test_map_response_invalid_json_errors() {
590 assert!(map_response(200, &Bytes::from_static(b"not json")).is_err());
591 }
592
593 #[tokio::test]
594 async fn test_callout_failure_routes_error() {
595 let mut cfg = base_config();
596 cfg["api_host"] = serde_json::json!("http://127.0.0.1:1");
597 cfg["timeout"] = serde_json::json!(200);
598 let p = plugin(cfg);
599 let err = p.execute(ctx()).await.unwrap_err();
600 assert_eq!(err.error.code, "OPENWHISK_CALLOUT_ERROR");
601 assert!(err.context.response.status_code >= 502);
602 }
603}