pub mod body;
pub mod codec;
pub mod error;
pub mod metadata;
pub mod request;
use axum::extract::{Path, RawQuery, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, patch, post, put, MethodRouter};
use axum::{Json, Router};
use futures::StreamExt;
use prost_reflect::{DescriptorPool, DynamicMessage, MethodDescriptor, SerializeOptions};
use std::sync::Arc;
use tonic::client::Grpc;
use crate::config::AliasConfig;
use error::{ErrorDetailsPolicy, StatusDetails};
pub trait TranscodeState: Clone + Send + Sync + 'static {
fn grpc_channel(&self) -> tonic::transport::Channel;
fn forwarded_headers(&self) -> &[String];
fn sse_keep_alive_secs(&self) -> u64;
}
impl TranscodeState for crate::ProxyState {
fn grpc_channel(&self) -> tonic::transport::Channel {
self.grpc_channel.clone()
}
fn forwarded_headers(&self) -> &[String] {
&self.forwarded_headers
}
fn sse_keep_alive_secs(&self) -> u64 {
self.sse_keep_alive_secs
}
}
#[derive(Debug, Clone)]
struct RouteEntry {
http_path: String,
http_method: HttpMethod,
grpc_path: axum::http::uri::PathAndQuery,
method: MethodDescriptor,
body: request::BodyMapping,
response_body: Option<String>,
error_details: Option<Arc<StatusDetails>>,
ndjson_envelope: bool,
response_has_required: bool,
}
#[derive(Debug, Clone, Default)]
pub struct TranscodeOptions {
pub(crate) error_details: ErrorDetailsPolicy,
pub(crate) ndjson_envelope: bool,
}
impl TranscodeOptions {
pub fn with_error_details(mut self, policy: ErrorDetailsPolicy) -> Self {
self.error_details = policy;
self
}
pub fn with_ndjson_envelope(mut self, enabled: bool) -> Self {
self.ndjson_envelope = enabled;
self
}
}
#[derive(Debug, Clone, Copy)]
enum HttpMethod {
Get,
Post,
Put,
Patch,
Delete,
}
impl HttpMethod {
fn as_str(self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Patch => "PATCH",
HttpMethod::Delete => "DELETE",
}
}
}
pub fn routes<S: TranscodeState>(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Router<S> {
routes_with_options(pool, aliases, &TranscodeOptions::default())
}
pub fn routes_with_options<S: TranscodeState>(
pool: &DescriptorPool,
aliases: &[AliasConfig],
options: &TranscodeOptions,
) -> Router<S> {
let bindings = route_bindings(pool, aliases);
if bindings.is_empty() {
tracing::warn!("No HTTP-annotated RPCs found in proto descriptors");
return Router::new();
}
tracing::info!("Registering {} transcoded REST→gRPC routes", bindings.len());
let mut status_details: [Option<Arc<StatusDetails>>; 2] = [None, None];
let mut router: Router<S> = Router::new();
for mut binding in bindings {
let policy = &options.error_details;
if policy.enabled_for(&binding.axum_path) {
let opaque = policy.opaque_for(&binding.axum_path);
let slot = usize::from(opaque);
if status_details[slot].is_none() {
let base = status_details
.iter()
.flatten()
.next()
.map(|existing| existing.as_ref().clone())
.unwrap_or_else(|| StatusDetails::new(pool));
status_details[slot] = Some(Arc::new(base.with_opaque_details(opaque)));
}
binding.entry.error_details = status_details[slot].clone();
}
binding.entry.ndjson_envelope = options.ndjson_envelope;
let method = binding.entry.http_method;
let entry = Arc::new(binding.entry);
let method_router: MethodRouter<S> = if binding.streaming {
let handler = move |proxy_state: State<S>,
headers: HeaderMap,
path_params: Path<std::collections::HashMap<String, String>>,
raw_query: RawQuery,
body: axum::body::Bytes| {
streaming_handler(proxy_state, headers, path_params, raw_query, body, entry)
};
match method {
HttpMethod::Get => get(handler),
HttpMethod::Post => post(handler),
_ => unreachable!("streaming routes are GET/POST only"),
}
} else {
let handler = move |proxy_state: State<S>,
headers: HeaderMap,
path_params: Path<std::collections::HashMap<String, String>>,
raw_query: RawQuery,
body: axum::body::Bytes| {
transcode_handler(proxy_state, headers, path_params, raw_query, body, entry)
};
match method {
HttpMethod::Get => get(handler),
HttpMethod::Post => post(handler),
HttpMethod::Put => put(handler),
HttpMethod::Patch => patch(handler),
HttpMethod::Delete => delete(handler),
}
};
router = router.route(&binding.axum_path, method_router);
}
router
}
struct RouteBinding {
entry: RouteEntry,
axum_path: String,
streaming: bool,
}
fn route_bindings(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Vec<RouteBinding> {
let mut bindings = Vec::new();
for entry in extract_routes(pool) {
bindings.push(RouteBinding {
axum_path: proto_path_to_axum(&entry.http_path),
entry: entry.clone(),
streaming: false,
});
for alias in aliases {
if let Some(suffix) = entry.http_path.strip_prefix(&alias.to) {
if alias.from.ends_with("/{path}") {
let prefix = alias.from.trim_end_matches("/{path}");
bindings.push(RouteBinding {
axum_path: format!("{prefix}{suffix}"),
entry: entry.clone(),
streaming: false,
});
}
}
}
}
for entry in extract_streaming_routes(pool) {
if matches!(entry.http_method, HttpMethod::Get | HttpMethod::Post) {
bindings.push(RouteBinding {
axum_path: proto_path_to_axum(&entry.http_path),
entry,
streaming: true,
});
}
}
bindings
}
pub fn route_paths(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Vec<(String, String)> {
route_bindings(pool, aliases)
.into_iter()
.map(|b| (b.entry.http_method.as_str().to_string(), b.axum_path))
.collect()
}
fn response_serialize_options() -> SerializeOptions {
SerializeOptions::new()
.skip_default_fields(false)
.stringify_64_bit_integers(true)
}
fn message_to_json_string(msg: &DynamicMessage, opts: &SerializeOptions) -> Result<String, String> {
let value = msg
.serialize_with_options(serde_json::value::Serializer, opts)
.map_err(|e| e.to_string())?;
serde_json::to_string(&value).map_err(|e| e.to_string())
}
fn wants_sse(headers: &HeaderMap) -> bool {
headers
.get_all(axum::http::header::ACCEPT)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|accept| accept.split(','))
.any(accept_range_selects_sse)
}
fn accept_range_selects_sse(range: &str) -> bool {
let mut parts = range.split(';');
let media = parts.next().unwrap_or("").trim();
if !media.eq_ignore_ascii_case("text/event-stream") {
return false;
}
for param in parts {
let mut kv = param.splitn(2, '=');
if kv.next().unwrap_or("").trim().eq_ignore_ascii_case("q") {
let q: f32 = kv.next().unwrap_or("").trim().parse().unwrap_or(1.0);
return q > 0.0;
}
}
true
}
async fn streaming_handler<S: TranscodeState>(
State(proxy_state): State<S>,
headers: HeaderMap,
Path(path_params): Path<std::collections::HashMap<String, String>>,
RawQuery(raw_query): RawQuery,
body_bytes: axum::body::Bytes,
entry: std::sync::Arc<RouteEntry>,
) -> Response {
let channel = proxy_state.grpc_channel();
let request_msg = match decode_request(
&entry,
&headers,
&path_params,
raw_query.as_deref(),
&body_bytes,
) {
Ok(msg) => msg,
Err(message) => return bad_request(&entry, message),
};
let grpc_metadata =
metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
let mut grpc_request = tonic::Request::new(request_msg);
*grpc_request.metadata_mut() = grpc_metadata;
metadata::apply_request_deadline(&mut grpc_request, &headers);
let output_desc = entry.method.output();
let grpc_codec =
codec::DynamicCodec::with_required_check(output_desc.clone(), entry.response_has_required);
let grpc_path = entry.grpc_path.clone();
let mut grpc_client = Grpc::new(channel);
if let Err(e) = grpc_client.ready().await {
let status = tonic::Status::unavailable(format!("gRPC upstream not ready: {e}"));
return error::status_to_response_with_details(&status, entry.error_details.as_deref());
}
let use_sse = wants_sse(&headers);
match grpc_client
.server_streaming(grpc_request, grpc_path, grpc_codec)
.await
{
Ok(response) => {
let stream = response.into_inner();
let envelope = entry.ndjson_envelope;
let render_error = move |status: &tonic::Status| {
error::error_body(status, entry.error_details.as_deref())
};
if use_sse {
sse_response(stream, render_error, proxy_state.sse_keep_alive_secs())
} else {
ndjson_response(stream, render_error, envelope)
}
}
Err(status) => {
error::status_to_response_with_details(&status, entry.error_details.as_deref())
}
}
}
enum StreamFrame {
Data(String),
Error(serde_json::Value),
}
const STATUS_TYPE_URL: &str = "type.googleapis.com/google.rpc.Status";
fn json_frames<St, R>(
stream: St,
render_error: R,
) -> impl futures::Stream<Item = StreamFrame> + Send + 'static
where
St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + Unpin + 'static,
R: Fn(&tonic::Status) -> serde_json::Value + Send + 'static,
{
let state = Some((stream, render_error, response_serialize_options()));
futures::stream::unfold(state, |state| async move {
let (mut stream, render_error, opts) = state?;
let error = match stream.next().await? {
Ok(msg) => match message_to_json_string(&msg, &opts) {
Ok(s) => return Some((StreamFrame::Data(s), Some((stream, render_error, opts)))),
Err(e) => tonic::Status::internal(format!("serialization error: {e}")),
},
Err(status) => status,
};
Some((StreamFrame::Error(render_error(&error)), None))
})
}
fn ndjson_response<St, R>(stream: St, render_error: R, envelope: bool) -> Response
where
St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + Unpin + 'static,
R: Fn(&tonic::Status) -> serde_json::Value + Send + 'static,
{
let byte_stream = json_frames(stream, render_error).map(move |frame| {
let mut line = match frame {
StreamFrame::Data(s) if envelope => {
let mut wrapped = String::with_capacity(s.len() + 12);
wrapped.push_str("{\"result\":");
wrapped.push_str(&s);
wrapped.push('}');
wrapped
}
StreamFrame::Data(s) => s,
StreamFrame::Error(body) if envelope => {
serde_json::json!({ "error": body }).to_string()
}
StreamFrame::Error(mut body) => {
if let Some(fields) = body.as_object_mut() {
fields.insert("@type".into(), STATUS_TYPE_URL.into());
}
body.to_string()
}
};
line.push('\n');
Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(line))
});
let body = axum::body::Body::from_stream(byte_stream);
Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/x-ndjson")
.body(body)
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
fn sse_response<St, R>(stream: St, render_error: R, keep_alive_secs: u64) -> Response
where
St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + Unpin + 'static,
R: Fn(&tonic::Status) -> serde_json::Value + Send + 'static,
{
let event_stream = json_frames(stream, render_error).map(|frame| {
let event = match frame {
StreamFrame::Data(s) => Event::default().data(s),
StreamFrame::Error(body) => Event::default()
.event("stream-error")
.data(body.to_string()),
};
Ok::<Event, std::convert::Infallible>(event)
});
Sse::new(event_stream)
.keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(keep_alive_secs)))
.into_response()
}
fn decode_request(
entry: &RouteEntry,
headers: &HeaderMap,
path_params: &std::collections::HashMap<String, String>,
raw_query: Option<&str>,
body_bytes: &[u8],
) -> Result<DynamicMessage, String> {
let json_body = match entry.body {
request::BodyMapping::None => serde_json::Value::Null,
_ => body::parse_body(body::content_type(headers), body_bytes)
.map_err(|e| format!("failed to parse request body: {e}"))?,
};
let query_pairs = request::parse_query(raw_query)?;
let input_desc = entry.method.input();
let request_json = request::build_request_json(
&input_desc,
&entry.body,
json_body,
path_params,
&query_pairs,
)?;
DynamicMessage::deserialize(input_desc, request_json)
.map_err(|e| format!("failed to decode request: {e}"))
}
fn bad_request(entry: &RouteEntry, message: String) -> Response {
error::status_to_response_with_details(
&tonic::Status::invalid_argument(message),
entry.error_details.as_deref(),
)
}
async fn transcode_handler<S: TranscodeState>(
State(proxy_state): State<S>,
headers: HeaderMap,
Path(path_params): Path<std::collections::HashMap<String, String>>,
RawQuery(raw_query): RawQuery,
body_bytes: axum::body::Bytes,
entry: std::sync::Arc<RouteEntry>,
) -> Response {
let channel = proxy_state.grpc_channel();
let request_msg = match decode_request(
&entry,
&headers,
&path_params,
raw_query.as_deref(),
&body_bytes,
) {
Ok(msg) => msg,
Err(message) => return bad_request(&entry, message),
};
let grpc_metadata =
metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
let mut grpc_request = tonic::Request::new(request_msg);
*grpc_request.metadata_mut() = grpc_metadata;
metadata::apply_request_deadline(&mut grpc_request, &headers);
let output_desc = entry.method.output();
let grpc_codec =
codec::DynamicCodec::with_required_check(output_desc.clone(), entry.response_has_required);
let grpc_path = entry.grpc_path.clone();
let mut grpc_client = Grpc::new(channel);
if let Err(e) = grpc_client.ready().await {
let status = tonic::Status::unavailable(format!("gRPC upstream not ready: {e}"));
return error::status_to_response_with_details(&status, entry.error_details.as_deref());
}
match grpc_client.unary(grpc_request, grpc_path, grpc_codec).await {
Ok(response) => {
let response_msg = response.into_inner();
let serialize_opts = response_serialize_options();
match response_msg
.serialize_with_options(serde_json::value::Serializer, &serialize_opts)
{
Ok(json_value) => {
let out = match &entry.response_body {
Some(path) => request::extract_response_body(&json_value, path)
.unwrap_or_else(|| {
tracing::warn!(
response_body = %path,
"configured response_body path not found in response; \
returning null"
);
serde_json::Value::Null
}),
None => json_value,
};
(StatusCode::OK, Json(out)).into_response()
}
Err(e) => {
tracing::error!("Failed to serialize gRPC response: {e}");
error::status_to_response_with_details(
&tonic::Status::internal("failed to serialize response"),
entry.error_details.as_deref(),
)
}
}
}
Err(status) => {
error::status_to_response_with_details(&status, entry.error_details.as_deref())
}
}
}
fn extract_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
let http_ext = match pool.get_extension_by_name("google.api.http") {
Some(ext) => ext,
None => {
tracing::warn!("google.api.http extension not found in descriptor pool");
return Vec::new();
}
};
let mut entries = Vec::new();
for service in pool.services() {
for method in service.methods() {
if method.is_client_streaming() || method.is_server_streaming() {
continue;
}
let grpc_path = format!("/{}/{}", service.full_name(), method.name());
let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
Ok(p) => p,
Err(e) => {
tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
continue;
}
};
let response_has_required = codec::has_required_fields(&method.output());
for binding in extract_http_bindings(&method, &http_ext) {
entries.push(RouteEntry {
http_path: binding.http_path,
http_method: binding.http_method,
grpc_path: grpc_path.clone(),
method: method.clone(),
body: binding.body,
response_body: binding.response_body,
error_details: None,
ndjson_envelope: false,
response_has_required,
});
}
}
}
entries
}
fn extract_streaming_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
let http_ext = match pool.get_extension_by_name("google.api.http") {
Some(ext) => ext,
None => return Vec::new(),
};
let mut entries = Vec::new();
for service in pool.services() {
for method in service.methods() {
if !method.is_server_streaming() || method.is_client_streaming() {
continue;
}
let grpc_path = format!("/{}/{}", service.full_name(), method.name());
let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
Ok(p) => p,
Err(e) => {
tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
continue;
}
};
let response_has_required = codec::has_required_fields(&method.output());
for binding in extract_http_bindings(&method, &http_ext) {
tracing::info!(
"Registering streaming route: {} {} → {}",
match binding.http_method {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
_ => "OTHER",
},
binding.http_path,
grpc_path
);
entries.push(RouteEntry {
http_path: binding.http_path,
http_method: binding.http_method,
grpc_path: grpc_path.clone(),
method: method.clone(),
body: binding.body,
response_body: binding.response_body,
error_details: None,
ndjson_envelope: false,
response_has_required,
});
}
}
}
entries
}
struct HttpBinding {
http_method: HttpMethod,
http_path: String,
body: request::BodyMapping,
response_body: Option<String>,
}
fn extract_http_bindings(
method: &MethodDescriptor,
http_ext: &prost_reflect::ExtensionDescriptor,
) -> Vec<HttpBinding> {
let options = method.options();
if !options.has_extension(http_ext) {
return Vec::new();
}
let prost_reflect::Value::Message(rule_msg) = options.get_extension(http_ext).into_owned()
else {
return Vec::new();
};
collect_bindings(&rule_msg)
}
fn collect_bindings(rule_msg: &DynamicMessage) -> Vec<HttpBinding> {
let mut bindings = Vec::new();
if let Some(binding) = parse_http_rule(rule_msg) {
bindings.push(binding);
}
if let Some(field) = rule_msg.get_field_by_name("additional_bindings") {
if let prost_reflect::Value::List(list) = field.into_owned() {
for item in list {
if let prost_reflect::Value::Message(sub) = item {
if let Some(binding) = parse_http_rule(&sub) {
bindings.push(binding);
}
}
}
}
}
bindings
}
fn parse_http_rule(rule_msg: &DynamicMessage) -> Option<HttpBinding> {
let (http_method, http_path) = [
("get", HttpMethod::Get),
("post", HttpMethod::Post),
("put", HttpMethod::Put),
("delete", HttpMethod::Delete),
("patch", HttpMethod::Patch),
]
.into_iter()
.find_map(
|(name, http_method)| match rule_msg.get_field_by_name(name)?.into_owned() {
prost_reflect::Value::String(path) if !path.is_empty() => Some((http_method, path)),
_ => None,
},
)?;
let body = rule_msg
.get_field_by_name("body")
.and_then(|v| match v.into_owned() {
prost_reflect::Value::String(s) => Some(request::BodyMapping::parse(&s)),
_ => None,
})
.unwrap_or(request::BodyMapping::None);
let response_body =
rule_msg
.get_field_by_name("response_body")
.and_then(|v| match v.into_owned() {
prost_reflect::Value::String(s) if !s.is_empty() => Some(s),
_ => None,
});
Some(HttpBinding {
http_method,
http_path,
body,
response_body,
})
}
pub fn proto_path_to_axum(path: &str) -> String {
let mut out = String::with_capacity(path.len());
let segments = split_top_level(path);
let last = segments.len().saturating_sub(1);
for (idx, segment) in segments.iter().enumerate() {
if idx > 0 {
out.push('/');
}
out.push_str(&convert_segment(segment, idx, idx == last));
}
out
}
fn split_top_level(path: &str) -> Vec<&str> {
let mut segments = Vec::new();
let mut depth = 0usize;
let mut start = 0usize;
for (i, ch) in path.char_indices() {
match ch {
'{' => depth += 1,
'}' if depth > 0 => depth -= 1,
'/' if depth == 0 => {
segments.push(&path[start..i]);
start = i + 1;
}
_ => {}
}
}
segments.push(&path[start..]);
segments
}
fn convert_segment(segment: &str, idx: usize, is_last: bool) -> String {
if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
if let Some((name, template)) = inner.split_once('=') {
return match template {
"*" => format!("{{{name}}}"),
"**" => catch_all(name, is_last),
_ => {
tracing::warn!(
template = %inner,
"google.api.http multi-segment field template is not fully \
supported; routing it as a catch-all capture"
);
catch_all(name, is_last)
}
};
}
return format!("{{{inner}}}");
}
match segment {
"**" => catch_all(&format!("wildcard{idx}"), is_last),
"*" => format!("{{wildcard{idx}}}"),
literal => literal.to_string(),
}
}
fn catch_all(name: &str, is_last: bool) -> String {
if is_last {
format!("{{*{name}}}")
} else {
tracing::warn!(
capture = %name,
"catch-all in a non-terminal path segment is unrepresentable in axum; \
degrading to a single-segment capture"
);
format!("{{{name}}}")
}
}
#[cfg(test)]
mod tests;