openzeppelin_relayer/api/routes/
plugin.rs

1//! This module defines the HTTP routes for plugin operations.
2//! It includes handlers for calling plugin methods.
3//! The routes are integrated with the Actix-web framework and interact with the plugin controller.
4use std::collections::HashMap;
5
6use crate::{
7    api::controllers::plugin,
8    models::{DefaultAppState, PaginationQuery, PluginCallRequest},
9};
10use actix_web::{get, post, web, HttpRequest, Responder};
11
12/// List plugins
13#[get("/plugins")]
14async fn list_plugins(
15    query: web::Query<PaginationQuery>,
16    data: web::ThinData<DefaultAppState>,
17) -> impl Responder {
18    plugin::list_plugins(query.into_inner(), data).await
19}
20
21/// Extracts HTTP headers from the request into a HashMap.
22fn extract_headers(http_req: &HttpRequest) -> HashMap<String, Vec<String>> {
23    let mut headers: HashMap<String, Vec<String>> = HashMap::new();
24    for (name, value) in http_req.headers().iter() {
25        if let Ok(value_str) = value.to_str() {
26            headers
27                .entry(name.as_str().to_string())
28                .or_default()
29                .push(value_str.to_string());
30        }
31    }
32    headers
33}
34
35/// Calls a plugin method.
36#[post("/plugins/{plugin_id}/call")]
37async fn plugin_call(
38    plugin_id: web::Path<String>,
39    http_req: HttpRequest,
40    req: web::Json<PluginCallRequest>,
41    data: web::ThinData<DefaultAppState>,
42) -> impl Responder {
43    let mut plugin_call_request = req.into_inner();
44    plugin_call_request.headers = Some(extract_headers(&http_req));
45    plugin::call_plugin(plugin_id.into_inner(), plugin_call_request, data).await
46}
47
48/// Initializes the routes for the plugins module.
49pub fn init(cfg: &mut web::ServiceConfig) {
50    // Register routes with literal segments before routes with path parameters
51    cfg.service(plugin_call); // /plugins/{plugin_id}/call
52    cfg.service(list_plugins); // /plugins
53}
54
55#[cfg(test)]
56mod tests {
57    use std::time::Duration;
58
59    use super::*;
60    use crate::{models::PluginModel, services::plugins::PluginCallResponse};
61    use actix_web::{test, App, HttpResponse};
62
63    async fn mock_plugin_call() -> impl Responder {
64        HttpResponse::Ok().json(PluginCallResponse {
65            result: serde_json::Value::Null,
66            metadata: None,
67        })
68    }
69
70    async fn mock_list_plugins() -> impl Responder {
71        HttpResponse::Ok().json(vec![
72            PluginModel {
73                id: "test-plugin".to_string(),
74                path: "test-path".to_string(),
75                timeout: Duration::from_secs(69),
76                emit_logs: false,
77                emit_traces: false,
78            },
79            PluginModel {
80                id: "test-plugin2".to_string(),
81                path: "test-path2".to_string(),
82                timeout: Duration::from_secs(69),
83                emit_logs: false,
84                emit_traces: false,
85            },
86        ])
87    }
88
89    #[actix_web::test]
90    async fn test_plugin_call() {
91        let app = test::init_service(
92            App::new()
93                .service(
94                    web::resource("/plugins/{plugin_id}/call")
95                        .route(web::post().to(mock_plugin_call)),
96                )
97                .configure(init),
98        )
99        .await;
100
101        let req = test::TestRequest::post()
102            .uri("/plugins/test-plugin/call")
103            .insert_header(("Content-Type", "application/json"))
104            .set_json(serde_json::json!({
105                "params": serde_json::Value::Null,
106            }))
107            .to_request();
108        let resp = test::call_service(&app, req).await;
109
110        assert!(resp.status().is_success());
111
112        let body = test::read_body(resp).await;
113        let plugin_call_response: PluginCallResponse = serde_json::from_slice(&body).unwrap();
114        assert!(plugin_call_response.result.is_null());
115    }
116
117    #[actix_web::test]
118    async fn test_list_plugins() {
119        let app = test::init_service(
120            App::new()
121                .service(web::resource("/plugins").route(web::get().to(mock_list_plugins)))
122                .configure(init),
123        )
124        .await;
125
126        let req = test::TestRequest::get().uri("/plugins").to_request();
127        let resp = test::call_service(&app, req).await;
128
129        assert!(resp.status().is_success());
130
131        let body = test::read_body(resp).await;
132        let plugin_call_response: Vec<PluginModel> = serde_json::from_slice(&body).unwrap();
133
134        assert_eq!(plugin_call_response.len(), 2);
135        assert_eq!(plugin_call_response[0].id, "test-plugin");
136        assert_eq!(plugin_call_response[0].path, "test-path");
137        assert_eq!(plugin_call_response[1].id, "test-plugin2");
138        assert_eq!(plugin_call_response[1].path, "test-path2");
139    }
140
141    #[actix_web::test]
142    async fn test_plugin_call_extracts_headers() {
143        // Test that custom headers are extracted and passed to the plugin
144        let app = test::init_service(
145            App::new()
146                .service(
147                    web::resource("/plugins/{plugin_id}/call")
148                        .route(web::post().to(mock_plugin_call)),
149                )
150                .configure(init),
151        )
152        .await;
153
154        let req = test::TestRequest::post()
155            .uri("/plugins/test-plugin/call")
156            .insert_header(("Content-Type", "application/json"))
157            .insert_header(("X-Custom-Header", "custom-value"))
158            .insert_header(("Authorization", "Bearer test-token"))
159            .insert_header(("X-Request-Id", "req-12345"))
160            // Add duplicate header to test multi-value
161            .insert_header(("Accept", "application/json"))
162            .set_json(serde_json::json!({
163                "params": {"test": "data"},
164            }))
165            .to_request();
166
167        let resp = test::call_service(&app, req).await;
168        assert!(resp.status().is_success());
169    }
170
171    #[actix_web::test]
172    async fn test_extract_headers_unit() {
173        // Unit test for extract_headers using TestRequest
174        use actix_web::test::TestRequest;
175
176        let req = TestRequest::default()
177            .insert_header(("X-Custom-Header", "value1"))
178            .insert_header(("Authorization", "Bearer token"))
179            .insert_header(("Content-Type", "application/json"))
180            .to_http_request();
181
182        let headers = extract_headers(&req);
183
184        assert_eq!(
185            headers.get("x-custom-header"),
186            Some(&vec!["value1".to_string()])
187        );
188        assert_eq!(
189            headers.get("authorization"),
190            Some(&vec!["Bearer token".to_string()])
191        );
192        assert_eq!(
193            headers.get("content-type"),
194            Some(&vec!["application/json".to_string()])
195        );
196    }
197
198    #[actix_web::test]
199    async fn test_extract_headers_multi_value() {
200        use actix_web::test::TestRequest;
201
202        // actix-web combines duplicate headers, but we can test the structure
203        let req = TestRequest::default()
204            .insert_header(("X-Values", "value1"))
205            .to_http_request();
206
207        let headers = extract_headers(&req);
208
209        // Verify structure is Vec<String>
210        let values = headers.get("x-values").unwrap();
211        assert_eq!(values.len(), 1);
212        assert_eq!(values[0], "value1");
213    }
214
215    #[actix_web::test]
216    async fn test_extract_headers_empty() {
217        use actix_web::test::TestRequest;
218
219        let req = TestRequest::default().to_http_request();
220        let headers = extract_headers(&req);
221
222        // Should return empty HashMap (no panic)
223        // Note: TestRequest may include default headers, so we just verify it doesn't panic
224        let _ = headers.len();
225    }
226}