From afbad1e0b0c99896618fe0eee35ac9fd6fbf8171 Mon Sep 17 00:00:00 2001 From: hahwul Date: Sat, 15 Aug 2026 18:39:23 +0900 Subject: [PATCH] feat(analyzer/rust): detect HTTP QUERY routes in axum (#2551) - Support axum routing::query, routing::query_service, and MethodRouter::query - Support MethodFilter::QUERY and combinations via .or() and bitwise | - Ensure nested router prefix composition propagates to QUERY routes - Update axum functional test and fixture with QUERY routes - Add comprehensive unit tests for axum analyzer QUERY routing --- .../fixtures/rust/axum/src/main.rs | 16 +- .../functional_test/testers/rust/axum_spec.cr | 12 + spec/unit_test/analyzer/analyzer_axum_spec.cr | 327 ++++++++++++++++++ src/analyzer/analyzers/rust/axum.cr | 112 +++++- src/models/analyzer.cr | 1 + 5 files changed, 452 insertions(+), 16 deletions(-) create mode 100644 spec/unit_test/analyzer/analyzer_axum_spec.cr diff --git a/spec/functional_test/fixtures/rust/axum/src/main.rs b/spec/functional_test/fixtures/rust/axum/src/main.rs index 3dfc025cc..89b4e581d 100644 --- a/spec/functional_test/fixtures/rust/axum/src/main.rs +++ b/spec/functional_test/fixtures/rust/axum/src/main.rs @@ -1,4 +1,10 @@ -use axum::{extract::{Form, Query}, http::HeaderMap, response::Html, routing::{any, get, post}, Router}; +use axum::{ + extract::{Form, Query}, + http::HeaderMap, + response::Html, + routing::{any, get, on, post, query, query_service, MethodFilter}, + Router, +}; use axum_extra::extract::CookieJar; use tower_http::services::ServeDir; @@ -11,6 +17,11 @@ async fn main() { .route("/foo", get(handler)) .route("/bar", post(handler)) .route("/search", get(search)) + .route("/search_query", query(search)) + .route("/items", get(handler).query(search)) + .route("/svc", query_service(ServeDir::new("assets"))) + .route("/filter-query", on(MethodFilter::QUERY, handler)) + .route("/filter-combo", on(MethodFilter::GET.or(MethodFilter::QUERY), handler)) .route("/submit", post(submit)) .route("/headers", get(headers)) .route("/session", get(session)) @@ -29,7 +40,8 @@ async fn main() { "/api", Router::new() .route("/users", get(handler)) - .route("/admin", post(handler)), + .route("/admin", post(handler)) + .route("/query-nested", query(handler)), ) // Real applications often build a sub-router in a local binding before // mounting it. The route must inherit the nest prefix and must not also diff --git a/spec/functional_test/testers/rust/axum_spec.cr b/spec/functional_test/testers/rust/axum_spec.cr index 14c0e4da4..be387abca 100644 --- a/spec/functional_test/testers/rust/axum_spec.cr +++ b/spec/functional_test/testers/rust/axum_spec.cr @@ -15,6 +15,17 @@ expected_endpoints = [ Endpoint.new("/search", "GET", [ Param.new("query", "", "query"), ]), + Endpoint.new("/search_query", "QUERY", [ + Param.new("query", "", "query"), + ]), + Endpoint.new("/items", "GET"), + Endpoint.new("/items", "QUERY", [ + Param.new("query", "", "query"), + ]), + Endpoint.new("/svc", "QUERY"), + Endpoint.new("/filter-query", "QUERY"), + Endpoint.new("/filter-combo", "GET"), + Endpoint.new("/filter-combo", "QUERY"), Endpoint.new("/submit", "POST", [ Param.new("form", "", "form"), ]), @@ -26,6 +37,7 @@ expected_endpoints = [ ]), Endpoint.new("/api/users", "GET"), Endpoint.new("/api/admin", "POST"), + Endpoint.new("/api/query-nested", "QUERY"), Endpoint.new("/internal/health", "GET"), Endpoint.new("/v1/projects", "GET"), Endpoint.new("/v1/projects/{id}", "GET", [ diff --git a/spec/unit_test/analyzer/analyzer_axum_spec.cr b/spec/unit_test/analyzer/analyzer_axum_spec.cr new file mode 100644 index 000000000..692359239 --- /dev/null +++ b/spec/unit_test/analyzer/analyzer_axum_spec.cr @@ -0,0 +1,327 @@ +require "../../spec_helper" +require "../../../src/models/code_locator" +require "../../../src/analyzer/analyzers/rust/axum" + +describe Analyzer::Rust::Axum do + options = create_test_options + + it "detects basic routing::query routes" do + instance = Analyzer::Rust::Axum.new(options) + temp_dir = File.tempname("axum_test") + Dir.mkdir_p(temp_dir) + temp_file = File.join(temp_dir, "test.rs") + + File.write(temp_file, <<-RUST) + use axum::{routing::query, Router}; + + async fn search_handler() {} + + fn app() -> Router { + Router::new() + .route("/search", query(search_handler)) + } + RUST + + endpoints = instance.analyze_file(temp_file) + endpoints.size.should eq(1) + endpoints[0].url.should eq("/search") + endpoints[0].method.should eq("QUERY") + + File.delete(temp_file) + Dir.delete(temp_dir) + end + + it "detects chained MethodRouter with .query()" do + instance = Analyzer::Rust::Axum.new(options) + temp_dir = File.tempname("axum_test") + Dir.mkdir_p(temp_dir) + temp_file = File.join(temp_dir, "test.rs") + + File.write(temp_file, <<-RUST) + use axum::{routing::{get, query}, Router}; + + async fn list_handler() {} + async fn query_handler() {} + + fn app() -> Router { + Router::new() + .route("/items", get(list_handler).query(query_handler)) + } + RUST + + endpoints = instance.analyze_file(temp_file) + endpoints.size.should eq(2) + endpoints[0].url.should eq("/items") + endpoints[0].method.should eq("GET") + endpoints[1].url.should eq("/items") + endpoints[1].method.should eq("QUERY") + + File.delete(temp_file) + Dir.delete(temp_dir) + end + + it "detects query_service routes" do + instance = Analyzer::Rust::Axum.new(options) + temp_dir = File.tempname("axum_test") + Dir.mkdir_p(temp_dir) + temp_file = File.join(temp_dir, "test.rs") + + File.write(temp_file, <<-RUST) + use axum::{routing::query_service, Router}; + + fn app() -> Router { + Router::new() + .route("/svc", query_service(my_svc)) + } + RUST + + endpoints = instance.analyze_file(temp_file) + endpoints.size.should eq(1) + endpoints[0].url.should eq("/svc") + endpoints[0].method.should eq("QUERY") + + File.delete(temp_file) + Dir.delete(temp_dir) + end + + it "detects on(MethodFilter::QUERY, ...)" do + instance = Analyzer::Rust::Axum.new(options) + temp_dir = File.tempname("axum_test") + Dir.mkdir_p(temp_dir) + temp_file = File.join(temp_dir, "test.rs") + + File.write(temp_file, <<-RUST) + use axum::{routing::{on, MethodFilter}, Router}; + + async fn handler() {} + + fn app() -> Router { + Router::new() + .route("/filter-q", on(MethodFilter::QUERY, handler)) + } + RUST + + endpoints = instance.analyze_file(temp_file) + endpoints.size.should eq(1) + endpoints[0].url.should eq("/filter-q") + endpoints[0].method.should eq("QUERY") + + File.delete(temp_file) + Dir.delete(temp_dir) + end + + it "detects on(MethodFilter::GET.or(MethodFilter::QUERY), ...)" do + instance = Analyzer::Rust::Axum.new(options) + temp_dir = File.tempname("axum_test") + Dir.mkdir_p(temp_dir) + temp_file = File.join(temp_dir, "test.rs") + + File.write(temp_file, <<-RUST) + use axum::{routing::{on, MethodFilter}, Router}; + + async fn handler() {} + + fn app() -> Router { + Router::new() + .route("/combo", on(MethodFilter::GET.or(MethodFilter::QUERY), handler)) + } + RUST + + endpoints = instance.analyze_file(temp_file) + endpoints.size.should eq(2) + endpoints[0].url.should eq("/combo") + endpoints[0].method.should eq("GET") + endpoints[1].url.should eq("/combo") + endpoints[1].method.should eq("QUERY") + + File.delete(temp_file) + Dir.delete(temp_dir) + end + + it "detects on(MethodFilter::GET | MethodFilter::QUERY, ...)" do + instance = Analyzer::Rust::Axum.new(options) + temp_dir = File.tempname("axum_test") + Dir.mkdir_p(temp_dir) + temp_file = File.join(temp_dir, "test.rs") + + File.write(temp_file, <<-RUST) + use axum::{routing::{on, MethodFilter}, Router}; + + async fn handler() {} + + fn app() -> Router { + Router::new() + .route("/pipe-combo", on(MethodFilter::GET | MethodFilter::QUERY, handler)) + } + RUST + + endpoints = instance.analyze_file(temp_file) + endpoints.size.should eq(2) + endpoints[0].url.should eq("/pipe-combo") + endpoints[0].method.should eq("GET") + endpoints[1].url.should eq("/pipe-combo") + endpoints[1].method.should eq("QUERY") + + File.delete(temp_file) + Dir.delete(temp_dir) + end + + it "detects on(MethodFilter::all(), ...) and fans out" do + instance = Analyzer::Rust::Axum.new(options) + temp_dir = File.tempname("axum_test") + Dir.mkdir_p(temp_dir) + temp_file = File.join(temp_dir, "test.rs") + + File.write(temp_file, <<-RUST) + use axum::{routing::{on, MethodFilter}, Router}; + + async fn handler() {} + + fn app() -> Router { + Router::new() + .route("/all-methods", on(MethodFilter::all(), handler)) + } + RUST + + endpoints = instance.analyze_file(temp_file) + endpoints.size.should eq(7) + endpoints.map(&.method).sort!.should eq(%w[DELETE GET HEAD OPTIONS PATCH POST PUT].sort!) + + File.delete(temp_file) + Dir.delete(temp_dir) + end + + it "propagates nest prefix to QUERY routes" do + instance = Analyzer::Rust::Axum.new(options) + temp_dir = File.tempname("axum_test") + Dir.mkdir_p(temp_dir) + temp_file = File.join(temp_dir, "test.rs") + + File.write(temp_file, <<-RUST) + use axum::{routing::{get, on, query, MethodFilter}, Router}; + + async fn handler() {} + + fn api_routes() -> Router { + Router::new() + .route("/search", query(handler)) + .route("/items", get(handler).query(handler)) + .route("/filter", on(MethodFilter::QUERY, handler)) + } + + fn app() -> Router { + Router::new() + .nest("/api/v1", api_routes()) + .nest("/scoped", Router::new().route("/direct-query", query(handler))) + } + RUST + + endpoints = instance.analyze_file(temp_file) + urls_and_methods = endpoints.map { |e| {e.url, e.method} } + + urls_and_methods.should contain({"/api/v1/search", "QUERY"}) + urls_and_methods.should contain({"/api/v1/items", "GET"}) + urls_and_methods.should contain({"/api/v1/items", "QUERY"}) + urls_and_methods.should contain({"/api/v1/filter", "QUERY"}) + urls_and_methods.should contain({"/scoped/direct-query", "QUERY"}) + + File.delete(temp_file) + Dir.delete(temp_dir) + end + + it "detects chained .on() and on_service()" do + instance = Analyzer::Rust::Axum.new(options) + temp_dir = File.tempname("axum_test") + Dir.mkdir_p(temp_dir) + temp_file = File.join(temp_dir, "test.rs") + + File.write(temp_file, <<-RUST) + use axum::{routing::{get, on, on_service, MethodFilter}, Router}; + + async fn list_handler() {} + async fn query_handler() {} + + fn app() -> Router { + Router::new() + .route("/items", get(list_handler).on(MethodFilter::QUERY, query_handler)) + .route("/svc", on_service(MethodFilter::QUERY, my_svc)) + .route("/multi", on(MethodFilter::GET.or(MethodFilter::POST).or(MethodFilter::QUERY), list_handler)) + } + RUST + + endpoints = instance.analyze_file(temp_file) + urls_and_methods = endpoints.map { |e| {e.url, e.method} } + + urls_and_methods.should contain({"/items", "GET"}) + urls_and_methods.should contain({"/items", "QUERY"}) + urls_and_methods.should contain({"/svc", "QUERY"}) + urls_and_methods.should contain({"/multi", "GET"}) + urls_and_methods.should contain({"/multi", "POST"}) + urls_and_methods.should contain({"/multi", "QUERY"}) + + File.delete(temp_file) + Dir.delete(temp_dir) + end + + it "detects custom route builders with query and unauthenticated_query" do + instance = Analyzer::Rust::Axum.new(options) + temp_dir = File.tempname("axum_test") + Dir.mkdir_p(temp_dir) + temp_file = File.join(temp_dir, "test.rs") + + File.write(temp_file, <<-RUST) + fn app() { + RouteBuilder::new() + .query("/custom-query", handler) + .unauthenticated_query("/open-query", handler); + } + RUST + + endpoints = instance.analyze_file(temp_file) + urls_and_methods = endpoints.map { |e| {e.url, e.method} } + + urls_and_methods.should contain({"/custom-query", "QUERY"}) + urls_and_methods.should contain({"/open-query", "QUERY"}) + + File.delete(temp_file) + Dir.delete(temp_dir) + end + + it "ignores QUERY routes inside #[cfg(test)] mod tests" do + instance = Analyzer::Rust::Axum.new(options) + temp_dir = File.tempname("axum_test") + Dir.mkdir_p(temp_dir) + temp_file = File.join(temp_dir, "test.rs") + + File.write(temp_file, <<-RUST) + use axum::{routing::query, Router}; + + async fn prod_handler() {} + async fn test_handler() {} + + fn app() -> Router { + Router::new() + .route("/prod-query", query(prod_handler)) + } + + #[cfg(test)] + mod tests { + use super::*; + + fn test_app() -> Router { + Router::new() + .route("/test-query", query(test_handler)) + } + } + RUST + + endpoints = instance.analyze_file(temp_file) + urls = endpoints.map(&.url) + + urls.should contain("/prod-query") + urls.should_not contain("/test-query") + + File.delete(temp_file) + Dir.delete(temp_dir) + end +end diff --git a/src/analyzer/analyzers/rust/axum.cr b/src/analyzer/analyzers/rust/axum.cr index 6f9bd42f5..ecfc91a05 100644 --- a/src/analyzer/analyzers/rust/axum.cr +++ b/src/analyzer/analyzers/rust/axum.cr @@ -37,7 +37,7 @@ module Analyzer::Rust # legacy fallback the regex analyzer used. `any` covers # `axum::routing::any(handler)` — a verb-agnostic registration # commonly used for WebSocket upgrades and reverse-proxy fallbacks. - HTTP_VERBS = Set{"get", "post", "put", "delete", "patch", "head", "options", "any"} + HTTP_VERBS = Set{"get", "post", "put", "delete", "patch", "head", "options", "any", "query"} # Method emitted for service-shaped registrations (`route_service`, # `nest_service`, `fallback_service`). Services aren't bound to a @@ -234,10 +234,10 @@ module Analyzer::Rust } BUILDER_ROUTE_EMIT_NAMES = Set{ - "get", "post", "put", "delete", "patch", "head", "options", "any", + "get", "post", "put", "delete", "patch", "head", "options", "any", "query", "unauthenticated_get", "unauthenticated_post", "unauthenticated_put", "unauthenticated_delete", "unauthenticated_patch", "unauthenticated_head", - "unauthenticated_options", "unauthenticated_any", + "unauthenticated_options", "unauthenticated_any", "unauthenticated_query", } private def router_emit?(call : LibTreeSitter::TSNode, source : String) : Bool @@ -528,7 +528,7 @@ module Analyzer::Rust return false unless fn_node text = Noir::TreeSitter.node_text(fn_node, source) - !!text.match(/(?:^|::)[A-Za-z_]\w*(?:RouteBuilder|RouterBuilder)::new$/) + !!text.match(/(?:^|::)(?:[A-Za-z_]\w*)?(?:RouteBuilder|RouterBuilder)::new$/) end # Returns `{path, [{http_method, handler_name?}, ...]}` for a valid @@ -628,21 +628,37 @@ module Analyzer::Rust break unless fn_node case Noir::TreeSitter.node_type(fn_node) when "identifier", "scoped_identifier" - # Innermost: `get(handler)` or aide's `get_with(handler, op)`. - verb = normalize_method_verb(Noir::TreeSitter.node_text(fn_node, source).split("::").last) - if verb - handlers.unshift({verb, first_callable_argument(cursor, source)}) + # Innermost: `get(handler)`, `query(handler)`, `on(MethodFilter::QUERY, handler)`, or aide's `get_with(handler, op)`. + call_name = Noir::TreeSitter.node_text(fn_node, source).split("::").last + if on_method_filter_call?(call_name) + verbs, handler_name = extract_on_call(cursor, source) + verbs.reverse_each do |verb| + handlers.unshift({verb, handler_name}) + end + else + verb = normalize_method_verb(call_name) + if verb + handlers.unshift({verb, first_callable_argument(cursor, source)}) + end end break when "field_expression" - # Chained: `.post(...)` / `.post_with(...)`. Field is + # Chained: `.post(...)` / `.post_with(...)` / `.on(...)`. Field is # the verb. Non-verb layers (`.layer(...)`, `.route_layer(...)`) # are transparent. field = Noir::TreeSitter.field(fn_node, "field") if field - verb = normalize_method_verb(Noir::TreeSitter.node_text(field, source)) - if verb - handlers.unshift({verb, first_callable_argument(cursor, source)}) + field_name = Noir::TreeSitter.node_text(field, source) + if on_method_filter_call?(field_name) + verbs, handler_name = extract_on_call(cursor, source) + verbs.reverse_each do |verb| + handlers.unshift({verb, handler_name}) + end + else + verb = normalize_method_verb(field_name) + if verb + handlers.unshift({verb, first_callable_argument(cursor, source)}) + end end end inner = Noir::TreeSitter.field(fn_node, "value") @@ -657,13 +673,81 @@ module Analyzer::Rust handlers end + private def on_method_filter_call?(name : String) : Bool + verb = name.downcase + verb == "on" || verb == "on_service" || verb == "on_with" || verb == "on_service_with" + end + + private def extract_on_call(call : LibTreeSitter::TSNode, source : String) : Tuple(Array(String), String?) + args = named_arguments(call) + return {["GET"], nil.as(String?)} if args.empty? + + verbs = extract_method_filter_verbs(args[0], source) + verbs = ["GET"] if verbs.empty? + + handler_name = args.size > 1 ? callable_text(args[1], source) : nil + {verbs, handler_name} + end + + private def extract_method_filter_verbs(node : LibTreeSitter::TSNode, source : String) : Array(String) + verbs = [] of String + collect_method_filter_verbs(node, source, verbs) + verbs.uniq! + verbs + end + + private def collect_method_filter_verbs(node : LibTreeSitter::TSNode, source : String, verbs : Array(String)) + case Noir::TreeSitter.node_type(node) + when "parenthesized_expression" + Noir::TreeSitter.each_named_child(node) do |child| + collect_method_filter_verbs(child, source, verbs) + end + when "binary_expression" + left = Noir::TreeSitter.field(node, "left") + right = Noir::TreeSitter.field(node, "right") + collect_method_filter_verbs(left, source, verbs) if left + collect_method_filter_verbs(right, source, verbs) if right + when "call_expression" + fn_node = Noir::TreeSitter.field(node, "function") + if fn_node + case Noir::TreeSitter.node_type(fn_node) + when "field_expression" + field = Noir::TreeSitter.field(fn_node, "field") + value = Noir::TreeSitter.field(fn_node, "value") + field_name = field ? Noir::TreeSitter.node_text(field, source) : "" + if field_name == "or" || field_name == "union" + collect_method_filter_verbs(value, source, verbs) if value + args = named_arguments(node) + args.each { |arg| collect_method_filter_verbs(arg, source, verbs) } + end + when "scoped_identifier", "identifier" + fn_text = Noir::TreeSitter.node_text(fn_node, source) + if fn_text.ends_with?("::all") || fn_text == "all" + verbs << "ANY" + end + end + end + when "scoped_identifier", "identifier" + text = Noir::TreeSitter.node_text(node, source) + leaf = text.split("::").last + case leaf.upcase + when "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE", "QUERY" + verbs << leaf.upcase + when "ALL" + verbs << "ANY" + end + end + end + # Map a method-router constructor name to its canonical HTTP verb, # or `nil` if it isn't one. Handles plain axum verbs (`get`, `post`, - # … `any`) and aide's operation-annotated `*_with` variants - # (`get_with`, `post_with`, …). Returns the upcased verb. + # … `any`, `query`) and aide's operation-annotated `*_with` variants + # (`get_with`, `post_with`, …) and `*_service` variants (`query_service`, …). + # Returns the upcased verb. private def normalize_method_verb(name : String) : String? verb = name.downcase verb = verb[0...-"_with".size] if verb.ends_with?("_with") + verb = verb[0...-"_service".size] if verb.ends_with?("_service") HTTP_VERBS.includes?(verb) ? verb.upcase : nil end diff --git a/src/models/analyzer.cr b/src/models/analyzer.cr index cc4117fc0..d01c40f26 100644 --- a/src/models/analyzer.cr +++ b/src/models/analyzer.cr @@ -1,6 +1,7 @@ require "./logger" require "./endpoint" require "./file_helper" +require "./code_locator" require "wait_group" require "../utils/media_filter" require "../utils/path_scope"