1pub mod body;
9pub mod codec;
10pub mod error;
11pub mod metadata;
12pub mod request;
13
14use axum::extract::{Path, RawQuery, State};
15use axum::http::{HeaderMap, StatusCode};
16use axum::response::sse::{Event, KeepAlive, Sse};
17use axum::response::{IntoResponse, Response};
18use axum::routing::{delete, get, patch, post, put, MethodRouter};
19use axum::{Json, Router};
20use futures::StreamExt;
21use prost_reflect::{DescriptorPool, DynamicMessage, MethodDescriptor, SerializeOptions};
22use tonic::client::Grpc;
23
24use crate::config::AliasConfig;
25
26pub trait TranscodeState: Clone + Send + Sync + 'static {
31 fn grpc_channel(&self) -> tonic::transport::Channel;
33 fn forwarded_headers(&self) -> &[String];
35 fn sse_keep_alive_secs(&self) -> u64;
37}
38
39impl TranscodeState for crate::ProxyState {
40 fn grpc_channel(&self) -> tonic::transport::Channel {
41 self.grpc_channel.clone()
42 }
43 fn forwarded_headers(&self) -> &[String] {
44 &self.forwarded_headers
45 }
46 fn sse_keep_alive_secs(&self) -> u64 {
47 self.sse_keep_alive_secs
48 }
49}
50
51#[derive(Debug, Clone)]
53struct RouteEntry {
54 http_path: String,
56 http_method: HttpMethod,
58 grpc_path: axum::http::uri::PathAndQuery,
61 method: MethodDescriptor,
63 body: request::BodyMapping,
65 response_body: Option<String>,
67}
68
69#[derive(Debug, Clone, Copy)]
70enum HttpMethod {
71 Get,
72 Post,
73 Put,
74 Patch,
75 Delete,
76}
77
78impl HttpMethod {
79 fn as_str(self) -> &'static str {
81 match self {
82 HttpMethod::Get => "GET",
83 HttpMethod::Post => "POST",
84 HttpMethod::Put => "PUT",
85 HttpMethod::Patch => "PATCH",
86 HttpMethod::Delete => "DELETE",
87 }
88 }
89}
90
91pub fn routes<S: TranscodeState>(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Router<S> {
96 let bindings = route_bindings(pool, aliases);
97 if bindings.is_empty() {
98 tracing::warn!("No HTTP-annotated RPCs found in proto descriptors");
99 return Router::new();
100 }
101
102 tracing::info!("Registering {} transcoded REST→gRPC routes", bindings.len());
103
104 let mut router: Router<S> = Router::new();
105 for binding in bindings {
106 let method = binding.entry.http_method;
107 let entry = std::sync::Arc::new(binding.entry);
108 let method_router: MethodRouter<S> = if binding.streaming {
109 let handler = move |proxy_state: State<S>, headers: HeaderMap| {
110 streaming_handler(proxy_state, headers, entry)
111 };
112 match method {
113 HttpMethod::Get => get(handler),
114 HttpMethod::Post => post(handler),
115 _ => unreachable!("streaming routes are GET/POST only"),
117 }
118 } else {
119 let handler = move |proxy_state: State<S>,
120 headers: HeaderMap,
121 path_params: Path<std::collections::HashMap<String, String>>,
122 raw_query: RawQuery,
123 body: axum::body::Bytes| {
124 transcode_handler(proxy_state, headers, path_params, raw_query, body, entry)
125 };
126 match method {
127 HttpMethod::Get => get(handler),
128 HttpMethod::Post => post(handler),
129 HttpMethod::Put => put(handler),
130 HttpMethod::Patch => patch(handler),
131 HttpMethod::Delete => delete(handler),
132 }
133 };
134 router = router.route(&binding.axum_path, method_router);
135 }
136
137 router
138}
139
140struct RouteBinding {
143 entry: RouteEntry,
144 axum_path: String,
145 streaming: bool,
146}
147
148fn route_bindings(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Vec<RouteBinding> {
153 let mut bindings = Vec::new();
154 for entry in extract_routes(pool) {
155 bindings.push(RouteBinding {
156 axum_path: proto_path_to_axum(&entry.http_path),
157 entry: entry.clone(),
158 streaming: false,
159 });
160 for alias in aliases {
161 if let Some(suffix) = entry.http_path.strip_prefix(&alias.to) {
162 if alias.from.ends_with("/{path}") {
163 let prefix = alias.from.trim_end_matches("/{path}");
164 bindings.push(RouteBinding {
165 axum_path: format!("{prefix}{suffix}"),
166 entry: entry.clone(),
167 streaming: false,
168 });
169 }
170 }
171 }
172 }
173 for entry in extract_streaming_routes(pool) {
174 if matches!(entry.http_method, HttpMethod::Get | HttpMethod::Post) {
175 bindings.push(RouteBinding {
176 axum_path: proto_path_to_axum(&entry.http_path),
177 entry,
178 streaming: true,
179 });
180 }
181 }
182 bindings
183}
184
185pub fn route_paths(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Vec<(String, String)> {
194 route_bindings(pool, aliases)
195 .into_iter()
196 .map(|b| (b.entry.http_method.as_str().to_string(), b.axum_path))
197 .collect()
198}
199
200fn response_serialize_options() -> SerializeOptions {
203 SerializeOptions::new()
204 .skip_default_fields(false)
205 .stringify_64_bit_integers(true)
206}
207
208fn message_to_json_string(msg: &DynamicMessage, opts: &SerializeOptions) -> Result<String, String> {
210 let value = msg
211 .serialize_with_options(serde_json::value::Serializer, opts)
212 .map_err(|e| e.to_string())?;
213 serde_json::to_string(&value).map_err(|e| e.to_string())
214}
215
216fn stream_error_json(status: &tonic::Status) -> serde_json::Value {
219 serde_json::json!({
220 "error": error::grpc_code_name(status.code()),
221 "message": status.message(),
222 "code": status.code() as i32,
223 })
224}
225
226fn wants_sse(headers: &HeaderMap) -> bool {
234 headers
235 .get_all(axum::http::header::ACCEPT)
236 .iter()
237 .filter_map(|v| v.to_str().ok())
238 .flat_map(|accept| accept.split(','))
239 .any(accept_range_selects_sse)
240}
241
242fn accept_range_selects_sse(range: &str) -> bool {
245 let mut parts = range.split(';');
246 let media = parts.next().unwrap_or("").trim();
247 if !media.eq_ignore_ascii_case("text/event-stream") {
248 return false;
249 }
250 for param in parts {
253 let mut kv = param.splitn(2, '=');
254 if kv.next().unwrap_or("").trim().eq_ignore_ascii_case("q") {
255 let q: f32 = kv.next().unwrap_or("").trim().parse().unwrap_or(1.0);
256 return q > 0.0;
257 }
258 }
259 true
260}
261
262async fn streaming_handler<S: TranscodeState>(
269 State(proxy_state): State<S>,
270 headers: HeaderMap,
271 entry: std::sync::Arc<RouteEntry>,
272) -> Response {
273 let channel = proxy_state.grpc_channel();
274
275 let input_desc = entry.method.input();
276 let request_msg = DynamicMessage::new(input_desc);
277
278 let grpc_metadata =
279 metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
280 let mut grpc_request = tonic::Request::new(request_msg);
281 *grpc_request.metadata_mut() = grpc_metadata;
282 metadata::apply_request_deadline(&mut grpc_request, &headers);
283
284 let output_desc = entry.method.output();
285 let grpc_codec = codec::DynamicCodec::new(output_desc.clone());
286 let grpc_path = entry.grpc_path.clone();
287
288 let mut grpc_client = Grpc::new(channel);
289 if let Err(e) = grpc_client.ready().await {
290 return (
291 StatusCode::SERVICE_UNAVAILABLE,
292 Json(serde_json::json!({
293 "error": "UNAVAILABLE",
294 "message": format!("gRPC upstream not ready: {e}"),
295 })),
296 )
297 .into_response();
298 }
299
300 let use_sse = wants_sse(&headers);
301
302 match grpc_client
303 .server_streaming(grpc_request, grpc_path, grpc_codec)
304 .await
305 {
306 Ok(response) => {
307 let stream = response.into_inner();
308 if use_sse {
309 sse_response(stream, proxy_state.sse_keep_alive_secs())
310 } else {
311 ndjson_response(stream)
312 }
313 }
314 Err(status) => error::status_to_response(status),
315 }
316}
317
318enum StreamFrame {
324 Data(String),
325 Error(String),
326}
327
328fn json_frames<St>(stream: St) -> impl futures::Stream<Item = StreamFrame> + Send + 'static
335where
336 St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + 'static,
337{
338 let opts = response_serialize_options();
339 stream.scan(false, move |stopped, result| {
340 if *stopped {
341 return futures::future::ready(None);
342 }
343 let frame = match result {
344 Ok(msg) => match message_to_json_string(&msg, &opts) {
345 Ok(s) => StreamFrame::Data(s),
346 Err(e) => {
347 *stopped = true;
348 StreamFrame::Error(
349 serde_json::json!({
350 "error": "INTERNAL",
351 "message": format!("serialization error: {e}"),
352 })
353 .to_string(),
354 )
355 }
356 },
357 Err(status) => {
358 *stopped = true;
359 StreamFrame::Error(stream_error_json(&status).to_string())
360 }
361 };
362 futures::future::ready(Some(frame))
363 })
364}
365
366fn ndjson_response<St>(stream: St) -> Response
368where
369 St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + 'static,
370{
371 let byte_stream = json_frames(stream).map(|frame| {
374 let mut line = match frame {
375 StreamFrame::Data(s) | StreamFrame::Error(s) => s,
376 };
377 line.push('\n');
378 Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(line))
379 });
380
381 let body = axum::body::Body::from_stream(byte_stream);
382 Response::builder()
386 .status(StatusCode::OK)
387 .header("content-type", "application/x-ndjson")
388 .body(body)
389 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
390}
391
392fn sse_response<St>(stream: St, keep_alive_secs: u64) -> Response
394where
395 St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + 'static,
396{
397 let event_stream = json_frames(stream).map(|frame| {
401 let event = match frame {
402 StreamFrame::Data(s) => Event::default().data(s),
403 StreamFrame::Error(s) => Event::default().event("stream-error").data(s),
404 };
405 Ok::<Event, std::convert::Infallible>(event)
406 });
407
408 Sse::new(event_stream)
409 .keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(keep_alive_secs)))
410 .into_response()
411}
412
413async fn transcode_handler<S: TranscodeState>(
415 State(proxy_state): State<S>,
416 headers: HeaderMap,
417 Path(path_params): Path<std::collections::HashMap<String, String>>,
418 RawQuery(raw_query): RawQuery,
419 body_bytes: axum::body::Bytes,
420 entry: std::sync::Arc<RouteEntry>,
421) -> Response {
422 let channel = proxy_state.grpc_channel();
423
424 let json_body = match entry.body {
426 request::BodyMapping::None => serde_json::Value::Null,
427 _ => {
428 let ct = body::content_type(&headers);
429 match body::parse_body(ct, &body_bytes) {
430 Ok(v) => v,
431 Err(e) => {
432 return (
433 StatusCode::BAD_REQUEST,
434 Json(serde_json::json!({
435 "error": "INVALID_ARGUMENT",
436 "message": format!("failed to parse request body: {e}"),
437 })),
438 )
439 .into_response();
440 }
441 }
442 }
443 };
444
445 let query_pairs = match request::parse_query(raw_query.as_deref()) {
449 Ok(pairs) => pairs,
450 Err(e) => {
451 return (
452 StatusCode::BAD_REQUEST,
453 Json(serde_json::json!({
454 "error": "INVALID_ARGUMENT",
455 "message": e,
456 })),
457 )
458 .into_response();
459 }
460 };
461
462 let input_desc = entry.method.input();
463 let request_json = match request::build_request_json(
464 &input_desc,
465 &entry.body,
466 json_body,
467 &path_params,
468 &query_pairs,
469 ) {
470 Ok(v) => v,
471 Err(e) => {
472 return (
473 StatusCode::BAD_REQUEST,
474 Json(serde_json::json!({
475 "error": "INVALID_ARGUMENT",
476 "message": e,
477 })),
478 )
479 .into_response();
480 }
481 };
482
483 let request_msg = match DynamicMessage::deserialize(input_desc, request_json) {
484 Ok(msg) => msg,
485 Err(e) => {
486 return (
487 StatusCode::BAD_REQUEST,
488 Json(serde_json::json!({
489 "error": "INVALID_ARGUMENT",
490 "message": format!("failed to decode request: {e}"),
491 })),
492 )
493 .into_response();
494 }
495 };
496
497 let grpc_metadata =
498 metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
499 let mut grpc_request = tonic::Request::new(request_msg);
500 *grpc_request.metadata_mut() = grpc_metadata;
501 metadata::apply_request_deadline(&mut grpc_request, &headers);
502
503 let output_desc = entry.method.output();
504 let grpc_codec = codec::DynamicCodec::new(output_desc.clone());
505 let grpc_path = entry.grpc_path.clone();
506
507 let mut grpc_client = Grpc::new(channel);
508 if let Err(e) = grpc_client.ready().await {
509 return (
510 StatusCode::SERVICE_UNAVAILABLE,
511 Json(serde_json::json!({
512 "error": "UNAVAILABLE",
513 "message": format!("gRPC upstream not ready: {e}"),
514 })),
515 )
516 .into_response();
517 }
518
519 match grpc_client.unary(grpc_request, grpc_path, grpc_codec).await {
520 Ok(response) => {
521 let response_msg = response.into_inner();
522 let serialize_opts = response_serialize_options();
523 match response_msg
524 .serialize_with_options(serde_json::value::Serializer, &serialize_opts)
525 {
526 Ok(json_value) => {
527 let out = match &entry.response_body {
529 Some(path) => request::extract_response_body(&json_value, path)
530 .unwrap_or_else(|| {
531 tracing::warn!(
532 response_body = %path,
533 "configured response_body path not found in response; \
534 returning null"
535 );
536 serde_json::Value::Null
537 }),
538 None => json_value,
539 };
540 (StatusCode::OK, Json(out)).into_response()
541 }
542 Err(e) => {
543 tracing::error!("Failed to serialize gRPC response: {e}");
544 (
545 StatusCode::INTERNAL_SERVER_ERROR,
546 Json(serde_json::json!({
547 "error": "INTERNAL",
548 "message": "failed to serialize response",
549 })),
550 )
551 .into_response()
552 }
553 }
554 }
555 Err(status) => error::status_to_response(status),
556 }
557}
558
559fn extract_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
561 let http_ext = match pool.get_extension_by_name("google.api.http") {
562 Some(ext) => ext,
563 None => {
564 tracing::warn!("google.api.http extension not found in descriptor pool");
565 return Vec::new();
566 }
567 };
568
569 let mut entries = Vec::new();
570
571 for service in pool.services() {
572 for method in service.methods() {
573 if method.is_client_streaming() || method.is_server_streaming() {
574 continue;
575 }
576
577 let grpc_path = format!("/{}/{}", service.full_name(), method.name());
578 let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
579 Ok(p) => p,
580 Err(e) => {
581 tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
582 continue;
583 }
584 };
585
586 for binding in extract_http_bindings(&method, &http_ext) {
587 entries.push(RouteEntry {
588 http_path: binding.http_path,
589 http_method: binding.http_method,
590 grpc_path: grpc_path.clone(),
591 method: method.clone(),
592 body: binding.body,
593 response_body: binding.response_body,
594 });
595 }
596 }
597 }
598
599 entries
600}
601
602fn extract_streaming_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
604 let http_ext = match pool.get_extension_by_name("google.api.http") {
605 Some(ext) => ext,
606 None => return Vec::new(),
607 };
608
609 let mut entries = Vec::new();
610
611 for service in pool.services() {
612 for method in service.methods() {
613 if !method.is_server_streaming() || method.is_client_streaming() {
614 continue;
615 }
616
617 let grpc_path = format!("/{}/{}", service.full_name(), method.name());
618 let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
619 Ok(p) => p,
620 Err(e) => {
621 tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
622 continue;
623 }
624 };
625
626 for binding in extract_http_bindings(&method, &http_ext) {
627 tracing::info!(
628 "Registering streaming route: {} {} → {}",
629 match binding.http_method {
630 HttpMethod::Get => "GET",
631 HttpMethod::Post => "POST",
632 _ => "OTHER",
633 },
634 binding.http_path,
635 grpc_path
636 );
637 entries.push(RouteEntry {
638 http_path: binding.http_path,
639 http_method: binding.http_method,
640 grpc_path: grpc_path.clone(),
641 method: method.clone(),
642 body: binding.body,
643 response_body: binding.response_body,
644 });
645 }
646 }
647 }
648
649 entries
650}
651
652struct HttpBinding {
654 http_method: HttpMethod,
655 http_path: String,
656 body: request::BodyMapping,
657 response_body: Option<String>,
658}
659
660fn extract_http_bindings(
663 method: &MethodDescriptor,
664 http_ext: &prost_reflect::ExtensionDescriptor,
665) -> Vec<HttpBinding> {
666 let options = method.options();
667 if !options.has_extension(http_ext) {
668 return Vec::new();
669 }
670
671 let prost_reflect::Value::Message(rule_msg) = options.get_extension(http_ext).into_owned()
672 else {
673 return Vec::new();
674 };
675
676 collect_bindings(&rule_msg)
677}
678
679fn collect_bindings(rule_msg: &DynamicMessage) -> Vec<HttpBinding> {
682 let mut bindings = Vec::new();
683 if let Some(binding) = parse_http_rule(rule_msg) {
684 bindings.push(binding);
685 }
686
687 if let Some(field) = rule_msg.get_field_by_name("additional_bindings") {
690 if let prost_reflect::Value::List(list) = field.into_owned() {
691 for item in list {
692 if let prost_reflect::Value::Message(sub) = item {
693 if let Some(binding) = parse_http_rule(&sub) {
694 bindings.push(binding);
695 }
696 }
697 }
698 }
699 }
700
701 bindings
702}
703
704fn parse_http_rule(rule_msg: &DynamicMessage) -> Option<HttpBinding> {
706 let (http_method, http_path) = [
707 ("get", HttpMethod::Get),
708 ("post", HttpMethod::Post),
709 ("put", HttpMethod::Put),
710 ("delete", HttpMethod::Delete),
711 ("patch", HttpMethod::Patch),
712 ]
713 .into_iter()
714 .find_map(
715 |(name, http_method)| match rule_msg.get_field_by_name(name)?.into_owned() {
716 prost_reflect::Value::String(path) if !path.is_empty() => Some((http_method, path)),
717 _ => None,
718 },
719 )?;
720
721 let body = rule_msg
722 .get_field_by_name("body")
723 .and_then(|v| match v.into_owned() {
724 prost_reflect::Value::String(s) => Some(request::BodyMapping::parse(&s)),
725 _ => None,
726 })
727 .unwrap_or(request::BodyMapping::None);
728
729 let response_body =
730 rule_msg
731 .get_field_by_name("response_body")
732 .and_then(|v| match v.into_owned() {
733 prost_reflect::Value::String(s) if !s.is_empty() => Some(s),
734 _ => None,
735 });
736
737 Some(HttpBinding {
738 http_method,
739 http_path,
740 body,
741 response_body,
742 })
743}
744
745pub fn proto_path_to_axum(path: &str) -> String {
756 let mut out = String::with_capacity(path.len());
757
758 let segments = split_top_level(path);
759 let last = segments.len().saturating_sub(1);
760 for (idx, segment) in segments.iter().enumerate() {
761 if idx > 0 {
762 out.push('/');
763 }
764 out.push_str(&convert_segment(segment, idx, idx == last));
765 }
766
767 out
768}
769
770fn split_top_level(path: &str) -> Vec<&str> {
777 let mut segments = Vec::new();
778 let mut depth = 0usize;
779 let mut start = 0usize;
780
781 for (i, ch) in path.char_indices() {
782 match ch {
783 '{' => depth += 1,
784 '}' if depth > 0 => depth -= 1,
787 '/' if depth == 0 => {
788 segments.push(&path[start..i]);
789 start = i + 1;
790 }
791 _ => {}
792 }
793 }
794 segments.push(&path[start..]);
795 segments
796}
797
798fn convert_segment(segment: &str, idx: usize, is_last: bool) -> String {
803 if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
804 if let Some((name, template)) = inner.split_once('=') {
806 return match template {
807 "*" => format!("{{{name}}}"),
809 "**" => catch_all(name, is_last),
811 _ => {
817 tracing::warn!(
818 template = %inner,
819 "google.api.http multi-segment field template is not fully \
820 supported; routing it as a catch-all capture"
821 );
822 catch_all(name, is_last)
823 }
824 };
825 }
826 return format!("{{{inner}}}");
828 }
829
830 match segment {
832 "**" => catch_all(&format!("wildcard{idx}"), is_last),
833 "*" => format!("{{wildcard{idx}}}"),
834 literal => literal.to_string(),
835 }
836}
837
838fn catch_all(name: &str, is_last: bool) -> String {
846 if is_last {
847 format!("{{*{name}}}")
848 } else {
849 tracing::warn!(
850 capture = %name,
851 "catch-all in a non-terminal path segment is unrepresentable in axum; \
852 degrading to a single-segment capture"
853 );
854 format!("{{{name}}}")
855 }
856}
857
858#[cfg(test)]
859mod tests {
860 use super::*;
861
862 fn http_rule_descriptor() -> prost_reflect::MessageDescriptor {
866 use prost_reflect::prost::Message;
867 use prost_reflect::prost_types::{
868 field_descriptor_proto::{Label, Type},
869 DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet,
870 };
871
872 let str_field = |name: &str, num: i32| FieldDescriptorProto {
873 name: Some(name.to_string()),
874 number: Some(num),
875 label: Some(Label::Optional as i32),
876 r#type: Some(Type::String as i32),
877 ..Default::default()
878 };
879 let rule = DescriptorProto {
880 name: Some("HttpRule".to_string()),
881 field: vec![
882 str_field("get", 2),
883 str_field("put", 3),
884 str_field("post", 4),
885 str_field("delete", 5),
886 str_field("patch", 6),
887 str_field("body", 7),
888 str_field("response_body", 12),
889 FieldDescriptorProto {
890 name: Some("additional_bindings".to_string()),
891 number: Some(11),
892 label: Some(Label::Repeated as i32),
893 r#type: Some(Type::Message as i32),
894 type_name: Some(".gapi.HttpRule".to_string()),
895 ..Default::default()
896 },
897 ],
898 ..Default::default()
899 };
900 let file = FileDescriptorProto {
901 name: Some("http.proto".to_string()),
902 package: Some("gapi".to_string()),
903 message_type: vec![rule],
904 syntax: Some("proto3".to_string()),
905 ..Default::default()
906 };
907 let fds = FileDescriptorSet { file: vec![file] };
908 let pool = DescriptorPool::decode(fds.encode_to_vec().as_slice()).unwrap();
909 pool.get_message_by_name("gapi.HttpRule").unwrap()
910 }
911
912 #[test]
913 fn collect_bindings_reads_body_response_and_additional() {
914 let desc = http_rule_descriptor();
915
916 let mut extra = DynamicMessage::new(desc.clone());
918 extra.set_field_by_name("post", prost_reflect::Value::String("/v1/items".into()));
919 extra.set_field_by_name("body", prost_reflect::Value::String("*".into()));
920
921 let mut rule = DynamicMessage::new(desc);
923 rule.set_field_by_name("get", prost_reflect::Value::String("/v1/items/{id}".into()));
924 rule.set_field_by_name(
925 "response_body",
926 prost_reflect::Value::String("result".into()),
927 );
928 rule.set_field_by_name(
929 "additional_bindings",
930 prost_reflect::Value::List(vec![prost_reflect::Value::Message(extra)]),
931 );
932
933 let bindings = collect_bindings(&rule);
934 assert_eq!(bindings.len(), 2);
935
936 assert!(matches!(bindings[0].http_method, HttpMethod::Get));
938 assert_eq!(bindings[0].http_path, "/v1/items/{id}");
939 assert_eq!(bindings[0].body, request::BodyMapping::None);
940 assert_eq!(bindings[0].response_body.as_deref(), Some("result"));
941
942 assert!(matches!(bindings[1].http_method, HttpMethod::Post));
944 assert_eq!(bindings[1].http_path, "/v1/items");
945 assert_eq!(bindings[1].body, request::BodyMapping::Root);
946 assert_eq!(bindings[1].response_body, None);
947 }
948
949 #[test]
950 fn test_proto_path_to_axum() {
951 assert_eq!(proto_path_to_axum("/v1/profiles/{id}"), "/v1/profiles/{id}");
953 assert_eq!(
954 proto_path_to_axum("/v1/admin/profiles/{profile_id}/metadata/{key}"),
955 "/v1/admin/profiles/{profile_id}/metadata/{key}"
956 );
957 assert_eq!(proto_path_to_axum("/v1/auth/login"), "/v1/auth/login");
958 }
959
960 #[test]
961 fn test_proto_path_to_axum_wildcards() {
962 assert_eq!(proto_path_to_axum("/v1/{name=*}"), "/v1/{name}");
964 assert_eq!(
966 proto_path_to_axum("/v1/files/{path=**}"),
967 "/v1/files/{*path}"
968 );
969 assert_eq!(proto_path_to_axum("/v1/*/items"), "/v1/{wildcard2}/items");
972 assert_eq!(proto_path_to_axum("/v1/files/**"), "/v1/files/{*wildcard3}");
973 }
974
975 #[test]
976 fn non_terminal_catch_all_degrades_to_single_capture() {
977 assert_eq!(
983 proto_path_to_axum("/v1/{name=projects/*}/topics"),
984 "/v1/{name}/topics"
985 );
986 let path = proto_path_to_axum("/v1/{name=projects/*}/topics");
987 let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" }));
988
989 assert_eq!(proto_path_to_axum("/v1/{rest=**}/tail"), "/v1/{rest}/tail");
992 assert_eq!(
993 proto_path_to_axum("/v1/files/{rest=**}"),
994 "/v1/files/{*rest}"
995 );
996 }
997
998 #[test]
999 fn multi_segment_field_template_does_not_fracture() {
1000 assert_eq!(
1006 proto_path_to_axum("/v1/{name=shelves/*/books/*}"),
1007 "/v1/{*name}"
1008 );
1009 let path = proto_path_to_axum("/v1/{name=shelves/*/books/*}");
1011 let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" }));
1012 }
1013
1014 #[test]
1019 fn router_builds_with_brace_path_params_on_axum_0_8() {
1020 let axum_path = proto_path_to_axum("/v1/profiles/{id}");
1021 let _router: Router<()> = Router::new().route(&axum_path, get(|| async { "ok" }));
1022
1023 let nested = proto_path_to_axum("/v1/admin/profiles/{profile_id}/metadata/{key}");
1025 let catch_all = proto_path_to_axum("/v1/files/{path=**}");
1026 let _router: Router<()> = Router::new()
1027 .route(&nested, get(|| async { "ok" }))
1028 .route(&catch_all, get(|| async { "ok" }));
1029 }
1030
1031 fn item_message() -> DynamicMessage {
1034 item_message_named("alice", 42)
1035 }
1036
1037 fn item_message_named(name: &str, count: i64) -> DynamicMessage {
1040 use prost_reflect::prost::Message;
1041 use prost_reflect::prost_types::{
1042 field_descriptor_proto::{Label, Type},
1043 DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet,
1044 };
1045
1046 let item = DescriptorProto {
1047 name: Some("Item".to_string()),
1048 field: vec![
1049 FieldDescriptorProto {
1050 name: Some("name".to_string()),
1051 number: Some(1),
1052 label: Some(Label::Optional as i32),
1053 r#type: Some(Type::String as i32),
1054 ..Default::default()
1055 },
1056 FieldDescriptorProto {
1057 name: Some("count".to_string()),
1058 number: Some(2),
1059 label: Some(Label::Optional as i32),
1060 r#type: Some(Type::Int64 as i32),
1061 ..Default::default()
1062 },
1063 ],
1064 ..Default::default()
1065 };
1066 let file = FileDescriptorProto {
1067 name: Some("item.proto".to_string()),
1068 package: Some("test.v1".to_string()),
1069 message_type: vec![item],
1070 syntax: Some("proto3".to_string()),
1071 ..Default::default()
1072 };
1073 let mut bytes = Vec::new();
1074 FileDescriptorSet { file: vec![file] }
1075 .encode(&mut bytes)
1076 .unwrap();
1077 let pool = DescriptorPool::decode(bytes.as_slice()).unwrap();
1078 let desc = pool.get_message_by_name("test.v1.Item").unwrap();
1079
1080 let mut msg = DynamicMessage::new(desc);
1081 msg.set_field_by_name("name", prost_reflect::Value::String(name.to_string()));
1082 msg.set_field_by_name("count", prost_reflect::Value::I64(count));
1083 msg
1084 }
1085
1086 async fn collect_body(resp: Response) -> String {
1088 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1089 .await
1090 .unwrap();
1091 String::from_utf8(bytes.to_vec()).unwrap()
1092 }
1093
1094 #[tokio::test]
1095 async fn ndjson_error_frame_is_terminal() {
1096 let items = vec![
1100 Ok(item_message_named("alice", 1)),
1101 Err(tonic::Status::internal("boom")),
1102 Ok(item_message_named("bob", 2)),
1103 ];
1104 let body = collect_body(ndjson_response(futures::stream::iter(items))).await;
1105 let lines: Vec<&str> = body.lines().collect();
1106 assert_eq!(lines.len(), 2, "stream must stop after the error frame");
1107 assert!(lines[0].contains("alice"));
1108 assert!(lines[1].contains("INTERNAL") && lines[1].contains("boom"));
1109 assert!(!body.contains("bob"), "post-error message must be dropped");
1110 }
1111
1112 #[tokio::test]
1113 async fn sse_error_uses_distinct_event_name() {
1114 let items = vec![
1117 Ok(item_message_named("alice", 1)),
1118 Err(tonic::Status::permission_denied("nope")),
1119 Ok(item_message_named("bob", 2)),
1120 ];
1121 let body = collect_body(sse_response(futures::stream::iter(items), 15)).await;
1122 assert!(body.contains("stream-error"));
1123 assert!(body.contains("PERMISSION_DENIED"));
1124 assert!(!body.contains("bob"), "post-error message must be dropped");
1125 }
1126
1127 #[test]
1128 fn wants_sse_detects_event_stream_accept() {
1129 let mut headers = HeaderMap::new();
1130 headers.insert("accept", "text/event-stream".parse().unwrap());
1131 assert!(wants_sse(&headers));
1132 }
1133
1134 #[test]
1135 fn wants_sse_matches_within_list_and_ignores_params() {
1136 let mut headers = HeaderMap::new();
1137 headers.insert(
1138 "accept",
1139 "application/json, text/event-stream;q=0.9".parse().unwrap(),
1140 );
1141 assert!(wants_sse(&headers));
1142 }
1143
1144 #[test]
1145 fn wants_sse_false_for_json_and_missing() {
1146 let mut headers = HeaderMap::new();
1147 headers.insert("accept", "application/json".parse().unwrap());
1148 assert!(!wants_sse(&headers));
1149 assert!(!wants_sse(&HeaderMap::new()));
1150 }
1151
1152 #[test]
1153 fn wants_sse_rejects_explicit_q_zero() {
1154 let mut headers = HeaderMap::new();
1157 headers.insert("accept", "text/event-stream;q=0".parse().unwrap());
1158 assert!(!wants_sse(&headers));
1159 }
1160
1161 #[test]
1162 fn wants_sse_honors_second_accept_header_line() {
1163 let mut headers = HeaderMap::new();
1166 headers.append("accept", "application/json".parse().unwrap());
1167 headers.append("accept", "text/event-stream".parse().unwrap());
1168 assert!(wants_sse(&headers));
1169 }
1170
1171 #[test]
1172 fn message_to_json_string_stringifies_64bit() {
1173 let opts = response_serialize_options();
1174 let json = message_to_json_string(&item_message(), &opts).unwrap();
1175 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1176 assert_eq!(value["name"], "alice");
1177 assert_eq!(value["count"], "42");
1179 }
1180
1181 #[test]
1182 fn ndjson_response_omits_manual_transfer_encoding() {
1183 let resp = ndjson_response(futures::stream::empty::<
1186 Result<DynamicMessage, tonic::Status>,
1187 >());
1188 assert_eq!(
1189 resp.headers().get("content-type").unwrap(),
1190 "application/x-ndjson"
1191 );
1192 assert!(resp.headers().get("transfer-encoding").is_none());
1193 }
1194
1195 #[test]
1196 fn stream_error_json_carries_grpc_code_name() {
1197 let status = tonic::Status::permission_denied("nope");
1198 let value = stream_error_json(&status);
1199 assert_eq!(value["error"], "PERMISSION_DENIED");
1200 assert_eq!(value["message"], "nope");
1201 assert_eq!(value["code"], tonic::Code::PermissionDenied as i32);
1202 }
1203}