refactor(dynamic): add multi-method support to RouteShape, update framework bindings, and improve test coverage

This commit is contained in:
elipeter 2026-05-23 10:08:41 -05:00
parent 4bcdec3a1b
commit ca075a7141
55 changed files with 524 additions and 215 deletions

View file

@ -140,7 +140,9 @@ mod tests {
let tree = parse_java(src);
let summary = FuncSummary {
name: "sign".into(),
callees: vec![crate::summary::CalleeSite::bare("MessageDigest.getInstance")],
callees: vec![crate::summary::CalleeSite::bare(
"MessageDigest.getInstance",
)],
..Default::default()
};
assert!(
@ -169,7 +171,8 @@ mod tests {
#[test]
fn skips_plain_method() {
let src: &[u8] = b"public class Plain { public static int add(int a, int b) { return a + b; } }\n";
let src: &[u8] =
b"public class Plain { public static int add(int a, int b) { return a + b; } }\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "add".into(),

View file

@ -122,7 +122,8 @@ mod tests {
#[test]
fn fires_on_math_random_key() {
let src: &[u8] = b"function run(value) { return Math.random(); }\nmodule.exports = { run };\n";
let src: &[u8] =
b"function run(value) { return Math.random(); }\nmodule.exports = { run };\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "run".into(),

View file

@ -128,8 +128,7 @@ mod tests {
#[test]
fn fires_on_md5() {
let src: &[u8] =
b"<?php\nfunction sign($value) {\n return md5($value);\n}\n";
let src: &[u8] = b"<?php\nfunction sign($value) {\n return md5($value);\n}\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "sign".into(),
@ -180,8 +179,7 @@ mod tests {
#[test]
fn skips_when_sha256_hashing_present() {
let src: &[u8] =
b"<?php\nfunction sign($value) {\n return hash('sha256', $value);\n}\n";
let src: &[u8] = b"<?php\nfunction sign($value) {\n return hash('sha256', $value);\n}\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "sign".into(),

View file

@ -159,7 +159,8 @@ mod tests {
#[test]
fn fires_on_md5_compute() {
let src: &[u8] = b"use md5;\npub fn sign(value: &[u8]) -> md5::Digest {\n md5::compute(value)\n}\n";
let src: &[u8] =
b"use md5;\npub fn sign(value: &[u8]) -> md5::Digest {\n md5::compute(value)\n}\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "sign".into(),

View file

@ -34,10 +34,7 @@ fn callee_is_outbound_http(name: &str) -> bool {
}
fn source_imports_go_http_client(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"\"net/http\"",
b"net/http\"",
];
const NEEDLES: &[&[u8]] = &[b"\"net/http\"", b"net/http\""];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))

View file

@ -213,7 +213,8 @@ mod tests {
#[test]
fn skips_plain_method() {
let src: &[u8] = b"public class Plain { public static int add(int a, int b) { return a + b; } }\n";
let src: &[u8] =
b"public class Plain { public static int add(int a, int b) { return a + b; } }\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "add".into(),

View file

@ -19,15 +19,7 @@ fn callee_is_outbound_http(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"urlopen"
| "get"
| "post"
| "put"
| "patch"
| "delete"
| "request"
| "Request"
| "send"
"urlopen" | "get" | "post" | "put" | "patch" | "delete" | "request" | "Request" | "send"
) || matches!(
name,
"urllib.request.urlopen"

View file

@ -19,33 +19,31 @@ const ADAPTER_NAME: &str = "data-exfil-ruby";
fn callee_is_outbound_http(name: &str) -> bool {
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
matches!(
last,
"get_response" | "post_form" | "request" | "start"
) || matches!(
name,
"Net::HTTP.get"
| "Net::HTTP.get_response"
| "Net::HTTP.post_form"
| "Net::HTTP.start"
| "Net::HTTP::Get.new"
| "Net::HTTP::Post.new"
| "RestClient.get"
| "RestClient.post"
| "RestClient.put"
| "RestClient.delete"
| "RestClient::Request.execute"
| "HTTParty.get"
| "HTTParty.post"
| "HTTParty.put"
| "HTTParty.delete"
| "Faraday.get"
| "Faraday.post"
| "Faraday.new"
| "Faraday::Connection.get"
| "URI.open"
| "Kernel.open"
)
matches!(last, "get_response" | "post_form" | "request" | "start")
|| matches!(
name,
"Net::HTTP.get"
| "Net::HTTP.get_response"
| "Net::HTTP.post_form"
| "Net::HTTP.start"
| "Net::HTTP::Get.new"
| "Net::HTTP::Post.new"
| "RestClient.get"
| "RestClient.post"
| "RestClient.put"
| "RestClient.delete"
| "RestClient::Request.execute"
| "HTTParty.get"
| "HTTParty.post"
| "HTTParty.put"
| "HTTParty.delete"
| "Faraday.get"
| "Faraday.post"
| "Faraday.new"
| "Faraday::Connection.get"
| "URI.open"
| "Kernel.open"
)
}
fn source_imports_ruby_http_client(file_bytes: &[u8]) -> bool {

View file

@ -56,7 +56,7 @@ impl FrameworkAdapter for GoChiAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -56,7 +56,7 @@ impl FrameworkAdapter for GoEchoAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -57,7 +57,7 @@ impl FrameworkAdapter for GoFiberAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -59,7 +59,7 @@ impl FrameworkAdapter for GoGinAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -83,10 +83,7 @@ fn detect_micronaut(
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape {
method: http_method,
path,
}),
route: Some(RouteShape::single(http_method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -87,10 +87,7 @@ fn detect_quarkus(
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape {
method: http_method,
path,
}),
route: Some(RouteShape::single(http_method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -81,10 +81,7 @@ fn detect_servlet(
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape {
method: http_method,
path,
}),
route: Some(RouteShape::single(http_method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -128,10 +128,7 @@ fn detect_spring(
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape {
method: http_method,
path,
}),
route: Some(RouteShape::single(http_method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -97,7 +97,7 @@ fn detect_express(
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -99,7 +99,7 @@ fn detect_fastify(
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -148,7 +148,7 @@ fn detect_koa(
return Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware,
@ -162,10 +162,7 @@ fn detect_koa(
return Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape {
method: HttpMethod::GET,
path: "/".to_owned(),
}),
route: Some(RouteShape::single(HttpMethod::GET, "/")),
request_params,
response_writer: None,
middleware: vec![MiddlewareShape {

View file

@ -120,10 +120,7 @@ fn detect_nest(
Some(FrameworkBinding {
adapter: adapter_name.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape {
method,
path: full_path,
}),
route: Some(RouteShape::single(method, full_path)),
request_params,
response_writer: None,
middleware,

View file

@ -58,7 +58,7 @@ impl FrameworkAdapter for PhpCodeIgniterAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -14,15 +14,15 @@
#[cfg(test)]
use crate::dynamic::framework::HttpMethod;
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape};
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
use tree_sitter::Node;
use super::php_routes::{
bind_php_path_params, collect_php_middleware, find_laravel_static_route, find_php_function,
php_class_name, php_formal_names, source_imports_laravel,
bind_php_path_params, collect_php_middleware, find_laravel_static_route_shape,
find_php_function, php_class_name, php_formal_names, source_imports_laravel,
};
pub struct PhpLaravelAdapter;
@ -50,16 +50,16 @@ impl FrameworkAdapter for PhpLaravelAdapter {
let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?;
let controller = class.and_then(|c| php_class_name(c, file_bytes));
let (method, path) = find_laravel_static_route(ast, file_bytes, &summary.name, controller)?;
let route = find_laravel_static_route_shape(ast, file_bytes, &summary.name, controller)?;
let formals = php_formal_names(func_node, file_bytes);
let request_params = bind_php_path_params(&formals, &path);
let request_params = bind_php_path_params(&formals, &route.path);
let middleware = collect_php_middleware(ast, file_bytes);
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(route),
request_params,
response_writer: None,
middleware,
@ -139,6 +139,22 @@ mod tests {
assert_eq!(binding.route.unwrap().method, HttpMethod::DELETE);
}
#[test]
fn preserves_match_route_methods() {
let src: &[u8] = b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::match(['POST', 'PATCH'], '/jobs/{id}', [JobController::class, 'run']);\nclass JobController {\n public function run($id) { return $id; }\n}\n";
let tree = parse(src);
let binding = PhpLaravelAdapter
.detect(&summary("run"), tree.root_node(), src)
.expect("binding");
let route = binding.route.unwrap();
assert_eq!(route.method, HttpMethod::POST);
assert_eq!(
route.reachable_methods(),
vec![HttpMethod::POST, HttpMethod::PATCH]
);
assert_eq!(route.path, "/jobs/{id}");
}
#[test]
fn populates_middleware_from_chained_call() {
let src: &[u8] = b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::get('/users/{id}', 'UserController@show')->middleware('auth');\nclass UserController {\n public function show($id) { return $id; }\n}\n";

View file

@ -11,7 +11,7 @@
//! framework share the same placeholder-binding semantics.
use crate::dynamic::framework::{
HttpMethod, MiddlewareShape, ParamBinding, ParamSource, auth_markers,
HttpMethod, MiddlewareShape, ParamBinding, ParamSource, RouteShape, auth_markers,
};
use crate::symbol::Lang;
use tree_sitter::Node;
@ -407,7 +407,19 @@ pub fn find_laravel_static_route<'a>(
target: &str,
controller: Option<&str>,
) -> Option<(HttpMethod, String)> {
let mut hit: Option<(HttpMethod, String)> = None;
find_laravel_static_route_shape(root, bytes, target, controller)
.map(|route| (route.method, route.path))
}
/// Laravel route lookup that preserves multi-verb registrations such
/// as `Route::any(...)` and `Route::match([...], ...)`.
pub fn find_laravel_static_route_shape<'a>(
root: Node<'a>,
bytes: &'a [u8],
target: &str,
controller: Option<&str>,
) -> Option<RouteShape> {
let mut hit: Option<RouteShape> = None;
visit_laravel_routes(root, bytes, target, controller, &mut hit);
hit
}
@ -417,7 +429,7 @@ fn visit_laravel_routes<'a>(
bytes: &'a [u8],
target: &str,
controller: Option<&str>,
out: &mut Option<(HttpMethod, String)>,
out: &mut Option<RouteShape>,
) {
if out.is_some() {
return;
@ -439,20 +451,81 @@ fn try_laravel_route<'a>(
bytes: &'a [u8],
target: &str,
controller: Option<&str>,
) -> Option<(HttpMethod, String)> {
) -> Option<RouteShape> {
let scope = call.child_by_field_name("scope")?.utf8_text(bytes).ok()?;
let scope_leaf = scope.rsplit('\\').next().unwrap_or(scope);
if scope_leaf != "Route" {
return None;
}
let verb_node = call.child_by_field_name("name")?.utf8_text(bytes).ok()?;
let method = verb_method(verb_node)?;
let args = call.child_by_field_name("arguments")?;
let path = first_php_string_arg(args, bytes)?;
if !laravel_callable_matches(args, bytes, target, controller) {
let methods = laravel_route_methods(verb_node, args, bytes)?;
let path = laravel_route_path(verb_node, args, bytes)?;
if !laravel_callable_matches(verb_node, args, bytes, target, controller) {
return None;
}
Some((method, path))
Some(if methods.len() > 1 {
RouteShape::multi(methods, path)
} else {
RouteShape::single(methods[0], path)
})
}
fn laravel_route_methods(verb: &str, arguments: Node<'_>, bytes: &[u8]) -> Option<Vec<HttpMethod>> {
match verb {
"any" => Some(vec![
HttpMethod::GET,
HttpMethod::HEAD,
HttpMethod::POST,
HttpMethod::PUT,
HttpMethod::PATCH,
HttpMethod::DELETE,
HttpMethod::OPTIONS,
]),
"match" => {
let first = positional_arg_values(arguments).into_iter().next()?;
let mut methods = Vec::new();
collect_http_methods(first, bytes, &mut methods);
if methods.is_empty() {
None
} else {
Some(methods)
}
}
other => verb_method(other).map(|method| vec![method]),
}
}
fn laravel_route_path(verb: &str, arguments: Node<'_>, bytes: &[u8]) -> Option<String> {
if verb != "match" {
return first_php_string_arg(arguments, bytes);
}
positional_arg_values(arguments)
.get(1)
.and_then(|value| string_content(*value, bytes))
}
fn positional_arg_values<'a>(arguments: Node<'a>) -> Vec<Node<'a>> {
let mut cur = arguments.walk();
arguments
.named_children(&mut cur)
.filter(|arg| arg.kind() == "argument" && arg.child_by_field_name("name").is_none())
.filter_map(|arg| arg.named_child(0))
.collect()
}
fn collect_http_methods(node: Node<'_>, bytes: &[u8], out: &mut Vec<HttpMethod>) {
if matches!(node.kind(), "string" | "encapsed_string")
&& let Some(raw) = string_content(node, bytes)
&& let Some(method) = HttpMethod::from_ident(&raw)
&& !out.contains(&method)
{
out.push(method);
}
let mut cur = node.walk();
for child in node.named_children(&mut cur) {
collect_http_methods(child, bytes, out);
}
}
/// Check the second positional arg of a `Route::verb('/x', ...)` call
@ -462,26 +535,15 @@ fn try_laravel_route<'a>(
/// - `'Controller@method'` / `'Controller::method'` strings
/// - `[ Controller::class, 'method' ]` arrays
fn laravel_callable_matches(
verb: &str,
arguments: Node<'_>,
bytes: &[u8],
target: &str,
controller: Option<&str>,
) -> bool {
let mut cur = arguments.walk();
let mut positional: Vec<Node<'_>> = Vec::new();
for arg in arguments.named_children(&mut cur) {
if arg.kind() != "argument" {
continue;
}
if arg.child_by_field_name("name").is_some() {
continue;
}
positional.push(arg);
}
let Some(callable_arg) = positional.get(1) else {
return false;
};
let Some(value) = callable_arg.named_child(0) else {
let callable_idx = if verb == "match" { 2 } else { 1 };
let positional = positional_arg_values(arguments);
let Some(value) = positional.get(callable_idx).copied() else {
return false;
};
match value.kind() {
@ -557,7 +619,6 @@ fn verb_method(verb: &str) -> Option<HttpMethod> {
"delete" => Some(HttpMethod::DELETE),
"options" => Some(HttpMethod::OPTIONS),
"head" => Some(HttpMethod::HEAD),
"any" | "match" => Some(HttpMethod::GET),
_ => None,
}
}
@ -895,6 +956,46 @@ mod tests {
assert_eq!(hit.1, "/users");
}
#[test]
fn finds_laravel_any_route_with_all_supported_methods() {
let src: &[u8] =
b"<?php\nRoute::any('/run', 'JobController@run');\nclass JobController { public function run() {} }\n";
let tree = parse(src);
let route =
find_laravel_static_route_shape(tree.root_node(), src, "run", Some("JobController"))
.unwrap();
assert_eq!(route.method, HttpMethod::GET);
assert_eq!(
route.reachable_methods(),
vec![
HttpMethod::GET,
HttpMethod::HEAD,
HttpMethod::POST,
HttpMethod::PUT,
HttpMethod::PATCH,
HttpMethod::DELETE,
HttpMethod::OPTIONS,
]
);
assert_eq!(route.path, "/run");
}
#[test]
fn finds_laravel_match_route_with_declared_methods() {
let src: &[u8] =
b"<?php\nRoute::match(['POST', 'PUT'], '/run', [JobController::class, 'run']);\nclass JobController { public function run() {} }\n";
let tree = parse(src);
let route =
find_laravel_static_route_shape(tree.root_node(), src, "run", Some("JobController"))
.unwrap();
assert_eq!(route.method, HttpMethod::POST);
assert_eq!(
route.reachable_methods(),
vec![HttpMethod::POST, HttpMethod::PUT]
);
assert_eq!(route.path, "/run");
}
#[test]
fn finds_codeigniter_member_route() {
let src: &[u8] = b"<?php\n$routes->get('users/(:num)', 'UserController::show');\n";

View file

@ -89,10 +89,7 @@ impl FrameworkAdapter for PhpSymfonyAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape {
method: http_method,
path,
}),
route: Some(RouteShape::single(http_method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -213,7 +213,7 @@ impl FrameworkAdapter for PythonDjangoAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware: Vec::new(),

View file

@ -263,7 +263,7 @@ impl FrameworkAdapter for PythonFastApiAdapter {
return Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware: Vec::new(),

View file

@ -122,7 +122,7 @@ impl FrameworkAdapter for PythonFlaskAdapter {
return Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware: Vec::new(),

View file

@ -125,7 +125,7 @@ impl FrameworkAdapter for PythonStarletteAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware: Vec::new(),

View file

@ -180,10 +180,7 @@ impl FrameworkAdapter for RubyHanamiAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape {
method: http_method,
path,
}),
route: Some(RouteShape::single(http_method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -289,10 +289,7 @@ impl FrameworkAdapter for RubyRailsAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape {
method: http_method,
path,
}),
route: Some(RouteShape::single(http_method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -153,10 +153,7 @@ impl FrameworkAdapter for RubySinatraAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape {
method: route.method,
path: route.path.clone(),
}),
route: Some(RouteShape::single(route.method, route.path.clone())),
request_params,
response_writer: None,
middleware,

View file

@ -54,7 +54,7 @@ impl FrameworkAdapter for RustActixAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -56,7 +56,7 @@ impl FrameworkAdapter for RustAxumAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -57,7 +57,7 @@ impl FrameworkAdapter for RustRocketAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -58,7 +58,7 @@ impl FrameworkAdapter for RustWarpAdapter {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape { method, path }),
route: Some(RouteShape::single(method, path)),
request_params,
response_writer: None,
middleware,

View file

@ -37,12 +37,56 @@ pub use crate::entry_points::HttpMethod;
pub struct RouteShape {
/// HTTP verb (`GET`, `POST`, …).
pub method: HttpMethod,
/// Additional HTTP verbs that reach the same handler. Empty for
/// single-verb routes; when populated, [`Self::method`] is the
/// first element for backward-compatible callers that still need a
/// single representative method.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub methods: Vec<HttpMethod>,
/// Route path template as registered with the framework (e.g.
/// `"/users/{id}"`). Adapter-specific placeholder syntax is
/// preserved verbatim.
pub path: String,
}
impl RouteShape {
/// Construct a single-method route while preserving the legacy
/// empty-`methods` representation.
pub fn single(method: HttpMethod, path: impl Into<String>) -> Self {
Self {
method,
methods: Vec::new(),
path: path.into(),
}
}
/// Construct a route reachable through multiple HTTP methods.
pub fn multi(methods: Vec<HttpMethod>, path: impl Into<String>) -> Self {
let mut deduped = Vec::new();
for method in methods {
if !deduped.contains(&method) {
deduped.push(method);
}
}
let method = deduped.first().copied().unwrap_or(HttpMethod::GET);
Self {
method,
methods: deduped,
path: path.into(),
}
}
/// Return every method that reaches this route. Legacy single-method
/// shapes return a one-element vector containing [`Self::method`].
pub fn reachable_methods(&self) -> Vec<HttpMethod> {
if self.methods.is_empty() {
vec![self.method]
} else {
self.methods.clone()
}
}
}
/// Where on the external surface a function formal originates from.
///
/// Adapters classify each declared parameter into one of these
@ -532,10 +576,7 @@ mod tests {
let original = FrameworkBinding {
adapter: "flask".into(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape {
method: HttpMethod::POST,
path: "/users/{id}".into(),
}),
route: Some(RouteShape::single(HttpMethod::POST, "/users/{id}")),
request_params: vec![ParamBinding {
index: 0,
name: "id".into(),