312 lines
10 KiB
Rust
312 lines
10 KiB
Rust
use std::{collections::BTreeMap, str::FromStr, time::Duration};
|
|
|
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
|
use crank_core::GrpcTarget;
|
|
use futures_util::StreamExt;
|
|
use prost::Message;
|
|
use prost_reflect::{DescriptorPool, MethodDescriptor, prost_types::FileDescriptorSet};
|
|
use serde_json::json;
|
|
use tokio::time::{Instant, timeout_at};
|
|
use tonic::{
|
|
Request,
|
|
client::Grpc,
|
|
metadata::{KeyAndValueRef, MetadataKey, MetadataValue},
|
|
transport::Endpoint,
|
|
};
|
|
|
|
use crate::{
|
|
GrpcAdapterError, GrpcRequest, GrpcResponse, GrpcWindowRequest, GrpcWindowResponse,
|
|
codec::JsonCodec,
|
|
};
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct GrpcAdapter;
|
|
|
|
impl GrpcAdapter {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
|
|
pub async fn execute(
|
|
&self,
|
|
target: &GrpcTarget,
|
|
request: &GrpcRequest,
|
|
) -> Result<GrpcResponse, GrpcAdapterError> {
|
|
let method = resolve_method(target)?;
|
|
if method.is_client_streaming() || method.is_server_streaming() {
|
|
return Err(GrpcAdapterError::UnsupportedMethodKind {
|
|
service: format!("{}.{}", target.package, target.service),
|
|
method: target.method.clone(),
|
|
});
|
|
}
|
|
|
|
let mut grpc = connect(target, request.timeout_ms).await?;
|
|
let path = build_path(&method)?;
|
|
let codec = JsonCodec::new(method.input(), method.output());
|
|
let mut tonic_request = Request::new(request.body.clone());
|
|
|
|
apply_headers(&mut tonic_request, &request.headers)?;
|
|
|
|
let response = grpc.unary(tonic_request, path, codec).await?;
|
|
let headers = normalize_headers(response.metadata());
|
|
let body = response.into_inner();
|
|
|
|
Ok(GrpcResponse {
|
|
status_code: 200,
|
|
headers,
|
|
body,
|
|
})
|
|
}
|
|
|
|
pub async fn execute_window(
|
|
&self,
|
|
target: &GrpcTarget,
|
|
request: &GrpcWindowRequest,
|
|
) -> Result<GrpcWindowResponse, GrpcAdapterError> {
|
|
let method = resolve_method(target)?;
|
|
if method.is_client_streaming() || !method.is_server_streaming() {
|
|
return Err(GrpcAdapterError::UnsupportedStreamingMethodKind {
|
|
service: format!("{}.{}", target.package, target.service),
|
|
method: target.method.clone(),
|
|
});
|
|
}
|
|
|
|
let mut grpc = connect(target, request.request.timeout_ms).await?;
|
|
let path = build_path(&method)?;
|
|
let codec = JsonCodec::new(method.input(), method.output());
|
|
let mut tonic_request = Request::new(request.request.body.clone());
|
|
|
|
apply_headers(&mut tonic_request, &request.request.headers)?;
|
|
|
|
let response = grpc.server_streaming(tonic_request, path, codec).await?;
|
|
let headers = normalize_headers(response.metadata());
|
|
let mut stream = response.into_inner();
|
|
let deadline = Instant::now() + Duration::from_millis(request.window_duration_ms);
|
|
let mut items = Vec::new();
|
|
let mut done = true;
|
|
|
|
loop {
|
|
if request
|
|
.max_items
|
|
.is_some_and(|limit| items.len() >= limit as usize)
|
|
{
|
|
done = false;
|
|
break;
|
|
}
|
|
|
|
let next_message = match timeout_at(deadline, stream.next()).await {
|
|
Ok(next_message) => next_message,
|
|
Err(_) => break,
|
|
};
|
|
|
|
let Some(next_message) = next_message else {
|
|
break;
|
|
};
|
|
|
|
let message = next_message?;
|
|
items.push(message);
|
|
}
|
|
|
|
Ok(GrpcWindowResponse {
|
|
status_code: 200,
|
|
headers,
|
|
body: json!({
|
|
"items": items,
|
|
"done": done,
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
|
|
async fn connect(
|
|
target: &GrpcTarget,
|
|
timeout_ms: u64,
|
|
) -> Result<Grpc<tonic::transport::Channel>, GrpcAdapterError> {
|
|
let endpoint = Endpoint::from_shared(target.server_addr.clone())?
|
|
.timeout(Duration::from_millis(timeout_ms));
|
|
let channel = endpoint.connect().await?;
|
|
let mut grpc = Grpc::new(channel);
|
|
grpc.ready()
|
|
.await
|
|
.map_err(|error| GrpcAdapterError::Status {
|
|
code: tonic::Code::Unavailable,
|
|
message: error.to_string(),
|
|
})?;
|
|
|
|
Ok(grpc)
|
|
}
|
|
|
|
fn build_path(
|
|
method: &MethodDescriptor,
|
|
) -> Result<tonic::codegen::http::uri::PathAndQuery, GrpcAdapterError> {
|
|
let service_name = method.parent_service().full_name().to_owned();
|
|
let method_name = method.name().to_owned();
|
|
tonic::codegen::http::uri::PathAndQuery::from_str(&format!("/{service_name}/{method_name}"))
|
|
.map_err(|_| GrpcAdapterError::InvalidMethodPath {
|
|
service: service_name,
|
|
method: method_name,
|
|
})
|
|
}
|
|
|
|
fn resolve_method(target: &GrpcTarget) -> Result<MethodDescriptor, GrpcAdapterError> {
|
|
let bytes = STANDARD
|
|
.decode(&target.descriptor_set_b64)
|
|
.map_err(|_| GrpcAdapterError::InvalidDescriptorEncoding)?;
|
|
let descriptor_set = FileDescriptorSet::decode(bytes.as_slice())
|
|
.map_err(|_| GrpcAdapterError::InvalidDescriptorSet)?;
|
|
let pool = DescriptorPool::from_file_descriptor_set(descriptor_set)
|
|
.map_err(|_| GrpcAdapterError::InvalidDescriptorPool)?;
|
|
let full_service_name = if target.package.is_empty() {
|
|
target.service.clone()
|
|
} else {
|
|
format!("{}.{}", target.package, target.service)
|
|
};
|
|
let service = pool
|
|
.get_service_by_name(&full_service_name)
|
|
.ok_or_else(|| GrpcAdapterError::ServiceNotFound {
|
|
service: full_service_name.clone(),
|
|
})?;
|
|
|
|
service
|
|
.methods()
|
|
.find(|method| method.name() == target.method)
|
|
.ok_or_else(|| GrpcAdapterError::MethodNotFound {
|
|
service: full_service_name,
|
|
method: target.method.clone(),
|
|
})
|
|
}
|
|
|
|
fn apply_headers(
|
|
request: &mut Request<serde_json::Value>,
|
|
headers: &BTreeMap<String, String>,
|
|
) -> Result<(), GrpcAdapterError> {
|
|
for (key, value) in headers {
|
|
let metadata_key =
|
|
MetadataKey::from_str(key).map_err(|source| GrpcAdapterError::InvalidMetadataKey {
|
|
key: key.clone(),
|
|
source,
|
|
})?;
|
|
let metadata_value = MetadataValue::from_str(value).map_err(|source| {
|
|
GrpcAdapterError::InvalidMetadataValue {
|
|
key: key.clone(),
|
|
source,
|
|
}
|
|
})?;
|
|
|
|
request.metadata_mut().insert(metadata_key, metadata_value);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn normalize_headers(metadata: &tonic::metadata::MetadataMap) -> BTreeMap<String, String> {
|
|
metadata
|
|
.iter()
|
|
.filter_map(|entry| match entry {
|
|
KeyAndValueRef::Ascii(key, value) => {
|
|
Some((key.as_str().to_owned(), value.to_str().ok()?.to_owned()))
|
|
}
|
|
KeyAndValueRef::Binary(_, _) => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::BTreeMap;
|
|
|
|
use crank_core::{DescriptorId, GrpcTarget};
|
|
use serde_json::json;
|
|
|
|
use crate::{GrpcAdapter, GrpcAdapterError, GrpcRequest, GrpcWindowRequest, test_support};
|
|
|
|
#[tokio::test]
|
|
async fn executes_unary_grpc_request() {
|
|
let server_addr = test_support::spawn_unary_echo_server().await;
|
|
let adapter = GrpcAdapter::new();
|
|
let target = GrpcTarget {
|
|
server_addr,
|
|
package: "echo".to_owned(),
|
|
service: "EchoService".to_owned(),
|
|
method: "UnaryEcho".to_owned(),
|
|
descriptor_ref: DescriptorId::new("desc_echo"),
|
|
descriptor_set_b64: test_support::descriptor_set_b64(),
|
|
};
|
|
let request = GrpcRequest {
|
|
headers: BTreeMap::new(),
|
|
body: json!({ "message": "hello" }),
|
|
timeout_ms: 1_000,
|
|
};
|
|
|
|
let response = adapter.execute(&target, &request).await.unwrap();
|
|
|
|
assert_eq!(response.body, json!({ "message": "hello" }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn collects_server_stream_messages_with_window_bounds() {
|
|
let server_addr = test_support::spawn_unary_echo_server().await;
|
|
let adapter = GrpcAdapter::new();
|
|
let target = GrpcTarget {
|
|
server_addr,
|
|
package: "echo".to_owned(),
|
|
service: "EchoService".to_owned(),
|
|
method: "ServerEcho".to_owned(),
|
|
descriptor_ref: DescriptorId::new("desc_echo"),
|
|
descriptor_set_b64: test_support::descriptor_set_b64(),
|
|
};
|
|
let request = GrpcWindowRequest {
|
|
request: GrpcRequest {
|
|
headers: BTreeMap::new(),
|
|
body: json!({ "message": "hello" }),
|
|
timeout_ms: 1_000,
|
|
},
|
|
window_duration_ms: 1_000,
|
|
max_items: Some(2),
|
|
};
|
|
|
|
let response = adapter.execute_window(&target, &request).await.unwrap();
|
|
|
|
assert_eq!(
|
|
response.body,
|
|
json!({
|
|
"items": [
|
|
{ "message": "hello-one" },
|
|
{ "message": "hello-two" }
|
|
],
|
|
"done": false
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_unary_method_in_window_mode() {
|
|
let server_addr = test_support::spawn_unary_echo_server().await;
|
|
let adapter = GrpcAdapter::new();
|
|
let target = GrpcTarget {
|
|
server_addr,
|
|
package: "echo".to_owned(),
|
|
service: "EchoService".to_owned(),
|
|
method: "UnaryEcho".to_owned(),
|
|
descriptor_ref: DescriptorId::new("desc_echo"),
|
|
descriptor_set_b64: test_support::descriptor_set_b64(),
|
|
};
|
|
let request = GrpcWindowRequest {
|
|
request: GrpcRequest {
|
|
headers: BTreeMap::new(),
|
|
body: json!({ "message": "hello" }),
|
|
timeout_ms: 1_000,
|
|
},
|
|
window_duration_ms: 1_000,
|
|
max_items: Some(10),
|
|
};
|
|
|
|
let error = adapter.execute_window(&target, &request).await.unwrap_err();
|
|
|
|
assert!(matches!(
|
|
error,
|
|
GrpcAdapterError::UnsupportedStreamingMethodKind { .. }
|
|
));
|
|
}
|
|
}
|