pub mod body;
pub mod codec;
pub mod error;
pub(crate) mod httpbody;
pub mod metadata;
pub mod request;
pub(crate) mod response;
pub(crate) mod rule;
mod select;
pub use select::RpcSelection;
use axum::body::{Body, Bytes};
use axum::extract::{Path, RawQuery, State};
use axum::http::header::{ALLOW, CONTENT_TYPE};
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::{MethodFilter, MethodRouter};
use axum::Extension;
use axum::Router;
use futures::{StreamExt, TryStreamExt};
use prost_reflect::{
DescriptorPool, DynamicMessage, FieldDescriptor, MessageDescriptor, MethodDescriptor,
SerializeOptions,
};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tonic::client::Grpc;
use tonic::metadata::MetadataMap;
use crate::config::AliasConfig;
use crate::service::ConnectionInfo;
use crate::upstream::Upstream;
use error::{ErrorDetailsPolicy, StatusDetails};
use response::UpstreamHeaders;
use rule::RouteMethod;
pub const UPSTREAM_DEADLINE: Duration = Duration::from_secs(5);
pub trait TranscodeState: Clone + Send + Sync + 'static {
type Upstream: Upstream;
fn into_upstream(self) -> Self::Upstream;
fn forwarded_headers(&self) -> &[String];
fn sse_keep_alive_secs(&self) -> u64;
}
impl<U: Upstream> TranscodeState for crate::ProxyState<U> {
type Upstream = U;
fn into_upstream(self) -> U {
self.upstream
}
fn forwarded_headers(&self) -> &[String] {
&self.forwarded_headers
}
fn sse_keep_alive_secs(&self) -> u64 {
self.sse_keep_alive_secs
}
}
type PathParams = HashMap<String, String>;
#[derive(Debug, Clone)]
enum RequestBody {
Parsed(request::BodyMapping),
RawRoot,
RawField {
mapping: request::BodyMapping,
field: FieldDescriptor,
http_body: MessageDescriptor,
},
}
#[derive(Debug, Clone)]
enum ResponseShape {
Json(Option<String>),
HttpBody(Vec<FieldDescriptor>),
}
#[derive(Debug, Clone)]
struct RouteEntry {
http_path: String,
http_method: RouteMethod,
grpc_path: axum::http::uri::PathAndQuery,
method: MethodDescriptor,
streaming: bool,
request_body: RequestBody,
response: ResponseShape,
error_details: Option<Arc<StatusDetails>>,
ndjson_envelope: bool,
response_has_required: bool,
denied_headers: Arc<[HeaderName]>,
}
impl RouteEntry {
fn codec(&self) -> codec::DynamicCodec {
codec::DynamicCodec::with_required_check(self.method.output(), self.response_has_required)
}
fn raw_body(&self, message: DynamicMessage) -> Option<httpbody::RawBody> {
match &self.response {
ResponseShape::HttpBody(path) => Some(httpbody::take(message, path)),
ResponseShape::Json(_) => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TranscodeOptions {
pub(crate) error_details: ErrorDetailsPolicy,
pub(crate) ndjson_envelope: bool,
pub(crate) denied_response_headers: Arc<[HeaderName]>,
pub(crate) selection: RpcSelection,
}
impl TranscodeOptions {
pub fn with_selection(mut self, selection: RpcSelection) -> Self {
self.selection = selection;
self
}
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
}
pub fn with_denied_response_headers(
mut self,
names: impl IntoIterator<Item = HeaderName>,
) -> Self {
self.denied_response_headers = names.into_iter().collect();
self
}
}
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, &options.selection);
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 paths: Vec<PathRoutes> = Vec::new();
let mut path_index: HashMap<String, usize> = HashMap::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;
binding.entry.denied_headers = options.denied_response_headers.clone();
let index = match path_index.get(&binding.axum_path) {
Some(&index) => index,
None => {
path_index.insert(binding.axum_path.clone(), paths.len());
paths.push(PathRoutes {
path: binding.axum_path,
methods: Vec::new(),
});
paths.len() - 1
}
};
paths[index].add(Arc::new(binding.entry));
}
let mut router: Router<S> = Router::new();
for path in &paths {
router = router.route(&path.path, path.method_router());
}
router
}
macro_rules! endpoint {
($entry:expr) => {{
let entry: Arc<RouteEntry> = $entry;
move |state: State<S>,
headers: HeaderMap,
path_params: Path<PathParams>,
raw_query: RawQuery,
connection: Option<Extension<ConnectionInfo>>,
body: Bytes| {
let client = Client {
headers,
connection: connection.map(|Extension(connection)| connection),
};
handle(state, client, path_params, raw_query, body, entry)
}
}};
}
struct PathRoutes {
path: String,
methods: Vec<Arc<RouteEntry>>,
}
impl PathRoutes {
fn add(&mut self, entry: Arc<RouteEntry>) {
let taken = self.methods.iter().any(|existing| {
existing.http_method == entry.http_method
|| existing.http_method == RouteMethod::Any
|| entry.http_method == RouteMethod::Any
});
if taken {
tracing::error!(
method = entry.http_method.as_str(),
path = %self.path,
rpc = %entry.grpc_path,
"HTTP method and path already bound to another RPC; skipping this binding"
);
return;
}
self.methods.push(entry);
}
fn method_router<S: TranscodeState>(&self) -> MethodRouter<S> {
let mut router = MethodRouter::new();
let mut extension: Vec<(Method, Arc<RouteEntry>)> = Vec::new();
let mut allow: Vec<&str> = Vec::new();
for entry in &self.methods {
match &entry.http_method {
RouteMethod::Any => return axum::routing::any(endpoint!(entry.clone())),
RouteMethod::One(method) => {
allow.push(method.as_str());
match MethodFilter::try_from(method.clone()) {
Ok(filter) => router = router.on(filter, endpoint!(entry.clone())),
Err(_) => extension.push((method.clone(), entry.clone())),
}
}
}
}
if extension.is_empty() {
return router;
}
if allow.contains(&"GET") && !allow.contains(&"HEAD") {
allow.push("HEAD");
}
let allow = HeaderValue::from_str(&allow.join(", "))
.expect("method tokens are valid header value characters");
let extension: Arc<[(Method, Arc<RouteEntry>)]> = extension.into();
router.fallback(
move |State(state): State<S>, request: axum::extract::Request| async move {
match extension
.iter()
.find(|(bound, _)| bound == request.method())
{
Some((_, entry)) => {
axum::handler::Handler::call(endpoint!(entry.clone()), request, state).await
}
None => (StatusCode::METHOD_NOT_ALLOWED, [(ALLOW, allow)]).into_response(),
}
},
)
}
}
struct RouteBinding {
entry: RouteEntry,
axum_path: String,
}
fn route_bindings(
pool: &DescriptorPool,
aliases: &[AliasConfig],
selection: &RpcSelection,
) -> Vec<RouteBinding> {
let mut bindings = Vec::new();
for entry in extract_routes(pool, selection) {
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: proto_path_to_axum(&format!("{prefix}{suffix}")),
entry: entry.clone(),
});
}
}
}
bindings.push(RouteBinding {
axum_path: proto_path_to_axum(&entry.http_path),
entry,
});
}
bindings
}
pub fn route_paths(
pool: &DescriptorPool,
aliases: &[AliasConfig],
selection: &RpcSelection,
) -> Vec<(String, String)> {
route_bindings(pool, aliases, selection)
.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_bytes(
msg: &DynamicMessage,
opts: &SerializeOptions,
) -> Result<Vec<u8>, serde_json::Error> {
let mut buf = Vec::with_capacity(128);
msg.serialize_with_options(&mut serde_json::Serializer::new(&mut buf), opts)?;
Ok(buf)
}
fn message_to_json_string(msg: &DynamicMessage, opts: &SerializeOptions) -> Result<String, String> {
let buf = message_to_json_bytes(msg, opts).map_err(|e| e.to_string())?;
Ok(unsafe { String::from_utf8_unchecked(buf) })
}
fn json_body(
msg: &DynamicMessage,
response_body: Option<&str>,
) -> Result<Vec<u8>, serde_json::Error> {
let opts = response_serialize_options();
let Some(path) = response_body else {
return message_to_json_bytes(msg, &opts);
};
let mut value = Some(msg.serialize_with_options(serde_json::value::Serializer, &opts)?);
let mut desc = Some(prost_reflect::ReflectMessage::descriptor(msg));
for segment in path.split('.') {
let field = desc.as_ref().and_then(|d| d.get_field_by_name(segment));
desc = match field.as_ref().map(FieldDescriptor::kind) {
Some(prost_reflect::Kind::Message(inner)) => Some(inner),
_ => None,
};
value = match (value, field) {
(Some(serde_json::Value::Object(mut fields)), Some(field)) => {
fields.remove(field.json_name())
}
_ => None,
};
}
let value = value.unwrap_or_else(|| {
tracing::warn!(
response_body = %path,
"configured response_body path not found in response; returning null"
);
serde_json::Value::Null
});
serde_json::to_vec(&value)
}
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
}
struct Client {
headers: HeaderMap,
connection: Option<ConnectionInfo>,
}
async fn handle<S: TranscodeState>(
State(proxy_state): State<S>,
client: Client,
Path(path_params): Path<PathParams>,
RawQuery(raw_query): RawQuery,
body: Bytes,
entry: Arc<RouteEntry>,
) -> Response {
let keep_alive_secs = proxy_state.sse_keep_alive_secs();
let Client {
headers,
connection,
} = client;
let prepared = prepare(
proxy_state,
&headers,
connection,
&path_params,
raw_query.as_deref(),
body,
&entry,
);
let call = match prepared {
Ok(call) => call,
Err(rejection) => return rejection.into_response(&entry),
};
if entry.streaming {
streaming_call(call, entry, wants_sse(&headers), keep_alive_secs).await
} else {
unary_call(call, &entry).await
}
}
struct Unmappable(String);
impl Unmappable {
fn into_response(self, entry: &RouteEntry) -> Response {
let status = tonic::Status::invalid_argument(self.0);
error::status_to_response_with_details(&status, entry.error_details.as_deref())
}
}
struct Call<U> {
upstream: U,
request: tonic::Request<DynamicMessage>,
deadline: Duration,
}
impl<U: Upstream> Call<U> {
async fn open(
self,
entry: &RouteEntry,
) -> Result<tonic::Response<tonic::Streaming<DynamicMessage>>, tonic::Status> {
let Self {
upstream,
request,
deadline,
} = self;
let call = async move {
let mut client = Grpc::new(upstream);
if let Err(e) = client.ready().await {
let e: crate::upstream::BoxError = e.into();
return Err(tonic::Status::unavailable(format!(
"gRPC upstream not ready: {e}"
)));
}
client
.server_streaming(request, entry.grpc_path.clone(), entry.codec())
.await
};
match tokio::time::timeout(deadline, call).await {
Ok(result) => result,
Err(_) => Err(tonic::Status::deadline_exceeded(
"upstream did not answer within the deadline",
)),
}
}
}
fn prepare<S: TranscodeState>(
proxy_state: S,
headers: &HeaderMap,
connection: Option<ConnectionInfo>,
path_params: &PathParams,
raw_query: Option<&str>,
body: Bytes,
entry: &RouteEntry,
) -> Result<Call<S::Upstream>, Unmappable> {
let request_metadata =
metadata::try_http_headers_to_grpc_metadata(headers, proxy_state.forwarded_headers())
.map_err(|e| Unmappable(e.to_string()))?;
let message =
decode_request(entry, headers, path_params, raw_query, body).map_err(Unmappable)?;
let mut request = tonic::Request::new(message);
*request.metadata_mut() = request_metadata;
if let Some(connection) = connection {
connection.into_tonic_extensions(request.extensions_mut());
}
let deadline = metadata::apply_request_deadline(&mut request, headers)
.map_or(UPSTREAM_DEADLINE, |client| client.min(UPSTREAM_DEADLINE));
Ok(Call {
upstream: proxy_state.into_upstream(),
request,
deadline,
})
}
struct UnaryAnswer {
initial: MetadataMap,
message: DynamicMessage,
trailers: Option<MetadataMap>,
}
async fn call_unary<U: Upstream>(
call: Call<U>,
entry: &RouteEntry,
) -> Result<UnaryAnswer, tonic::Status> {
let (initial, mut stream, _) = call.open(entry).await?.into_parts();
let message = match stream.message().await {
Ok(Some(message)) => message,
Ok(None) => {
let status = tonic::Status::internal("Missing response message.");
return Err(with_initial(status, initial));
}
Err(status) => return Err(with_initial(status, initial)),
};
let trailers = match stream.trailers().await {
Ok(trailers) => trailers,
Err(status) => return Err(with_initial(status, initial)),
};
Ok(UnaryAnswer {
initial,
message,
trailers,
})
}
fn with_initial(mut status: tonic::Status, initial: MetadataMap) -> tonic::Status {
let own = std::mem::take(status.metadata_mut()).into_headers();
let mut merged = initial.into_headers();
let mut last: Option<HeaderName> = None;
for (name, value) in own {
if let Some(name) = name {
merged.append(&name, value);
last = Some(name);
} else if let Some(name) = &last {
merged.append(name, value);
}
}
*status.metadata_mut() = MetadataMap::from_headers(merged);
status
}
async fn unary_call<U: Upstream>(call: Call<U>, entry: &RouteEntry) -> Response {
match call_unary(call, entry).await {
Ok(answer) => unary_success(entry, answer),
Err(status) => upstream_error(status, entry),
}
}
fn unary_success(entry: &RouteEntry, answer: UnaryAnswer) -> Response {
let mut upstream = UpstreamHeaders::default();
upstream.absorb(answer.initial, &entry.denied_headers);
if let Some(trailers) = answer.trailers {
upstream.absorb(trailers, &entry.denied_headers);
}
let status = match upstream.status() {
Ok(status) => status.unwrap_or(StatusCode::OK),
Err(response::InvalidHttpCode) => {
tracing::error!(
rpc = %entry.grpc_path,
"upstream set x-http-code to something other than one integer in 200-599"
);
return error::malformed_response(entry.error_details.as_deref());
}
};
let (content_type, body) = match &entry.response {
ResponseShape::HttpBody(path) => {
let raw = httpbody::take(answer.message, path);
match content_type_header(&raw.content_type) {
Ok(content_type) => (content_type, Body::from(raw.data)),
Err(()) => return invalid_content_type(entry),
}
}
ResponseShape::Json(response_body) => {
match json_body(&answer.message, response_body.as_deref()) {
Ok(json) => (
Some(HeaderValue::from_static("application/json")),
Body::from(json),
),
Err(e) => {
tracing::error!("Failed to serialize gRPC response: {e}");
return error::status_to_response_with_details(
&tonic::Status::internal("failed to serialize response"),
entry.error_details.as_deref(),
);
}
}
}
};
response::build(status, upstream.into_headers(), content_type, body)
}
fn content_type_header(content_type: &str) -> Result<Option<HeaderValue>, ()> {
if content_type.is_empty() {
return Ok(None);
}
HeaderValue::from_str(content_type)
.map(Some)
.map_err(|_| ())
}
fn invalid_content_type(entry: &RouteEntry) -> Response {
tracing::error!(
rpc = %entry.grpc_path,
"upstream HttpBody content_type is not a valid header value"
);
error::malformed_response(entry.error_details.as_deref())
}
fn upstream_error(mut status: tonic::Status, entry: &RouteEntry) -> Response {
let (response, faithful) = error::render_response(&status, entry.error_details.as_deref());
let metadata = std::mem::take(status.metadata_mut());
if !faithful || metadata.is_empty() {
return response;
}
let mut upstream = UpstreamHeaders::default();
upstream.absorb(metadata, &entry.denied_headers);
response::with_upstream_headers(response, upstream.into_headers())
}
async fn streaming_call<U: Upstream>(
call: Call<U>,
entry: Arc<RouteEntry>,
use_sse: bool,
keep_alive_secs: u64,
) -> Response {
let response = match call.open(&entry).await {
Ok(response) => response,
Err(status) => return upstream_error(status, &entry),
};
let (initial, stream, _) = response.into_parts();
if matches!(entry.response, ResponseShape::HttpBody(_)) {
return http_body_stream(stream, entry, initial).await;
}
let mut upstream = UpstreamHeaders::default();
upstream.absorb(initial, &entry.denied_headers);
let headers = upstream.into_headers();
let envelope = entry.ndjson_envelope;
let render_error =
move |status: &tonic::Status| error::error_body(status, entry.error_details.as_deref());
let response = if use_sse {
sse_response(stream, render_error, keep_alive_secs)
} else {
ndjson_response(stream, render_error, envelope)
};
response::with_upstream_headers(response, headers)
}
async fn http_body_stream(
mut stream: tonic::Streaming<DynamicMessage>,
entry: Arc<RouteEntry>,
initial: MetadataMap,
) -> Response {
let first = match stream.message().await {
Ok(Some(message)) => entry.raw_body(message).unwrap_or_default(),
Ok(None) => httpbody::RawBody::default(),
Err(status) => return upstream_error(with_initial(status, initial), &entry),
};
let mut upstream = UpstreamHeaders::default();
upstream.absorb(initial, &entry.denied_headers);
let headers = upstream.into_headers();
let content_type = match content_type_header(&first.content_type) {
Ok(content_type) => content_type,
Err(()) => return invalid_content_type(&entry),
};
let rest = stream.map(move |item| match item {
Ok(message) => Ok(entry.raw_body(message).unwrap_or_default().data),
Err(status) => {
tracing::error!(
rpc = %entry.grpc_path,
code = ?status.code(),
"server-streaming HttpBody failed after the response started; aborting the body"
);
Err(std::io::Error::other(status))
}
});
let chunks = futures::stream::once(futures::future::ready(Ok(first.data)))
.chain(rest)
.try_filter(|chunk| futures::future::ready(!chunk.is_empty()));
response::build(
StatusCode::OK,
headers,
content_type,
Body::from_stream(chunks),
)
}
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::<Bytes, std::io::Error>(Bytes::from(line))
});
let 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()
}
static NO_PARSED_BODY: request::BodyMapping = request::BodyMapping::None;
fn decode_request(
entry: &RouteEntry,
headers: &HeaderMap,
path_params: &PathParams,
raw_query: Option<&str>,
body_bytes: Bytes,
) -> Result<DynamicMessage, String> {
let (mapping, body, raw_query) = match &entry.request_body {
RequestBody::Parsed(mapping @ request::BodyMapping::None) => {
(mapping, request::Body::Absent, raw_query)
}
RequestBody::Parsed(mapping) => (
mapping,
request::Body::new(body::content_type(headers), &body_bytes),
raw_query,
),
RequestBody::RawRoot => (&NO_PARSED_BODY, request::Body::Absent, None),
RequestBody::RawField { mapping, .. } => (mapping, request::Body::Absent, raw_query),
};
let mut message = request::build_request_message(
&entry.method.input(),
mapping,
body,
path_params,
raw_query,
)?;
match &entry.request_body {
RequestBody::Parsed(_) => {}
RequestBody::RawRoot => {
httpbody::fill(&mut message, request_content_type(headers)?, body_bytes);
}
RequestBody::RawField {
field, http_body, ..
} => {
let mut inner = DynamicMessage::new(http_body.clone());
httpbody::fill(&mut inner, request_content_type(headers)?, body_bytes);
message.set_field(field, prost_reflect::Value::Message(inner));
}
}
Ok(message)
}
fn request_content_type(headers: &HeaderMap) -> Result<String, String> {
match headers.get(CONTENT_TYPE) {
None => Ok(String::new()),
Some(value) => value
.to_str()
.map(str::to_owned)
.map_err(|_| "request Content-Type is not a visible ASCII string".to_string()),
}
}
fn extract_routes(pool: &DescriptorPool, selection: &RpcSelection) -> 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() || !selection.selects(&method) {
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 input = method.input();
let output = method.output();
let streaming = method.is_server_streaming();
let response_has_required = codec::has_required_fields(&output);
for binding in rule::http_bindings(&method, &http_ext) {
if streaming {
tracing::info!(
"Registering streaming route: {} {} → {}",
binding.method.as_str(),
binding.path,
grpc_path
);
}
entries.push(RouteEntry {
http_path: binding.path,
http_method: binding.method,
grpc_path: grpc_path.clone(),
method: method.clone(),
streaming,
request_body: request_body(&input, binding.body),
response: response_shape(&output, binding.response_body),
error_details: None,
ndjson_envelope: false,
response_has_required,
denied_headers: Arc::default(),
});
}
}
}
entries
}
fn request_body(input: &MessageDescriptor, mapping: request::BodyMapping) -> RequestBody {
let raw_field = match &mapping {
request::BodyMapping::Root if httpbody::is_http_body(input) => return RequestBody::RawRoot,
request::BodyMapping::Field(name) => httpbody::http_body_field(input, name),
_ => None,
};
match raw_field {
Some((field, http_body)) => RequestBody::RawField {
mapping,
field,
http_body,
},
None => RequestBody::Parsed(mapping),
}
}
fn response_shape(output: &MessageDescriptor, response_body: Option<String>) -> ResponseShape {
match response_body {
None if httpbody::is_http_body(output) => ResponseShape::HttpBody(Vec::new()),
None => ResponseShape::Json(None),
Some(path) => match httpbody::http_body_path(output, &path) {
Some(fields) => ResponseShape::HttpBody(fields),
None => ResponseShape::Json(Some(path)),
},
}
}
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;