Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 19 additions & 13 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -924,13 +924,13 @@
]
}
},
"/api/v1/plugins/{plugin_id}/call{route}": {
"/api/v1/plugins/{plugin_id}/call": {
"get": {
"tags": [
"Plugins"
],
"summary": "Execute a plugin via GET (must be enabled per plugin)",
"description": "This endpoint is disabled by default. To enable it for a given plugin, set\n`allow_get_invocation: true` in the plugin configuration.\n\nWhen invoked via GET:\n- `params` is an empty object (`{}`)\n- query parameters are passed to the plugin handler via `context.query`\n- wildcard route routing is supported the same way as POST (see `doc_call_plugin`)",
"description": "This endpoint is disabled by default. To enable it for a given plugin, set\n`allow_get_invocation: true` in the plugin configuration.\n\nWhen invoked via GET:\n- `params` is an empty object (`{}`)\n- query parameters are passed to the plugin handler via `context.query`\n- wildcard route routing is supported the same way as POST (see `doc_call_plugin`)\n- Use the `route` query parameter or append the route to the URL path",
"operationId": "callPluginGet",
"parameters": [
{
Expand All @@ -944,13 +944,12 @@
},
{
"name": "route",
"in": "path",
"description": "Optional route suffix captured by the server. Use an empty string for the default route, or include a leading slash (e.g. '/supported'). May include additional slashes for nested routes.",
"required": true,
"in": "query",
"description": "Optional route suffix for custom routing (e.g., '/verify'). Alternative to appending the route to the URL path.",
"required": false,
"schema": {
"type": "string"
},
"example": "/supported"
}
}
],
"responses": {
Expand Down Expand Up @@ -1051,7 +1050,7 @@
"Plugins"
],
"summary": "Execute a plugin with optional wildcard route routing",
"description": "Logs and traces are only returned when the plugin is configured with `emit_logs` / `emit_traces`.\nPlugin-provided errors are normalized into a consistent payload (`code`, `details`) and a derived\nmessage so downstream clients receive a stable shape regardless of how the handler threw.\n\nThe endpoint supports wildcard route routing, allowing plugins to implement custom routing logic:\n- `/api/v1/plugins/{plugin_id}/call` - Default endpoint (route = \"\")\n- `/api/v1/plugins/{plugin_id}/call/verify` - Custom route (route = \"/verify\")\n- `/api/v1/plugins/{plugin_id}/call/settle` - Custom route (route = \"/settle\")\n- `/api/v1/plugins/{plugin_id}/call/api/v1/action` - Nested route (route = \"/api/v1/action\")\n\nThe route is passed to the plugin handler via the `context.route` field.",
"description": "Logs and traces are only returned when the plugin is configured with `emit_logs` / `emit_traces`.\nPlugin-provided errors are normalized into a consistent payload (`code`, `details`) and a derived\nmessage so downstream clients receive a stable shape regardless of how the handler threw.\n\nThe endpoint supports wildcard route routing, allowing plugins to implement custom routing logic:\n- `/api/v1/plugins/{plugin_id}/call` - Default endpoint (route = \"\")\n- `/api/v1/plugins/{plugin_id}/call?route=/verify` - Custom route via query parameter\n- `/api/v1/plugins/{plugin_id}/call/verify` - Custom route via URL path (route = \"/verify\")\n\nThe route is passed to the plugin handler via the `context.route` field.\nYou can specify a custom route either by appending it to the URL path or by using the `route` query parameter.",
"operationId": "callPlugin",
"parameters": [
{
Expand All @@ -1065,13 +1064,12 @@
},
{
"name": "route",
"in": "path",
"description": "Optional route suffix captured by the server. Use an empty string for the default route, or include a leading slash (e.g. '/verify'). May include additional slashes for nested routes (e.g. '/api/v1/action').",
"required": true,
"in": "query",
"description": "Optional route suffix for custom routing (e.g., '/verify'). Alternative to appending the route to the URL path.",
"required": false,
"schema": {
"type": "string"
},
"example": "/verify"
}
}
],
"requestBody": {
Expand Down Expand Up @@ -4499,6 +4497,10 @@
"type": "boolean",
"description": "Whether to include traces in the HTTP response"
},
"forward_logs": {
"type": "boolean",
"description": "Whether to forward plugin logs into the relayer's tracing output"
},
"id": {
"type": "string",
"description": "Plugin ID"
Expand Down Expand Up @@ -6986,6 +6988,10 @@
"type": "boolean",
"description": "Whether to include traces in the HTTP response"
},
"forward_logs": {
"type": "boolean",
"description": "Whether to forward plugin logs into the relayer's tracing output"
},
"id": {
"type": "string",
"description": "Plugin ID"
Expand Down
25 changes: 8 additions & 17 deletions src/api/routes/docs/plugin_docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ use crate::{
///
/// The endpoint supports wildcard route routing, allowing plugins to implement custom routing logic:
/// - `/api/v1/plugins/{plugin_id}/call` - Default endpoint (route = "")
/// - `/api/v1/plugins/{plugin_id}/call/verify` - Custom route (route = "/verify")
/// - `/api/v1/plugins/{plugin_id}/call/settle` - Custom route (route = "/settle")
/// - `/api/v1/plugins/{plugin_id}/call/api/v1/action` - Nested route (route = "/api/v1/action")
/// - `/api/v1/plugins/{plugin_id}/call?route=/verify` - Custom route via query parameter
/// - `/api/v1/plugins/{plugin_id}/call/verify` - Custom route via URL path (route = "/verify")
///
/// The route is passed to the plugin handler via the `context.route` field.
/// You can specify a custom route either by appending it to the URL path or by using the `route` query parameter.
#[utoipa::path(
post,
path = "/api/v1/plugins/{plugin_id}/call{route}",
path = "/api/v1/plugins/{plugin_id}/call",
tag = "Plugins",
operation_id = "callPlugin",
summary = "Execute a plugin with optional wildcard route routing",
Expand All @@ -28,12 +28,7 @@ use crate::{
),
params(
("plugin_id" = String, Path, description = "The unique identifier of the plugin"),
(
"route" = String,
Path,
description = "Optional route suffix captured by the server. Use an empty string for the default route, or include a leading slash (e.g. '/verify'). May include additional slashes for nested routes (e.g. '/api/v1/action').",
example = "/verify"
)
("route" = Option<String>, Query, description = "Optional route suffix for custom routing (e.g., '/verify'). Alternative to appending the route to the URL path.")
),
request_body = PluginCallRequest,
responses(
Expand Down Expand Up @@ -134,9 +129,10 @@ fn doc_call_plugin() {}
/// - `params` is an empty object (`{}`)
/// - query parameters are passed to the plugin handler via `context.query`
/// - wildcard route routing is supported the same way as POST (see `doc_call_plugin`)
/// - Use the `route` query parameter or append the route to the URL path
#[utoipa::path(
get,
path = "/api/v1/plugins/{plugin_id}/call{route}",
path = "/api/v1/plugins/{plugin_id}/call",
tag = "Plugins",
operation_id = "callPluginGet",
summary = "Execute a plugin via GET (must be enabled per plugin)",
Expand All @@ -145,12 +141,7 @@ fn doc_call_plugin() {}
),
params(
("plugin_id" = String, Path, description = "The unique identifier of the plugin"),
(
"route" = String,
Path,
description = "Optional route suffix captured by the server. Use an empty string for the default route, or include a leading slash (e.g. '/supported'). May include additional slashes for nested routes.",
example = "/supported"
)
("route" = Option<String>, Query, description = "Optional route suffix for custom routing (e.g., '/verify'). Alternative to appending the route to the URL path.")
),
responses(
(
Expand Down
94 changes: 89 additions & 5 deletions src/api/routes/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ fn extract_query_params(http_req: &HttpRequest) -> HashMap<String, Vec<String>>
query_params
}

/// Resolves the effective route from path and query parameters.
/// Path route takes precedence; if empty, falls back to `route` query parameter.
fn resolve_route(path_route: &str, http_req: &HttpRequest) -> String {
if !path_route.is_empty() {
return path_route.to_string();
}

// Check for route in query parameters
let query_params = extract_query_params(http_req);
query_params
.get("route")
.and_then(|values| values.first())
.cloned()
.unwrap_or_default()
}

fn build_plugin_call_request_from_post_body(
route: &str,
http_req: &HttpRequest,
Expand Down Expand Up @@ -110,7 +126,8 @@ async fn plugin_call(
body: web::Bytes,
data: web::ThinData<DefaultAppState>,
) -> Result<HttpResponse, ApiError> {
let (plugin_id, route) = params.into_inner();
let (plugin_id, path_route) = params.into_inner();
let route = resolve_route(&path_route, &http_req);

let mut plugin_call_request =
match build_plugin_call_request_from_post_body(&route, &http_req, body.as_ref()) {
Expand All @@ -130,7 +147,8 @@ async fn plugin_call_get(
http_req: HttpRequest,
data: web::ThinData<DefaultAppState>,
) -> Result<HttpResponse, ApiError> {
let (plugin_id, route) = params.into_inner();
let (plugin_id, path_route) = params.into_inner();
let route = resolve_route(&path_route, &http_req);

// Check if GET requests are allowed for this plugin
let plugin = data
Expand Down Expand Up @@ -162,7 +180,7 @@ pub fn init(cfg: &mut web::ServiceConfig) {
// Register routes with literal segments before routes with path parameters
cfg.service(plugin_call); // POST /plugins/{plugin_id}/call
cfg.service(plugin_call_get); // GET /plugins/{plugin_id}/call
cfg.service(list_plugins); // /plugins
cfg.service(list_plugins); // GET /plugins
}

#[cfg(test)]
Expand Down Expand Up @@ -204,7 +222,8 @@ mod tests {
body: web::Bytes,
captured: web::Data<CapturedRequest>,
) -> impl Responder {
let (_plugin_id, route) = params.into_inner();
let (_plugin_id, path_route) = params.into_inner();
let route = resolve_route(&path_route, &http_req);
match build_plugin_call_request_from_post_body(&route, &http_req, body.as_ref()) {
Ok(mut req) => {
req.method = Some("POST".to_string());
Expand All @@ -226,7 +245,8 @@ mod tests {
http_req: HttpRequest,
captured: web::Data<CapturedRequest>,
) -> impl Responder {
let (_plugin_id, route) = params.into_inner();
let (_plugin_id, path_route) = params.into_inner();
let route = resolve_route(&path_route, &http_req);
// Simulate what plugin_call_get does for GET requests
let plugin_call_request = PluginCallRequest {
params: serde_json::json!({}),
Expand Down Expand Up @@ -908,6 +928,70 @@ mod tests {
}
}

/// Verifies that route can be specified via query parameter when path route is empty
#[actix_web::test]
async fn test_route_from_query_parameter() {
let captured = web::Data::new(CapturedRequest::default());
let app = test::init_service(
App::new()
.app_data(captured.clone())
.service(
web::resource("/plugins/{plugin_id}/call{route:.*}")
.route(web::post().to(capturing_plugin_call_handler)),
)
.configure(init),
)
.await;

// Test route from query parameter
let req = test::TestRequest::post()
.uri("/plugins/test/call?route=/verify")
.insert_header(("Content-Type", "application/json"))
.set_json(serde_json::json!({}))
.to_request();

test::call_service(&app, req).await;

let captured_req = captured.get().expect("Request should have been captured");
assert_eq!(
captured_req.route,
Some("/verify".to_string()),
"Route should be extracted from query parameter"
);
}

/// Verifies that path route takes precedence over query parameter
#[actix_web::test]
async fn test_path_route_takes_precedence_over_query() {
let captured = web::Data::new(CapturedRequest::default());
let app = test::init_service(
App::new()
.app_data(captured.clone())
.service(
web::resource("/plugins/{plugin_id}/call{route:.*}")
.route(web::post().to(capturing_plugin_call_handler)),
)
.configure(init),
)
.await;

// Test that path route takes precedence over query param
let req = test::TestRequest::post()
.uri("/plugins/test/call/settle?route=/verify")
.insert_header(("Content-Type", "application/json"))
.set_json(serde_json::json!({}))
.to_request();

test::call_service(&app, req).await;

let captured_req = captured.get().expect("Request should have been captured");
assert_eq!(
captured_req.route,
Some("/settle".to_string()),
"Path route should take precedence over query parameter"
);
}

/// Verifies that query parameters are correctly extracted and passed
#[actix_web::test]
async fn test_query_params_extracted_and_passed() {
Expand Down
Loading