openzeppelin_relayer/api/controllers/
plugin.rs

1//! # Plugin Controller
2//!
3//! Handles HTTP endpoints for plugin operations including:
4//! - Calling plugins
5use crate::{
6    jobs::JobProducerTrait,
7    models::{
8        ApiError, ApiResponse, NetworkRepoModel, NotificationRepoModel, PaginationMeta,
9        PaginationQuery, PluginCallRequest, PluginModel, RelayerRepoModel, SignerRepoModel,
10        ThinDataAppState, TransactionRepoModel,
11    },
12    repositories::{
13        ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository,
14        Repository, TransactionCounterTrait, TransactionRepository,
15    },
16    services::plugins::{
17        PluginCallResponse, PluginCallResult, PluginHandlerResponse, PluginRunner, PluginService,
18        PluginServiceTrait,
19    },
20};
21use actix_web::{http::StatusCode, HttpResponse};
22use eyre::Result;
23use std::sync::Arc;
24
25/// Call plugin
26///
27/// # Arguments
28///
29/// * `plugin_id` - The ID of the plugin to call.
30/// * `plugin_call_request` - The plugin call request.
31/// * `state` - The application state containing the plugin repository.
32///
33/// # Returns
34///
35/// The result of the plugin call.
36pub async fn call_plugin<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
37    plugin_id: String,
38    plugin_call_request: PluginCallRequest,
39    state: ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>,
40) -> Result<HttpResponse, ApiError>
41where
42    J: JobProducerTrait + Send + Sync + 'static,
43    RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
44    TR: TransactionRepository + Repository<TransactionRepoModel, String> + Send + Sync + 'static,
45    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
46    NFR: Repository<NotificationRepoModel, String> + Send + Sync + 'static,
47    SR: Repository<SignerRepoModel, String> + Send + Sync + 'static,
48    TCR: TransactionCounterTrait + Send + Sync + 'static,
49    PR: PluginRepositoryTrait + Send + Sync + 'static,
50    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
51{
52    let plugin = state
53        .plugin_repository
54        .get_by_id(&plugin_id)
55        .await?
56        .ok_or_else(|| ApiError::NotFound(format!("Plugin with id {plugin_id} not found")))?;
57
58    let plugin_runner = PluginRunner;
59    let plugin_service = PluginService::new(plugin_runner);
60    let result = plugin_service
61        .call_plugin(plugin, plugin_call_request, Arc::new(state))
62        .await;
63
64    match result {
65        PluginCallResult::Success(plugin_result) => {
66            let PluginCallResponse { result, metadata } = plugin_result;
67
68            let mut response = ApiResponse::success(result);
69            response.metadata = metadata;
70            Ok(HttpResponse::Ok().json(response))
71        }
72        PluginCallResult::Handler(handler) => {
73            let PluginHandlerResponse {
74                status,
75                message,
76                error,
77                metadata,
78            } = handler;
79
80            let log_count = metadata
81                .as_ref()
82                .and_then(|meta| meta.logs.as_ref().map(|logs| logs.len()))
83                .unwrap_or(0);
84            let trace_count = metadata
85                .as_ref()
86                .and_then(|meta| meta.traces.as_ref().map(|traces| traces.len()))
87                .unwrap_or(0);
88
89            let http_status =
90                StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
91
92            // This is an intentional error thrown by the plugin handler - log at debug level
93            tracing::debug!(
94                status,
95                message = %message,
96                code = ?error.code.as_ref(),
97                details = ?error.details.as_ref(),
98                log_count,
99                trace_count,
100                "Plugin handler error"
101            );
102
103            let mut response = ApiResponse::new(Some(error), Some(message.clone()), None);
104            response.metadata = metadata;
105            Ok(HttpResponse::build(http_status).json(response))
106        }
107        PluginCallResult::Fatal(error) => {
108            tracing::error!("Plugin error: {:?}", error);
109            Ok(HttpResponse::InternalServerError()
110                .json(ApiResponse::<String>::error("Internal server error")))
111        }
112    }
113}
114
115/// List plugins
116///
117/// # Arguments
118///
119/// * `query` - The pagination query parameters.
120///     * `page` - The page number.
121///     * `per_page` - The number of items per page.
122/// * `state` - The application state containing the plugin repository.
123///
124/// # Returns
125///
126/// The result of the plugin list.
127pub async fn list_plugins<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
128    query: PaginationQuery,
129    state: ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>,
130) -> Result<HttpResponse, ApiError>
131where
132    J: JobProducerTrait + Send + Sync + 'static,
133    RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
134    TR: TransactionRepository + Repository<TransactionRepoModel, String> + Send + Sync + 'static,
135    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
136    NFR: Repository<NotificationRepoModel, String> + Send + Sync + 'static,
137    SR: Repository<SignerRepoModel, String> + Send + Sync + 'static,
138    TCR: TransactionCounterTrait + Send + Sync + 'static,
139    PR: PluginRepositoryTrait + Send + Sync + 'static,
140    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
141{
142    let plugins = state.plugin_repository.list_paginated(query).await?;
143
144    let plugin_items: Vec<PluginModel> = plugins.items.into_iter().collect();
145
146    Ok(HttpResponse::Ok().json(ApiResponse::paginated(
147        plugin_items,
148        PaginationMeta {
149            total_items: plugins.total,
150            current_page: plugins.page,
151            per_page: plugins.per_page,
152        },
153    )))
154}
155
156#[cfg(test)]
157mod tests {
158    use std::time::Duration;
159
160    use super::*;
161    use actix_web::web;
162
163    use crate::{
164        constants::DEFAULT_PLUGIN_TIMEOUT_SECONDS, models::PluginModel,
165        utils::mocks::mockutils::create_mock_app_state,
166    };
167
168    #[actix_web::test]
169    async fn test_call_plugin_execution_failure() {
170        // Tests the fatal error path (line 107-111) - plugin exists but execution fails
171        let plugin = PluginModel {
172            id: "test-plugin".to_string(),
173            path: "test-path".to_string(),
174            timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS),
175            emit_logs: false,
176            emit_traces: false,
177        };
178        let app_state =
179            create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
180        let plugin_call_request = PluginCallRequest {
181            params: serde_json::json!({"key":"value"}),
182            headers: None,
183        };
184        let response = call_plugin(
185            "test-plugin".to_string(),
186            plugin_call_request,
187            web::ThinData(app_state),
188        )
189        .await;
190        assert!(response.is_ok());
191        let http_response = response.unwrap();
192        // Plugin execution fails in test environment (no ts-node), returns 500
193        assert_eq!(http_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
194    }
195
196    #[actix_web::test]
197    async fn test_call_plugin_not_found() {
198        // Tests the not found error path (line 52-56)
199        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
200        let plugin_call_request = PluginCallRequest {
201            params: serde_json::json!({"key":"value"}),
202            headers: None,
203        };
204        let response = call_plugin(
205            "non-existent".to_string(),
206            plugin_call_request,
207            web::ThinData(app_state),
208        )
209        .await;
210        assert!(response.is_err());
211        match response.unwrap_err() {
212            ApiError::NotFound(msg) => assert!(msg.contains("non-existent")),
213            _ => panic!("Expected NotFound error"),
214        }
215    }
216
217    #[actix_web::test]
218    async fn test_call_plugin_with_logs_and_traces_enabled() {
219        // Tests that emit_logs and emit_traces flags are respected
220        let plugin = PluginModel {
221            id: "test-plugin-logs".to_string(),
222            path: "test-path".to_string(),
223            timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS),
224            emit_logs: true,
225            emit_traces: true,
226        };
227        let app_state =
228            create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
229        let plugin_call_request = PluginCallRequest {
230            params: serde_json::json!({}),
231            headers: None,
232        };
233        let response = call_plugin(
234            "test-plugin-logs".to_string(),
235            plugin_call_request,
236            web::ThinData(app_state),
237        )
238        .await;
239        assert!(response.is_ok());
240    }
241
242    #[actix_web::test]
243    async fn test_list_plugins() {
244        // Tests the list_plugins endpoint (line 127-154)
245        let plugin1 = PluginModel {
246            id: "plugin1".to_string(),
247            path: "path1".to_string(),
248            timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS),
249            emit_logs: false,
250            emit_traces: false,
251        };
252        let plugin2 = PluginModel {
253            id: "plugin2".to_string(),
254            path: "path2".to_string(),
255            timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS),
256            emit_logs: true,
257            emit_traces: true,
258        };
259        let app_state =
260            create_mock_app_state(None, None, None, None, Some(vec![plugin1, plugin2]), None).await;
261
262        let query = PaginationQuery {
263            page: 1,
264            per_page: 10,
265        };
266
267        let response = list_plugins(query, web::ThinData(app_state)).await;
268        assert!(response.is_ok());
269        let http_response = response.unwrap();
270        assert_eq!(http_response.status(), StatusCode::OK);
271    }
272
273    #[actix_web::test]
274    async fn test_list_plugins_empty() {
275        // Tests list_plugins with no plugins
276        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
277
278        let query = PaginationQuery {
279            page: 1,
280            per_page: 10,
281        };
282
283        let response = list_plugins(query, web::ThinData(app_state)).await;
284        assert!(response.is_ok());
285        let http_response = response.unwrap();
286        assert_eq!(http_response.status(), StatusCode::OK);
287    }
288}