use super::*;
use axum::routing::get;
struct OneApi(&'static str);
impl protox::file::FileResolver for OneApi {
fn open_file(&self, name: &str) -> Result<protox::file::File, protox::Error> {
let source = match name {
"google/api/annotations.proto" => {
r#"syntax = "proto3";
package google.api;
import "google/protobuf/descriptor.proto";
message HttpRule {
oneof pattern { string get = 2; string post = 4; }
string body = 7;
string response_body = 12;
}
extend google.protobuf.MethodOptions { HttpRule http = 72295728; }
"#
}
"api.proto" => self.0,
_ => return protox::file::GoogleFileResolver::new().open_file(name),
};
protox::file::File::from_source(name, source)
}
}
fn api_pool(source: &'static str) -> DescriptorPool {
protox::Compiler::with_file_resolver(OneApi(source))
.open_file("api.proto")
.unwrap()
.descriptor_pool()
}
#[test]
fn response_body_names_proto_fields_not_json_keys() {
let pool = api_pool(
r#"syntax = "proto3";
package t;
message Inner { string display_name = 1; }
message Resp { Inner user_info = 1; }
"#,
);
let inner_desc = pool.get_message_by_name("t.Inner").unwrap();
let mut inner = DynamicMessage::new(inner_desc);
inner.set_field_by_name("display_name", prost_reflect::Value::String("Ann".into()));
let mut resp = DynamicMessage::new(pool.get_message_by_name("t.Resp").unwrap());
resp.set_field_by_name("user_info", prost_reflect::Value::Message(inner));
let json = |path| {
serde_json::from_slice::<serde_json::Value>(&json_body(&resp, Some(path)).unwrap()).unwrap()
};
assert_eq!(json("user_info"), serde_json::json!({"displayName": "Ann"}));
assert_eq!(json("user_info.display_name"), serde_json::json!("Ann"));
assert_eq!(json("user_info.missing"), serde_json::Value::Null);
assert_eq!(json("userInfo"), serde_json::Value::Null);
}
#[test]
fn alias_paths_are_converted_like_the_route_they_alias() {
let pool = api_pool(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string path = 1; }
service S {
rpc Get(Req) returns (Req) { option (google.api.http) = { get: "/v1/files/{path=**}" }; }
rpc Watch(Req) returns (stream Req) { option (google.api.http) = { get: "/v1/logs/{path=*}" }; }
}
"#,
);
let alias: AliasConfig = serde_yaml::from_str("from: /api/{path}\nto: /v1").unwrap();
let paths = route_paths(&pool, &[alias], &RpcSelection::default());
for expected in [
"/v1/files/{*path}",
"/api/files/{*path}",
"/v1/logs/{path}",
"/api/logs/{path}",
] {
assert!(
paths.contains(&("GET".to_owned(), expected.to_owned())),
"{expected} missing from {paths:?}"
);
}
}
#[test]
fn test_proto_path_to_axum() {
assert_eq!(proto_path_to_axum("/v1/profiles/{id}"), "/v1/profiles/{id}");
assert_eq!(
proto_path_to_axum("/v1/admin/profiles/{profile_id}/metadata/{key}"),
"/v1/admin/profiles/{profile_id}/metadata/{key}"
);
assert_eq!(proto_path_to_axum("/v1/auth/login"), "/v1/auth/login");
}
#[test]
fn test_proto_path_to_axum_wildcards() {
assert_eq!(proto_path_to_axum("/v1/{name=*}"), "/v1/{name}");
assert_eq!(
proto_path_to_axum("/v1/files/{path=**}"),
"/v1/files/{*path}"
);
assert_eq!(proto_path_to_axum("/v1/*/items"), "/v1/{2}/items");
assert_eq!(proto_path_to_axum("/v1/files/**"), "/v1/files/{*3}");
let _router: Router<()> = Router::new()
.route("/v1/{2}/items", get(|| async { "ok" }))
.route("/v1/files/{*3}", get(|| async { "ok" }));
}
#[test]
fn non_terminal_field_template_is_spelled_out_in_the_path() {
assert_eq!(
proto_path_to_axum("/v1/{name=projects/*}/topics"),
"/v1/projects/{name.1}/topics"
);
let path = proto_path_to_axum("/v1/{name=projects/*}/topics");
let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" }));
assert!(path::MountedPath::new("/v1/{name=projects/*}/topics")
.unsupported
.is_none());
assert!(path::MountedPath::new("/v1/{rest=**}/tail")
.unsupported
.is_some());
assert!(path::MountedPath::new("/v1/{rest=a/**}/tail")
.unsupported
.is_some());
assert_eq!(
proto_path_to_axum("/v1/files/{rest=**}"),
"/v1/files/{*rest}"
);
for template in ["/v1/{name=**/**/x}", "/v1/{name=a/**/b}"] {
assert!(
path::MountedPath::new(template).routable().is_err(),
"{template}"
);
}
assert!(path::MountedPath::new("/v1/{name=a/**}").routable().is_ok());
for template in ["/v1/{parent=publishers/{id}}/books", "/v1/{name=a/{id}}"] {
assert!(
path::MountedPath::new(template).routable().is_err(),
"{template}"
);
}
}
#[test]
fn a_non_terminal_field_template_binds_only_its_own_shape() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string parent = 1; string id = 2; }
service S {
rpc Cancel(Req) returns (Req) { option (google.api.http) = { post: "/v1/{parent=publishers/*}/books/{id}:cancel" }; }
}
"#,
);
assert_eq!(
answer(&routes, Method::POST, "/v1/publishers/p1/books/x:cancel"),
"/t.S/Cancel"
);
assert_eq!(
answer(&routes, Method::POST, "/v1/anything/books/x:cancel"),
"no route"
);
let (table, index) = bound_in(&routes, Method::POST, "/v1/publishers/p1/books/x:cancel");
let mut params: PathParams<'_> = vec![("parent.1", "p1".into()), ("id", "x:cancel".into())];
routes.tables[table].bind_params(index, &mut params);
let expected = named(&[("parent", "publishers/p1"), ("id", "x")]);
assert_eq!(by_name(¶ms), expected);
}
fn named(fields: &[(&str, &str)]) -> HashMap<String, String> {
fields
.iter()
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect()
}
fn by_name(params: &PathParams<'_>) -> HashMap<String, String> {
let by_name: HashMap<String, String> = params
.iter()
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect();
assert_eq!(
by_name.len(),
params.len(),
"a name bound twice: {params:?}"
);
by_name
}
fn bound_fields(routes: &table::Routes, method: Method, path: &str) -> HashMap<String, String> {
let (table, index) = bound_in(routes, method, path);
let mut router = matchit::Router::new();
router
.insert(routes.tables[table].path.as_str(), ())
.unwrap();
let matched = router.at(path).unwrap();
let mut params: PathParams<'_> = matched
.params
.iter()
.map(|(name, value)| {
let value = percent_encoding::percent_decode_str(value)
.decode_utf8()
.unwrap();
(name, value)
})
.collect();
routes.tables[table].bind_params(index, &mut params);
by_name(¶ms)
}
#[test]
fn an_encoded_literal_of_a_spelled_out_template_is_decoded_in_its_field() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string parent = 1; string id = 2; }
service S {
rpc Get(Req) returns (Req) { option (google.api.http) = { get: "/v1/{parent=publishers/foo%3Abar/*}/books/{id}" }; }
}
"#,
);
let expected = named(&[("parent", "publishers/foo:bar/p1"), ("id", "x")]);
assert_eq!(
bound_fields(&routes, Method::GET, "/v1/publishers/foo%3Abar/p1/books/x"),
expected
);
assert_eq!(
bound_fields(&routes, Method::GET, "/v1/publishers/foo%3abar/p1/books/x"),
expected
);
assert_eq!(
answer(&routes, Method::GET, "/v1/publishers/foo/p1/books/x"),
"404"
);
}
#[test]
fn a_path_ending_in_a_literal_its_bindings_refuse_leaves_the_url_to_others() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string parent = 1; string x = 2; string y = 3; string z = 4; }
service S {
rpc Books(Req) returns (Req) { option (google.api.http) = { get: "/v1/{parent=foo%3Abar/*}/books" }; }
rpc Any(Req) returns (Req) { option (google.api.http) = { get: "/v1/{x}/{y}/{z}" }; }
}
"#,
);
assert_eq!(
answer(&routes, Method::GET, "/v1/other/x/books"),
"/t.S/Any"
);
assert_eq!(
answer(&routes, Method::GET, "/v1/foo%3abar/x/books"),
"/t.S/Books"
);
}
#[test]
fn a_constrained_binding_of_a_shared_path_wins_over_an_earlier_open_one() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string parent = 1; string x = 2; string y = 3; string path = 4; string name = 5; }
service S {
rpc Open(Req) returns (Req) { option (google.api.http) = { get: "/v1/{x}/{y}/books" }; }
rpc Books(Req) returns (Req) { option (google.api.http) = { get: "/v1/{parent=foo%3Abar/*}/books" }; }
rpc All(Req) returns (Req) { option (google.api.http) = { get: "/v2/{path=**}" }; }
rpc Shelf(Req) returns (Req) { option (google.api.http) = { get: "/v2/{name=shelves/*}" }; }
}
"#,
);
let rpc = |path: &str| answer(&routes, Method::GET, path);
assert_eq!(rpc("/v1/foo%3Abar/p1/books"), "/t.S/Books");
assert_eq!(rpc("/v1/a/b/books"), "/t.S/Open");
assert_eq!(rpc("/v2/shelves/1"), "/t.S/Shelf");
assert_eq!(rpc("/v2/x/y"), "/t.S/All");
}
#[test]
fn spelled_out_templates_differing_only_in_an_encoded_literal_both_serve() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string p = 1; string q = 2; }
service S {
rpc A(Req) returns (Req) { option (google.api.http) = { get: "/v1/{p=a%3Ab/*}/x" }; }
rpc C(Req) returns (Req) { option (google.api.http) = { get: "/v1/{q=c%3Ad/*}/x" }; }
}
"#,
);
assert_eq!(answer(&routes, Method::GET, "/v1/a%3Ab/1/x"), "/t.S/A");
assert_eq!(answer(&routes, Method::GET, "/v1/c%3ad/1/x"), "/t.S/C");
}
#[test]
fn a_field_named_like_a_bare_wildcard_keeps_its_value() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string wildcard2 = 1; }
service S {
rpc Get(Req) returns (Req) { option (google.api.http) = { get: "/v1/*/{wildcard2}" }; }
}
"#,
);
let expected = named(&[("wildcard2", "b")]);
assert_eq!(bound_fields(&routes, Method::GET, "/v1/a/b"), expected);
}
#[test]
fn a_spelled_out_template_sharing_a_route_keeps_its_field() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string a = 1; string id = 2; }
service S {
rpc Get(Req) returns (Req) { option (google.api.http) = { get: "/v1/foo/{a}/books/{id}" }; }
rpc Cancel(Req) returns (Req) { option (google.api.http) = { post: "/v1/{a=foo/*}/books/{id}:cancel" }; }
}
"#,
);
let get = named(&[("a", "x"), ("id", "y")]);
assert_eq!(bound_fields(&routes, Method::GET, "/v1/foo/x/books/y"), get);
let cancel = named(&[("a", "foo/x"), ("id", "y")]);
assert_eq!(
bound_fields(&routes, Method::POST, "/v1/foo/x/books/y:cancel"),
cancel
);
}
#[test]
fn templates_differing_only_in_escape_case_are_one_binding() {
let pool = api_pool(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; }
service S {
rpc A(Req) returns (Req) { option (google.api.http) = { get: "/v1/{name=foo%3Abar/*}" }; }
rpc B(Req) returns (Req) { option (google.api.http) = { get: "/v1/{name=foo%3abar/*}" }; }
}
"#,
);
let paths = route_paths(&pool, &[], &RpcSelection::default());
assert_eq!(paths.len(), 2, "the duplicate is listed twice: {paths:?}");
}
#[test]
fn multi_segment_field_template_does_not_fracture() {
assert_eq!(
proto_path_to_axum("/v1/{name=shelves/*/books/*}"),
"/v1/{*name}"
);
let path = proto_path_to_axum("/v1/{name=shelves/*/books/*}");
let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" }));
}
#[test]
fn custom_verb_after_a_variable_or_wildcard_is_split_off() {
let cases = [
(
"/v2/ops/{operation}:cancel",
"/v2/ops/{operation}",
"/v2/ops/{}",
&["operation"][..],
Some(":cancel"),
),
(
"/v1/{name=publishers/*/books/*}:archive",
"/v1/{*name}",
"/v1/{*}",
&["name"][..],
Some(":archive"),
),
(
"/v1/{name=*}:x",
"/v1/{name}",
"/v1/{}",
&["name"][..],
Some(":x"),
),
(
"/v1/files/{path=**}:x",
"/v1/files/{*path}",
"/v1/files/{*}",
&["path"][..],
Some(":x"),
),
("/v1/*:x", "/v1/{2}", "/v1/{}", &["2"][..], Some(":x")),
("/v1/**:x", "/v1/{*2}", "/v1/{*}", &["2"][..], Some(":x")),
("/v1/{a}:b:c", "/v1/{a}", "/v1/{}", &["a"][..], Some(":b:c")),
];
for (template, axum, shape, captures, verb) in cases {
let mount = path::MountedPath::new(template);
assert_eq!(mount.axum, axum, "{template}");
assert_eq!(mount.shape, shape, "{template}");
assert_eq!(mount.captures, captures, "{template}");
assert_eq!(mount.verb.as_deref(), verb, "{template}");
assert_eq!(
mount.display(),
axum.to_owned() + verb.unwrap(),
"{template}"
);
assert!(path::mountable(&mount.axum).is_ok(), "{template}");
}
assert_eq!(proto_path_to_axum("/v2/ops/{op}:cancel"), "/v2/ops/{op}");
}
#[test]
fn verb_after_a_literal_or_anything_else_stays_in_the_path() {
let mount = path::MountedPath::new("/v1/nodes:batch");
assert_eq!(mount.axum, "/v1/nodes:batch");
assert_eq!(mount.verb, None);
assert!(mount.captures.is_empty());
let mount = path::MountedPath::new("/v1/{a}:x/items");
assert_eq!(mount.verb, None);
assert_eq!(mount.axum, "/v1/{a}:x/items");
for template in ["/v1/{a}:", "/v1/{a}x:y", "/v1/{a"] {
let mount = path::MountedPath::new(template);
assert_eq!(mount.verb, None, "{template}");
assert!(path::mountable(&mount.axum).is_err(), "{template}");
}
}
#[test]
fn mountable_refuses_what_axum_would_panic_on() {
for (path, reason) in [
("/v1/a{x}b", "text around a capture"),
("/v1/{x}{y}", "two captures in one segment"),
("/v1/{}", "a capture without a name"),
("v1/items", "no leading slash"),
("/v1/:items", "a segment starting with ':'"),
("/v1/*items", "a segment starting with '*'"),
] {
assert!(path::mountable(path).is_err(), "{path}: {reason}");
let registered = std::panic::catch_unwind(|| {
let _router: Router<()> = Router::new().route(path, get(|| async { "ok" }));
});
assert!(registered.is_err(), "{path}: axum took it");
}
for path in ["/v1/{x}", "/v1/{*rest}", "/v1/nodes:batch", "/"] {
assert!(path::mountable(path).is_ok(), "{path}");
}
}
#[test]
fn a_route_with_more_captures_than_the_router_holds_is_refused() {
let captures = |count: usize| (0..count).map(|k| format!("/{{c{k}}}")).collect::<String>();
assert!(path::mountable(&captures(25)).is_ok());
assert!(path::mountable(&format!("{}/{{*rest}}", captures(25))).is_ok());
assert!(path::mountable(&captures(26)).is_err());
let stars = vec!["*"; 26].join("/");
let mount = path::MountedPath::new(&format!("/v1/{{parent={stars}}}/books"));
assert!(mount.routable().is_err());
}
#[test]
fn route_paths_list_one_route_for_bindings_that_differ_only_by_verb() {
let pool = api_pool(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; string id = 2; }
service S {
rpc Get(Req) returns (Req) { option (google.api.http) = { get: "/v1/ops/{name}" }; }
rpc Cancel(Req) returns (Req) { option (google.api.http) = { post: "/v1/ops/{name}:cancel" }; }
rpc Pause(Req) returns (Req) { option (google.api.http) = { post: "/v1/ops/{id}:pause" }; }
rpc Batch(Req) returns (Req) { option (google.api.http) = { post: "/v1/ops:batch" }; }
rpc Again(Req) returns (Req) { option (google.api.http) = { post: "/v1/ops/{id}:cancel" }; }
}
"#,
);
let paths = route_paths(&pool, &[], &RpcSelection::default());
let expected = [
("*", "/v1/ops/{name}"),
("POST", "/v1/ops/{name}"),
("POST", "/v1/ops:batch"),
];
let expected: Vec<(String, String)> = expected
.iter()
.map(|(m, p)| ((*m).to_owned(), (*p).to_owned()))
.collect();
assert_eq!(paths, expected);
}
#[test]
fn bindings_of_one_shape_share_one_table_whatever_their_names() {
let pool = api_pool(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; string id = 2; }
service S {
rpc Get(Req) returns (Req) { option (google.api.http) = { get: "/v1/ops/{name}" }; }
rpc Post(Req) returns (Req) { option (google.api.http) = { post: "/v1/ops/{id}" }; }
rpc Broken(Req) returns (Req) { option (google.api.http) = { get: "/v1/broken/a{name}b" }; }
}
"#,
);
let tables = path_tables(&pool, &[], &TranscodeOptions::default());
let paths: Vec<&str> = tables.iter().map(|t| t.path.as_str()).collect();
assert_eq!(paths, ["/v1/ops/{name}"]);
let built = table::Routes::new(tables);
assert_eq!(answer(&built, Method::POST, "/v1/ops/x"), "/t.S/Post");
let _router: Router<crate::ProxyState<tonic::transport::Channel>> = routes(&pool, &[]);
}
#[test]
fn swapped_capture_names_bind_each_binding_its_own_values() {
let pool = api_pool(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string a = 1; string b = 2; }
service S {
rpc Get(Req) returns (Req) { option (google.api.http) = { get: "/x/{a}/{b}" }; }
rpc Post(Req) returns (Req) { option (google.api.http) = { post: "/x/{b}/{a}:go" }; }
}
"#,
);
let tables = path_tables(&pool, &[], &TranscodeOptions::default());
assert_eq!(tables.len(), 1);
let routes = table::Routes::new(tables);
let table = &routes.tables[0];
let matched =
|b: &'static str| -> PathParams<'static> { vec![("a", "first".into()), ("b", b.into())] };
let post = bound(&routes, Method::POST, "/x/first/second:go");
let mut params = matched("second:go");
table.bind_params(post, &mut params);
assert_eq!(by_name(¶ms), named(&[("b", "first"), ("a", "second")]));
let get = bound(&routes, Method::GET, "/x/first/second");
let mut params = matched("second");
table.bind_params(get, &mut params);
assert_eq!(params, matched("second"));
}
#[test]
fn a_trailing_double_wildcard_of_a_template_may_match_no_segment_before_a_verb() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; }
service S {
rpc Purge(Req) returns (Req) { option (google.api.http) = { post: "/v1/{name=shelves/**}:purge" }; }
}
"#,
);
assert_eq!(
answer(&routes, Method::POST, "/v1/shelves/:purge"),
"/t.S/Purge"
);
assert_eq!(
bound_fields(&routes, Method::POST, "/v1/shelves/:purge"),
named(&[("name", "shelves/")])
);
}
#[test]
fn a_capture_named_as_a_prefix_one_takes_the_verb_off_its_own_value() {
let pool = api_pool(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; }
service S {
rpc Cancel(Req) returns (Req) { option (google.api.http) = { post: "/v1/{name}:cancel" }; }
}
"#,
);
let routes = table::Routes::new(path_tables(&pool, &[], &TranscodeOptions::default()));
let index = bound(&routes, Method::POST, "/v1/foo:cancel");
let mut params: PathParams<'_> = vec![("name", "outer".into()), ("name", "foo:cancel".into())];
routes.tables[0].bind_params(index, &mut params);
let expected: PathParams<'_> = vec![("name", "outer".into()), ("name", "foo".into())];
assert_eq!(params, expected);
}
fn bound(routes: &table::Routes, method: Method, path: &str) -> usize {
match routes.choose(0, &method, path) {
table::Choice::Route { table: 0, index } => index,
_ => panic!("{method} {path} must be answered by the only table"),
}
}
fn answer(routes: &table::Routes, method: Method, path: &str) -> String {
let mut router = matchit::Router::new();
for (index, table) in routes.tables.iter().enumerate() {
router.insert(table.path.as_str(), index).unwrap();
}
let Ok(matched) = router.at(path) else {
return "no route".to_owned();
};
match routes.choose(*matched.value, &method, path) {
table::Choice::Route { table, index } => routes.tables[table]
.entry(index)
.grpc_path
.path()
.to_owned(),
table::Choice::MethodNotAllowed { allow, .. } => {
format!("405 {}", allow.to_str().unwrap())
}
table::Choice::NotFound => "404".to_owned(),
}
}
#[test]
fn a_one_segment_template_shares_its_route_with_a_plain_variable() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; string id = 2; }
service S {
rpc Fixed(Req) returns (Req) { option (google.api.http) = { get: "/v1/{name=foo}" }; }
rpc Open(Req) returns (Req) { option (google.api.http) = { get: "/v1/{id}" }; }
}
"#,
);
assert_eq!(answer(&routes, Method::GET, "/v1/foo"), "/t.S/Fixed");
assert_eq!(answer(&routes, Method::GET, "/v1/bar"), "/t.S/Open");
}
#[test]
fn the_transcoded_paths_rank_among_the_other_routes_as_one_router_does() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; }
service S {
rpc Get(Req) returns (Req) { option (google.api.http) = { get: "/v1/{name}" }; }
rpc Batch(Req) returns (Req) { option (google.api.http) = { post: "/v1/nodes:batch" }; }
}
"#,
);
let table = |path: &str| routes.tables.iter().position(|t| t.path == path).unwrap();
let (get, batch) = (table("/v1/{name}"), table("/v1/nodes:batch"));
let others = [
(Some(Method::GET), "/v1/health".to_owned()),
(Some(Method::GET), "/v1/nodes:batch".to_owned()),
(None, "/verify".to_owned()),
];
let ranked = table::Ranked::new(&routes, &others).unwrap();
assert_eq!(ranked.table(&Method::GET, "/v1/a"), Some(get));
assert_eq!(ranked.table(&Method::GET, "/v1/health"), None);
assert_eq!(ranked.table(&Method::POST, "/v1/health"), None);
assert_eq!(ranked.table(&Method::POST, "/v1/nodes:batch"), Some(batch));
assert_eq!(ranked.table(&Method::GET, "/v1/nodes:batch"), None);
assert_eq!(ranked.table(&Method::HEAD, "/v1/nodes:batch"), None);
assert_eq!(ranked.table(&Method::DELETE, "/verify"), None);
assert_eq!(ranked.table(&Method::GET, "/v2/a"), None);
let partial = [(Some(Method::GET), "/v1/{a}.json".to_owned())];
assert!(table::Ranked::new(&routes, &partial).is_none());
}
fn routes_of(source: &'static str) -> table::Routes {
table::Routes::new(path_tables(
&api_pool(source),
&[],
&TranscodeOptions::default(),
))
}
#[test]
fn the_verb_is_the_last_segment_from_its_first_colon() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; }
service S {
rpc Short(Req) returns (Req) { option (google.api.http) = { post: "/v1/{name}:c" }; }
rpc Long(Req) returns (Req) { option (google.api.http) = { get: "/v1/{name}:b:c" }; }
}
"#,
);
assert_eq!(answer(&routes, Method::POST, "/v1/x:b:c"), "405 GET, HEAD");
assert_eq!(answer(&routes, Method::GET, "/v1/x:b:c"), "/t.S/Long");
assert_eq!(answer(&routes, Method::POST, "/v1/x:c"), "/t.S/Short");
assert_eq!(answer(&routes, Method::POST, "/v1/x:d"), "404");
}
#[test]
fn a_verb_matches_whatever_the_case_of_its_escapes() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; }
service S {
rpc Get(Req) returns (Req) { option (google.api.http) = { get: "/v1/{name}" }; }
rpc RunNow(Req) returns (Req) { option (google.api.http) = { post: "/v1/{name}:run%3anow" }; }
}
"#,
);
assert_eq!(
answer(&routes, Method::POST, "/v1/job:run%3Anow"),
"/t.S/RunNow"
);
assert_eq!(
answer(&routes, Method::POST, "/v1/job:run%3anow"),
"/t.S/RunNow"
);
}
#[test]
fn a_literal_route_keeps_its_url_over_a_verb_after_a_variable() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; }
service S {
rpc Special(Req) returns (Req) { option (google.api.http) = { get: "/v1/jobs/special:cancel" }; }
rpc Cancel(Req) returns (Req) { option (google.api.http) = { post: "/v1/jobs/{name}:cancel" }; }
}
"#,
);
assert_eq!(
answer(&routes, Method::GET, "/v1/jobs/special:cancel"),
"/t.S/Special"
);
assert_eq!(
answer(&routes, Method::POST, "/v1/jobs/other:cancel"),
"/t.S/Cancel"
);
}
#[test]
fn a_path_with_only_verb_bindings_does_not_hide_a_plain_route() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string id = 1; string path = 2; }
service S {
rpc Cancel(Req) returns (Req) { option (google.api.http) = { post: "/v1/jobs/{id}:cancel" }; }
rpc Files(Req) returns (Req) { option (google.api.http) = { get: "/v1/{path=**}" }; }
}
"#,
);
assert_eq!(answer(&routes, Method::GET, "/v1/jobs/x"), "/t.S/Files");
assert_eq!(
answer(&routes, Method::POST, "/v1/jobs/x:cancel"),
"/t.S/Cancel"
);
}
#[test]
fn a_multi_segment_field_template_constrains_its_value() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; }
service S {
rpc Book(Req) returns (Req) { option (google.api.http) = { get: "/v1/{name=shelves/*/books/*}" }; }
rpc Shelf(Req) returns (Req) { option (google.api.http) = { get: "/v1/{name=shelves/*}" }; }
rpc Deep(Req) returns (Req) { option (google.api.http) = { get: "/v1/{name=deep/**}" }; }
}
"#,
);
assert_eq!(
answer(&routes, Method::GET, "/v1/shelves/s1/books/b1"),
"/t.S/Book"
);
assert_eq!(answer(&routes, Method::GET, "/v1/shelves/s1"), "/t.S/Shelf");
assert_eq!(answer(&routes, Method::GET, "/v1/deep/a/b/c"), "/t.S/Deep");
assert_eq!(answer(&routes, Method::GET, "/v1/deep"), "/t.S/Deep");
for path in [
"/v1/other/x",
"/v1/shelves/s1/books",
"/v1/shelves/s1/x/b1",
"/v1/shelves",
] {
assert_eq!(answer(&routes, Method::GET, path), "404", "{path}");
}
}
#[test]
fn a_template_that_does_not_fit_leaves_the_url_to_a_lower_ranked_path() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; string path = 2; }
service S {
rpc Special(Req) returns (Req) { option (google.api.http) = { get: "/v1/books/{name=special/*}" }; }
rpc Files(Req) returns (Req) { option (google.api.http) = { get: "/v1/{path=**}" }; }
rpc SpecialGo(Req) returns (Req) { option (google.api.http) = { post: "/v2/books/{name=special/*}:go" }; }
rpc FilesGo(Req) returns (Req) { option (google.api.http) = { post: "/v2/{path=**}:go" }; }
}
"#,
);
assert_eq!(
answer(&routes, Method::GET, "/v1/books/special/x"),
"/t.S/Special"
);
assert_eq!(
answer(&routes, Method::GET, "/v1/books/ordinary/x"),
"/t.S/Files"
);
assert_eq!(
answer(&routes, Method::POST, "/v2/books/special/x:go"),
"/t.S/SpecialGo"
);
assert_eq!(
answer(&routes, Method::POST, "/v2/books/ordinary/x:go"),
"/t.S/FilesGo"
);
}
#[test]
fn a_method_the_best_path_lacks_is_answered_by_a_lower_ranked_one() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; string path = 2; }
service S {
rpc Special(Req) returns (Req) { option (google.api.http) = { get: "/v1/books/{name=special/*}" }; }
rpc Upload(Req) returns (Req) { option (google.api.http) = { post: "/v1/{path=**}" }; }
}
"#,
);
assert_eq!(
answer(&routes, Method::GET, "/v1/books/special/x"),
"/t.S/Special"
);
assert_eq!(
answer(&routes, Method::POST, "/v1/books/special/x"),
"/t.S/Upload"
);
assert_eq!(
answer(&routes, Method::PUT, "/v1/books/special/x"),
"405 GET, HEAD"
);
}
#[test]
fn a_bare_wildcard_binds_no_field() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string wildcard2 = 1; string wildcard3 = 2; }
service S {
rpc Run(Req) returns (Req) { option (google.api.http) = { post: "/v1/*:run" }; }
rpc Get(Req) returns (Req) { option (google.api.http) = { get: "/v2/*/x/**" }; }
}
"#,
);
let run = bound_fields(&routes, Method::POST, "/v1/a:run");
assert!(run.is_empty(), "{run:?}");
let get = bound_fields(&routes, Method::GET, "/v2/a/x/b/c");
assert!(get.is_empty(), "{get:?}");
}
fn bound_in(routes: &table::Routes, method: Method, path: &str) -> (usize, usize) {
let mut router = matchit::Router::new();
for (index, table) in routes.tables.iter().enumerate() {
router.insert(table.path.as_str(), index).unwrap();
}
let matched = *router.at(path).unwrap().value;
match routes.choose(matched, &method, path) {
table::Choice::Route { table, index } => (table, index),
_ => panic!("{method} {path} must be answered"),
}
}
#[test]
fn head_falls_back_to_get_and_a_bound_verb_owns_its_url() {
let routes = routes_of(
r#"syntax = "proto3";
package t;
import "google/api/annotations.proto";
message Req { string name = 1; }
service S {
rpc Get(Req) returns (Req) { option (google.api.http) = { get: "/v1/{name}" }; }
rpc Short(Req) returns (Req) { option (google.api.http) = { post: "/v1/{name}:c" }; }
rpc Long(Req) returns (Req) { option (google.api.http) = { post: "/v1/{name}:b:c" }; }
}
"#,
);
let rpc = |method: Method, path: &str| answer(&routes, method, path);
assert_eq!(rpc(Method::HEAD, "/v1/x"), "/t.S/Get");
assert_eq!(rpc(Method::POST, "/v1/x:b:c"), "/t.S/Long");
assert_eq!(rpc(Method::POST, "/v1/x:c"), "/t.S/Short");
assert_eq!(rpc(Method::POST, "/v1/x"), "405 GET, HEAD");
assert_eq!(rpc(Method::PUT, "/v1/x:c"), "405 POST");
assert_eq!(rpc(Method::GET, "/v1/x:c"), "405 POST");
assert_eq!(rpc(Method::GET, "/v1/x:z"), "/t.S/Get");
}
#[test]
fn router_builds_with_brace_path_params_on_axum_0_8() {
let axum_path = proto_path_to_axum("/v1/profiles/{id}");
let _router: Router<()> = Router::new().route(&axum_path, get(|| async { "ok" }));
let nested = proto_path_to_axum("/v1/admin/profiles/{profile_id}/metadata/{key}");
let catch_all = proto_path_to_axum("/v1/files/{path=**}");
let _router: Router<()> = Router::new()
.route(&nested, get(|| async { "ok" }))
.route(&catch_all, get(|| async { "ok" }));
}
fn item_message() -> DynamicMessage {
item_message_named("alice", 42)
}
fn item_message_named(name: &str, count: i64) -> DynamicMessage {
use prost_reflect::prost::Message;
use prost_reflect::prost_types::{
field_descriptor_proto::{Label, Type},
DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet,
};
let item = DescriptorProto {
name: Some("Item".to_string()),
field: vec![
FieldDescriptorProto {
name: Some("name".to_string()),
number: Some(1),
label: Some(Label::Optional as i32),
r#type: Some(Type::String as i32),
..Default::default()
},
FieldDescriptorProto {
name: Some("count".to_string()),
number: Some(2),
label: Some(Label::Optional as i32),
r#type: Some(Type::Int64 as i32),
..Default::default()
},
],
..Default::default()
};
let file = FileDescriptorProto {
name: Some("item.proto".to_string()),
package: Some("test.v1".to_string()),
message_type: vec![item],
syntax: Some("proto3".to_string()),
..Default::default()
};
let mut bytes = Vec::new();
FileDescriptorSet { file: vec![file] }
.encode(&mut bytes)
.unwrap();
let pool = DescriptorPool::decode(bytes.as_slice()).unwrap();
let desc = pool.get_message_by_name("test.v1.Item").unwrap();
let mut msg = DynamicMessage::new(desc);
msg.set_field_by_name("name", prost_reflect::Value::String(name.to_string()));
msg.set_field_by_name("count", prost_reflect::Value::I64(count));
msg
}
async fn collect_body(resp: Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
fn no_details(status: &tonic::Status) -> serde_json::Value {
error::error_body(status, None)
}
#[tokio::test]
async fn ndjson_error_frame_is_terminal() {
let items = vec![
Ok(item_message_named("alice", 1)),
Err(tonic::Status::internal("boom")),
Ok(item_message_named("bob", 2)),
];
let body = collect_body(ndjson_response(
futures::stream::iter(items),
no_details,
false,
))
.await;
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 2, "stream must stop after the error frame");
assert!(lines[0].contains("alice"));
assert!(lines[1].contains("INTERNAL") && lines[1].contains("boom"));
assert!(!body.contains("bob"), "post-error message must be dropped");
}
#[tokio::test]
async fn sse_error_uses_distinct_event_name() {
let items = vec![
Ok(item_message_named("alice", 1)),
Err(tonic::Status::permission_denied("nope")),
Ok(item_message_named("bob", 2)),
];
let body = collect_body(sse_response(futures::stream::iter(items), no_details, 15)).await;
assert!(body.contains("stream-error"));
assert!(body.contains("PERMISSION_DENIED"));
assert!(!body.contains("bob"), "post-error message must be dropped");
}
#[tokio::test]
async fn ndjson_terminal_frame_carries_status_details() {
use tonic_types::{ErrorDetail, ErrorInfo, StatusExt};
let status = tonic::Status::with_error_details_vec(
tonic::Code::ResourceExhausted,
"quota",
[ErrorDetail::from(ErrorInfo::new(
"QUOTA",
"acme.example.com",
std::collections::HashMap::new(),
))],
);
let renderer = Arc::new(error::StatusDetails::new(&DescriptorPool::new()));
let mut expected = error::error_body(&status, Some(&renderer));
let render = move |s: &tonic::Status| error::error_body(s, Some(&renderer));
let items = vec![Ok(item_message_named("alice", 1)), Err(status)];
let body = collect_body(ndjson_response(futures::stream::iter(items), render, false)).await;
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 2);
let frame: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
expected["@type"] = STATUS_TYPE_URL.into();
assert_eq!(frame, expected);
assert_eq!(
frame["details"][0]["@type"],
"type.googleapis.com/google.rpc.ErrorInfo"
);
}
fn unserializable_message() -> DynamicMessage {
use prost_reflect::prost_types::{
field_descriptor_proto::{Label, Type},
DescriptorProto, FieldDescriptorProto, FileDescriptorProto,
};
let wrapper = DescriptorProto {
name: Some("Wrapper".to_string()),
field: vec![FieldDescriptorProto {
name: Some("payload".to_string()),
number: Some(1),
label: Some(Label::Optional as i32),
r#type: Some(Type::Message as i32),
type_name: Some(".google.protobuf.Any".to_string()),
..Default::default()
}],
..Default::default()
};
let file = FileDescriptorProto {
name: Some("wrapper.proto".to_string()),
package: Some("test.v1".to_string()),
dependency: vec!["google/protobuf/any.proto".to_string()],
message_type: vec![wrapper],
syntax: Some("proto3".to_string()),
..Default::default()
};
let mut pool = DescriptorPool::global();
pool.add_file_descriptor_proto(file).unwrap();
let desc = pool.get_message_by_name("test.v1.Wrapper").unwrap();
let any_desc = pool.get_message_by_name("google.protobuf.Any").unwrap();
let mut any = DynamicMessage::new(any_desc);
any.set_field_by_name(
"type_url",
prost_reflect::Value::String("type.googleapis.com/acme.v1.Unknown".into()),
);
let mut msg = DynamicMessage::new(desc);
msg.set_field_by_name("payload", prost_reflect::Value::Message(any));
assert!(message_to_json_string(&msg, &response_serialize_options()).is_err());
msg
}
#[tokio::test]
async fn serialization_failure_ends_the_stream_with_the_shared_error_body() {
let renderer = Arc::new(error::StatusDetails::new(&DescriptorPool::new()));
let render = move |s: &tonic::Status| error::error_body(s, Some(&renderer));
let items = vec![
Ok(item_message_named("alice", 1)),
Ok(unserializable_message()),
Ok(item_message_named("bob", 2)),
];
let body = collect_body(ndjson_response(futures::stream::iter(items), render, false)).await;
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 2, "{body}");
let frame: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(frame["@type"], STATUS_TYPE_URL);
assert_eq!(frame["error"], "INTERNAL");
assert_eq!(frame["code"], 13);
assert_eq!(frame["details"], serde_json::json!([]));
assert!(
frame["message"]
.as_str()
.unwrap()
.starts_with("serialization error: "),
"{frame}"
);
}
#[tokio::test]
async fn terminal_frame_ends_the_body_while_the_upstream_stays_open() {
let serialization_failure = futures::stream::iter(vec![Ok(unserializable_message())]);
let upstream_error = futures::stream::iter(vec![Err(tonic::Status::internal("boom"))]);
for (name, items) in [
("serialization failure", serialization_failure),
("upstream error", upstream_error),
] {
let open_upstream = items.chain(futures::stream::pending());
let body = tokio::time::timeout(
std::time::Duration::from_secs(1),
collect_body(ndjson_response(open_upstream, no_details, false)),
)
.await
.unwrap_or_else(|_| panic!("{name}: body stayed open after the terminal frame"));
assert_eq!(body.lines().count(), 1, "{name}: {body}");
}
}
#[tokio::test]
async fn sse_error_payload_is_the_unary_body_without_the_ndjson_marker() {
let status = tonic::Status::permission_denied("nope");
let expected = error::error_body(&status, None);
let items = vec![Err(status)];
let body = collect_body(sse_response(futures::stream::iter(items), no_details, 15)).await;
let payload = body
.lines()
.find_map(|line| line.strip_prefix("data: "))
.unwrap();
let frame: serde_json::Value = serde_json::from_str(payload).unwrap();
assert_eq!(frame, expected);
}
#[tokio::test]
async fn ndjson_envelope_wraps_data_and_error_lines() {
let status = tonic::Status::internal("boom");
let expected_error = error::error_body(&status, None);
let items = vec![
Ok(item_message_named("alice", 1)),
Err(status),
Ok(item_message_named("bob", 2)),
];
let body = collect_body(ndjson_response(
futures::stream::iter(items),
no_details,
true,
))
.await;
let lines: Vec<serde_json::Value> = body
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(
lines,
vec![
serde_json::json!({"result": {"name": "alice", "count": "1"}}),
serde_json::json!({"error": expected_error}),
]
);
}
#[test]
fn wants_sse_detects_event_stream_accept() {
let mut headers = HeaderMap::new();
headers.insert("accept", "text/event-stream".parse().unwrap());
assert!(wants_sse(&headers));
}
#[test]
fn wants_sse_matches_within_list_and_ignores_params() {
let mut headers = HeaderMap::new();
headers.insert(
"accept",
"application/json, text/event-stream;q=0.9".parse().unwrap(),
);
assert!(wants_sse(&headers));
}
#[test]
fn wants_sse_false_for_json_and_missing() {
let mut headers = HeaderMap::new();
headers.insert("accept", "application/json".parse().unwrap());
assert!(!wants_sse(&headers));
assert!(!wants_sse(&HeaderMap::new()));
}
#[test]
fn wants_sse_rejects_explicit_q_zero() {
let mut headers = HeaderMap::new();
headers.insert("accept", "text/event-stream;q=0".parse().unwrap());
assert!(!wants_sse(&headers));
}
#[test]
fn wants_sse_honors_second_accept_header_line() {
let mut headers = HeaderMap::new();
headers.append("accept", "application/json".parse().unwrap());
headers.append("accept", "text/event-stream".parse().unwrap());
assert!(wants_sse(&headers));
}
#[test]
fn message_to_json_string_stringifies_64bit() {
let opts = response_serialize_options();
let json = message_to_json_string(&item_message(), &opts).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["name"], "alice");
assert_eq!(value["count"], "42");
}
#[test]
fn ndjson_response_omits_manual_transfer_encoding() {
let resp = ndjson_response(
futures::stream::empty::<Result<DynamicMessage, tonic::Status>>(),
no_details,
false,
);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/x-ndjson"
);
assert!(resp.headers().get("transfer-encoding").is_none());
}
#[test]
fn stream_error_frame_carries_grpc_code_name() {
let status = tonic::Status::permission_denied("nope");
let value = no_details(&status);
assert_eq!(value["error"], "PERMISSION_DENIED");
assert_eq!(value["message"], "nope");
assert_eq!(value["code"], tonic::Code::PermissionDenied as i32);
}