featherbit/plugins/native/
openwhisk.rs1use async_trait::async_trait;
21use base64::engine::general_purpose::STANDARD as BASE64;
22use base64::Engine;
23use bytes::Bytes;
24use std::collections::HashMap;
25use std::sync::Arc;
26use std::time::Duration;
27
28use crate::context::{Context, GatewayError};
29use crate::outbound::{OutboundClient, OutboundRequest, OutboundResponse};
30use crate::plugins::resources::PluginResources;
31use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
32
33use super::faas;
34
35pub struct OpenWhiskPlugin {
37 api_host: String,
38 authorization: String,
40 namespace: String,
41 package: Option<String>,
42 action: String,
43 result: bool,
44 ssl_verify: bool,
45 timeout: Duration,
46 timeout_ms: u64,
48 client: Arc<OutboundClient>,
49}
50
51impl OpenWhiskPlugin {
52 pub fn from_config(
80 config: &HashMap<String, serde_json::Value>,
81 resources: &Arc<PluginResources>,
82 ) -> Result<Self, String> {
83 let api_host = config
84 .get("api_host")
85 .and_then(|v| v.as_str())
86 .filter(|s| !s.is_empty())
87 .ok_or_else(|| "openwhisk plugin requires 'api_host'".to_string())?
88 .trim_end_matches('/')
89 .to_string();
90
91 let service_token = config
92 .get("service_token")
93 .and_then(|v| v.as_str())
94 .filter(|s| !s.is_empty())
95 .ok_or_else(|| "openwhisk plugin requires 'service_token'".to_string())?;
96 let authorization = format!("Basic {}", BASE64.encode(service_token));
97
98 let action = config
99 .get("action")
100 .and_then(|v| v.as_str())
101 .filter(|s| !s.is_empty())
102 .ok_or_else(|| "openwhisk plugin requires 'action'".to_string())?
103 .to_string();
104
105 let namespace = config
106 .get("namespace")
107 .and_then(|v| v.as_str())
108 .filter(|s| !s.is_empty())
109 .unwrap_or("_")
110 .to_string();
111
112 let package = config
113 .get("package")
114 .and_then(|v| v.as_str())
115 .filter(|s| !s.is_empty())
116 .map(String::from);
117
118 let result = config
119 .get("result")
120 .and_then(|v| v.as_bool())
121 .unwrap_or(true);
122
123 let ssl_verify = config
124 .get("ssl_verify")
125 .and_then(|v| v.as_bool())
126 .unwrap_or(true);
127
128 let timeout_ms = config
129 .get("timeout")
130 .and_then(|v| v.as_u64())
131 .unwrap_or(3000);
132
133 Ok(Self {
134 api_host,
135 authorization,
136 namespace,
137 package,
138 action,
139 result,
140 ssl_verify,
141 timeout: Duration::from_millis(timeout_ms),
142 timeout_ms,
143 client: resources.outbound.clone(),
144 })
145 }
146
147 fn endpoint(&self) -> String {
149 let package = self
150 .package
151 .as_ref()
152 .map(|p| format!("{}/", p))
153 .unwrap_or_default();
154 format!(
155 "{}/api/v1/namespaces/{}/actions/{}{}?blocking=true&result={}&timeout={}",
156 self.api_host, self.namespace, package, self.action, self.result, self.timeout_ms
157 )
158 }
159
160 fn build_request(&self, ctx: &Context) -> OutboundRequest {
163 let headers = vec![
164 ("authorization".to_string(), self.authorization.clone()),
165 ("content-type".to_string(), "application/json".to_string()),
166 ];
167 OutboundRequest {
168 method: http::Method::POST,
169 url: self.endpoint(),
170 headers,
171 body: ctx.request.body.clone(),
172 timeout: self.timeout,
173 ssl_verify: self.ssl_verify,
174 tls: None,
175 }
176 }
177}
178
179struct MappedResponse {
181 status: u16,
182 headers: HashMap<String, Vec<String>>,
183 body: Bytes,
184}
185
186fn map_response(status: u16, raw: &Bytes) -> Result<MappedResponse, String> {
194 if raw.is_empty() {
195 return Ok(MappedResponse {
196 status,
197 headers: HashMap::new(),
198 body: raw.clone(),
199 });
200 }
201
202 let envelope: serde_json::Value = serde_json::from_slice(raw)
203 .map_err(|e| format!("failed to parse openwhisk response: {}", e))?;
204
205 let mut headers: HashMap<String, Vec<String>> = HashMap::new();
206 if let Some(hdrs) = envelope.get("headers").and_then(|v| v.as_object()) {
207 for (name, value) in hdrs {
208 let value = match value {
209 serde_json::Value::String(s) => s.clone(),
210 other => other.to_string(),
211 };
212 headers
213 .entry(name.to_ascii_lowercase())
214 .or_default()
215 .push(value);
216 }
217 }
218
219 let code = envelope
220 .get("statusCode")
221 .and_then(|v| v.as_u64())
222 .map(|c| c as u16)
223 .unwrap_or(status);
224
225 let body = match envelope.get("body") {
226 Some(serde_json::Value::String(s)) => Bytes::from(s.clone().into_bytes()),
227 Some(other) => Bytes::from(other.to_string().into_bytes()),
228 None => raw.clone(),
229 };
230
231 Ok(MappedResponse {
232 status: code,
233 headers,
234 body,
235 })
236}
237
238#[async_trait]
239impl Plugin for OpenWhiskPlugin {
240 fn plugin_type(&self) -> &str {
241 "openwhisk"
242 }
243
244 async fn execute(
245 &self,
246 mut ctx: Context,
247 _named_inputs: &HashMap<String, serde_json::Value>,
248 ) -> PluginResult {
249 let request = self.build_request(&ctx);
250
251 let response: OutboundResponse = match self.client.request(request).await {
252 Ok(resp) => resp,
253 Err(e) => {
254 let (status, message) = faas::classify_error("openwhisk", &e);
255 return Err(reject(ctx, status, message));
256 }
257 };
258
259 match map_response(response.status, &response.body) {
260 Ok(mapped) => {
261 ctx.response.status_code = mapped.status;
262 ctx.response.headers = mapped.headers;
263 ctx.response.body = mapped.body;
264 Ok(PluginOutput {
265 context: ctx,
266 named_outputs: HashMap::new(),
267 })
268 }
269 Err(message) => Err(reject(ctx, 503, message)),
270 }
271 }
272}
273
274fn reject(mut ctx: Context, status: u16, message: String) -> PluginExecutionError {
276 ctx.response.status_code = status;
277 PluginExecutionError {
278 context: ctx,
279 error: GatewayError {
280 node_id: String::new(),
281 code: "OPENWHISK_CALLOUT_ERROR".to_string(),
282 message,
283 metadata: HashMap::new(),
284 },
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
292
293 fn ctx() -> Context {
294 Context {
295 request: GatewayRequest {
296 method: "POST".to_string(),
297 path: "/orig".to_string(),
298 host: "gw".to_string(),
299 scheme: "http".to_string(),
300 headers: HashMap::new(),
301 query_params: HashMap::new(),
302 body: Bytes::from_static(b"{\"name\":\"x\"}"),
303 remote_addr: "1.2.3.4:5".to_string(),
304 protocol: Protocol::Http1,
305 },
306 response: GatewayResponse {
307 status_code: 0,
308 headers: HashMap::new(),
309 body: Bytes::new(),
310 },
311 message: HashMap::new(),
312 errors: Vec::new(),
313 }
314 }
315
316 fn plugin(config: serde_json::Value) -> OpenWhiskPlugin {
317 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
318 OpenWhiskPlugin::from_config(&map, &PluginResources::empty()).unwrap()
319 }
320
321 fn base_config() -> serde_json::Value {
322 serde_json::json!({
323 "api_host": "https://ow.example.com",
324 "service_token": "user:pass",
325 "namespace": "guest",
326 "action": "hello"
327 })
328 }
329
330 #[test]
331 fn test_requires_api_host_and_token_and_action() {
332 assert!(OpenWhiskPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
333 let mut cfg: HashMap<String, serde_json::Value> =
334 serde_json::from_value(serde_json::json!({ "api_host": "https://x" })).unwrap();
335 assert!(OpenWhiskPlugin::from_config(&cfg, &PluginResources::empty()).is_err());
336 cfg.insert("service_token".to_string(), serde_json::json!("t"));
337 assert!(OpenWhiskPlugin::from_config(&cfg, &PluginResources::empty()).is_err());
339 }
340
341 #[test]
342 fn test_endpoint_and_auth() {
343 let p = plugin(base_config());
344 let req = p.build_request(&ctx());
345 assert_eq!(
346 req.url,
347 "https://ow.example.com/api/v1/namespaces/guest/actions/hello?blocking=true&result=true&timeout=3000"
348 );
349 let authz = req
350 .headers
351 .iter()
352 .find(|(k, _)| k == "authorization")
353 .map(|(_, v)| v.clone())
354 .unwrap();
355 assert_eq!(authz, "Basic dXNlcjpwYXNz");
357 assert_eq!(req.body, Bytes::from_static(b"{\"name\":\"x\"}"));
359 }
360
361 #[test]
362 fn test_endpoint_with_package_and_result_false() {
363 let mut cfg = base_config();
364 cfg["package"] = serde_json::json!("mypkg");
365 cfg["result"] = serde_json::json!(false);
366 let p = plugin(cfg);
367 assert_eq!(
368 p.endpoint(),
369 "https://ow.example.com/api/v1/namespaces/guest/actions/mypkg/hello?blocking=true&result=false&timeout=3000"
370 );
371 }
372
373 #[test]
374 fn test_map_response_status_code_and_headers_and_body() {
375 let raw = Bytes::from_static(
376 br#"{"statusCode":201,"headers":{"Content-Type":"application/json"},"body":"hi"}"#,
377 );
378 let mapped = map_response(200, &raw).unwrap();
379 assert_eq!(mapped.status, 201);
380 assert_eq!(mapped.body, Bytes::from_static(b"hi"));
381 assert_eq!(
382 mapped.headers.get("content-type"),
383 Some(&vec!["application/json".to_string()])
384 );
385 }
386
387 #[test]
388 fn test_map_response_falls_back_to_transport_status() {
389 let raw = Bytes::from_static(br#"{"greeting":"hello"}"#);
390 let mapped = map_response(200, &raw).unwrap();
391 assert_eq!(mapped.status, 200);
392 assert_eq!(mapped.body, raw);
394 }
395
396 #[test]
397 fn test_map_response_empty_body_passthrough() {
398 let mapped = map_response(204, &Bytes::new()).unwrap();
399 assert_eq!(mapped.status, 204);
400 assert!(mapped.body.is_empty());
401 }
402
403 #[test]
404 fn test_map_response_invalid_json_errors() {
405 assert!(map_response(200, &Bytes::from_static(b"not json")).is_err());
406 }
407
408 #[tokio::test]
409 async fn test_callout_failure_routes_error() {
410 let mut cfg = base_config();
411 cfg["api_host"] = serde_json::json!("http://127.0.0.1:1");
412 cfg["timeout"] = serde_json::json!(200);
413 let p = plugin(cfg);
414 let err = p.execute(ctx(), &HashMap::new()).await.unwrap_err();
415 assert_eq!(err.error.code, "OPENWHISK_CALLOUT_ERROR");
416 assert!(err.context.response.status_code >= 502);
417 }
418}