feat: add grpc support
This commit is contained in:
+170
-4
@@ -8,8 +8,9 @@ use crate::{
|
||||
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
|
||||
operations::{
|
||||
create_operation, create_version, export_operation, generate_draft, get_operation,
|
||||
get_operation_version, list_operations, publish_operation, run_test, upload_input_json,
|
||||
upload_output_json,
|
||||
get_operation_version, list_grpc_services, list_operations, publish_operation,
|
||||
run_test, upload_descriptor_set, upload_input_json, upload_output_json,
|
||||
upload_proto_descriptor,
|
||||
},
|
||||
},
|
||||
state::AppState,
|
||||
@@ -38,6 +39,18 @@ pub fn build_app(state: AppState) -> Router {
|
||||
"/operations/{operation_id}/samples/output-json",
|
||||
post(upload_output_json),
|
||||
)
|
||||
.route(
|
||||
"/operations/{operation_id}/descriptors/proto",
|
||||
post(upload_proto_descriptor),
|
||||
)
|
||||
.route(
|
||||
"/operations/{operation_id}/descriptors/descriptor-set",
|
||||
post(upload_descriptor_set),
|
||||
)
|
||||
.route(
|
||||
"/operations/{operation_id}/grpc/services",
|
||||
get(list_grpc_services),
|
||||
)
|
||||
.route(
|
||||
"/operations/{operation_id}/drafts/generate",
|
||||
post(generate_draft),
|
||||
@@ -67,9 +80,10 @@ mod tests {
|
||||
};
|
||||
|
||||
use axum::{Json, Router, routing::post};
|
||||
use mcpaas_adapter_grpc::test_support as grpc_test_support;
|
||||
use mcpaas_core::{
|
||||
ExecutionConfig, GraphqlOperationType, GraphqlTarget, HttpMethod, Protocol, RestTarget,
|
||||
Target, ToolDescription,
|
||||
DescriptorId, ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
|
||||
Protocol, RestTarget, Target, ToolDescription,
|
||||
};
|
||||
use mcpaas_mapping::{MappingRule, MappingSet};
|
||||
use mcpaas_registry::PostgresRegistry;
|
||||
@@ -199,6 +213,105 @@ mod tests {
|
||||
assert_eq!(test_run["response_preview"]["id"], "lead_123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uploads_descriptor_set_and_lists_grpc_services() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("grpc_descriptor");
|
||||
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_grpc_operation_payload(
|
||||
&server_addr,
|
||||
"echo_descriptor",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let uploaded = client
|
||||
.post(format!(
|
||||
"{base_url}/operations/{operation_id}/descriptors/descriptor-set"
|
||||
))
|
||||
.header("x-file-name", "echo_descriptor.bin")
|
||||
.body(grpc_test_support::echo::FILE_DESCRIPTOR_SET.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let services = client
|
||||
.get(format!(
|
||||
"{base_url}/operations/{operation_id}/grpc/services"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(uploaded["descriptor_id"].as_str().is_some());
|
||||
assert_eq!(services["services"][0]["package"], "echo");
|
||||
assert_eq!(services["services"][0]["service"], "EchoService");
|
||||
assert_eq!(services["services"][0]["methods"][0]["name"], "UnaryEcho");
|
||||
assert_eq!(services["services"][0]["methods"][0]["kind"], "unary");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_publishes_and_tests_grpc_operation() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("grpc_runtime");
|
||||
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_grpc_operation_payload(&server_addr, "echo_runtime"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let published = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let test_run = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "message": "hello" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(published["published_version"], 1);
|
||||
assert_eq!(test_run["ok"], true);
|
||||
assert_eq!(test_run["request_preview"]["grpc"]["message"], "hello");
|
||||
assert_eq!(test_run["response_preview"]["message"], "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manages_auth_profiles_and_yaml_upsert() {
|
||||
let registry = test_registry().await;
|
||||
@@ -536,6 +649,59 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_grpc_operation_payload(server_addr: &str, name: &str) -> OperationPayload {
|
||||
OperationPayload {
|
||||
name: name.to_owned(),
|
||||
display_name: "Unary Echo gRPC".to_owned(),
|
||||
protocol: Protocol::Grpc,
|
||||
target: Target::Grpc(GrpcTarget {
|
||||
server_addr: server_addr.to_owned(),
|
||||
package: "echo".to_owned(),
|
||||
service: "EchoService".to_owned(),
|
||||
method: "UnaryEcho".to_owned(),
|
||||
descriptor_ref: DescriptorId::new("desc_echo"),
|
||||
descriptor_set_b64: grpc_test_support::descriptor_set_b64(),
|
||||
}),
|
||||
input_schema: object_schema("message"),
|
||||
output_schema: object_schema("message"),
|
||||
input_mapping: MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.mcp.message".to_owned(),
|
||||
target: "$.request.grpc.message".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
},
|
||||
output_mapping: MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.response.data.message".to_owned(),
|
||||
target: "$.output.message".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
},
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Unary Echo gRPC".to_owned(),
|
||||
description: "Echoes a unary gRPC payload".to_owned(),
|
||||
tags: vec!["grpc".to_owned()],
|
||||
examples: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn object_schema(field_name: &str) -> Schema {
|
||||
Schema {
|
||||
kind: SchemaKind::Object,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use axum::{
|
||||
Json,
|
||||
body::Bytes,
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::IntoResponse,
|
||||
@@ -128,6 +129,56 @@ pub async fn upload_output_json(
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn upload_proto_descriptor(
|
||||
Path(operation_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let source_name = header_file_name(&headers);
|
||||
let descriptor = state
|
||||
.service
|
||||
.upload_proto_file(
|
||||
&operation_id.as_str().into(),
|
||||
source_name.as_deref(),
|
||||
body.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!(descriptor)))
|
||||
}
|
||||
|
||||
pub async fn upload_descriptor_set(
|
||||
Path(operation_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let source_name = header_file_name(&headers);
|
||||
let descriptor = state
|
||||
.service
|
||||
.upload_descriptor_set(
|
||||
&operation_id.as_str().into(),
|
||||
source_name.as_deref(),
|
||||
body.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!(descriptor)))
|
||||
}
|
||||
|
||||
pub async fn list_grpc_services(
|
||||
Path(operation_id): Path<String>,
|
||||
Query(query): Query<ExportQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let services = state
|
||||
.service
|
||||
.list_grpc_services(&operation_id.as_str().into(), query.version)
|
||||
.await?;
|
||||
Ok(Json(json!({ "services": services })))
|
||||
}
|
||||
|
||||
pub async fn generate_draft(
|
||||
Path(operation_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
@@ -166,3 +217,10 @@ pub async fn import_operation(
|
||||
let imported = state.service.import_operation(query, &body).await?;
|
||||
Ok(Json(json!(imported)))
|
||||
}
|
||||
|
||||
fn header_file_name(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("x-file-name")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ use mcpaas_core::{
|
||||
GeneratedDraftStatus, OperationId, OperationStatus, Protocol, SampleId, Samples, Target,
|
||||
};
|
||||
use mcpaas_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
|
||||
use mcpaas_proto::{ProtoService, services_from_descriptor_set_bytes};
|
||||
use mcpaas_registry::{
|
||||
CreateVersionRequest, OperationSampleMetadata, OperationSummary, OperationVersionRecord,
|
||||
PostgresRegistry, PublishRequest, RegistryOperation, SampleKind, SaveAuthProfileRequest,
|
||||
SaveSampleMetadataRequest,
|
||||
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
};
|
||||
use mcpaas_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
|
||||
use mcpaas_schema::Schema;
|
||||
@@ -139,6 +140,27 @@ pub struct ImportResponse {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DescriptorUploadResponse {
|
||||
pub descriptor_id: String,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct GrpcServiceSummary {
|
||||
pub package: String,
|
||||
pub service: String,
|
||||
pub methods: Vec<GrpcMethodSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct GrpcMethodSummary {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub input_schema: Schema,
|
||||
pub output_schema: Schema,
|
||||
}
|
||||
|
||||
impl AdminService {
|
||||
pub fn new(registry: PostgresRegistry, storage_root: PathBuf) -> Self {
|
||||
Self {
|
||||
@@ -345,6 +367,138 @@ impl AdminService {
|
||||
Ok(self.registry.list_auth_profiles().await?)
|
||||
}
|
||||
|
||||
pub async fn upload_descriptor_set(
|
||||
&self,
|
||||
operation_id: &OperationId,
|
||||
source_name: Option<&str>,
|
||||
payload: &[u8],
|
||||
) -> Result<DescriptorUploadResponse, ApiError> {
|
||||
let summary = self.get_operation(operation_id).await?;
|
||||
if summary.protocol != Protocol::Grpc {
|
||||
return Err(ApiError::validation(
|
||||
"descriptor upload is only allowed for grpc operations",
|
||||
));
|
||||
}
|
||||
|
||||
let services = services_from_descriptor_set_bytes(payload)
|
||||
.map_err(|error| ApiError::validation(error.to_string()))?;
|
||||
let descriptor_id = mcpaas_core::DescriptorId::new(new_prefixed_id("desc"));
|
||||
let storage_ref = self
|
||||
.storage
|
||||
.write_descriptor(
|
||||
operation_id,
|
||||
summary.current_draft_version,
|
||||
mcpaas_registry::DescriptorKind::DescriptorSet,
|
||||
&descriptor_id,
|
||||
source_name,
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.registry
|
||||
.save_descriptor_metadata(SaveDescriptorMetadataRequest {
|
||||
descriptor: &mcpaas_registry::DescriptorMetadata {
|
||||
id: descriptor_id.clone(),
|
||||
operation_id: Some(operation_id.clone()),
|
||||
version: Some(summary.current_draft_version),
|
||||
descriptor_kind: mcpaas_registry::DescriptorKind::DescriptorSet,
|
||||
storage_ref,
|
||||
source_name: source_name.map(ToOwned::to_owned),
|
||||
package_index: Some(
|
||||
serde_json::to_value(&services)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
||||
),
|
||||
created_at: now_string()?,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(DescriptorUploadResponse {
|
||||
descriptor_id: descriptor_id.as_str().to_owned(),
|
||||
version: summary.current_draft_version,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn upload_proto_file(
|
||||
&self,
|
||||
operation_id: &OperationId,
|
||||
source_name: Option<&str>,
|
||||
payload: &[u8],
|
||||
) -> Result<DescriptorUploadResponse, ApiError> {
|
||||
let summary = self.get_operation(operation_id).await?;
|
||||
if summary.protocol != Protocol::Grpc {
|
||||
return Err(ApiError::validation(
|
||||
"proto upload is only allowed for grpc operations",
|
||||
));
|
||||
}
|
||||
|
||||
let descriptor_id = mcpaas_core::DescriptorId::new(new_prefixed_id("desc"));
|
||||
let storage_ref = self
|
||||
.storage
|
||||
.write_descriptor(
|
||||
operation_id,
|
||||
summary.current_draft_version,
|
||||
mcpaas_registry::DescriptorKind::ProtoUpload,
|
||||
&descriptor_id,
|
||||
source_name,
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.registry
|
||||
.save_descriptor_metadata(SaveDescriptorMetadataRequest {
|
||||
descriptor: &mcpaas_registry::DescriptorMetadata {
|
||||
id: descriptor_id.clone(),
|
||||
operation_id: Some(operation_id.clone()),
|
||||
version: Some(summary.current_draft_version),
|
||||
descriptor_kind: mcpaas_registry::DescriptorKind::ProtoUpload,
|
||||
storage_ref,
|
||||
source_name: source_name.map(ToOwned::to_owned),
|
||||
package_index: None,
|
||||
created_at: now_string()?,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(DescriptorUploadResponse {
|
||||
descriptor_id: descriptor_id.as_str().to_owned(),
|
||||
version: summary.current_draft_version,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_grpc_services(
|
||||
&self,
|
||||
operation_id: &OperationId,
|
||||
version: Option<u32>,
|
||||
) -> Result<Vec<GrpcServiceSummary>, ApiError> {
|
||||
let summary = self.get_operation(operation_id).await?;
|
||||
if summary.protocol != Protocol::Grpc {
|
||||
return Err(ApiError::validation(
|
||||
"grpc services are only available for grpc operations",
|
||||
));
|
||||
}
|
||||
|
||||
let version = version.unwrap_or(summary.current_draft_version);
|
||||
let descriptor = self
|
||||
.registry
|
||||
.list_descriptor_metadata(operation_id, version)
|
||||
.await?
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|descriptor| {
|
||||
descriptor.descriptor_kind == mcpaas_registry::DescriptorKind::DescriptorSet
|
||||
})
|
||||
.ok_or_else(|| ApiError::not_found("descriptor set was not uploaded"))?;
|
||||
let bytes = self.storage.read_bytes(&descriptor.storage_ref).await?;
|
||||
let services = services_from_descriptor_set_bytes(&bytes)
|
||||
.map_err(|error| ApiError::validation(error.to_string()))?;
|
||||
|
||||
services
|
||||
.into_iter()
|
||||
.map(proto_service_summary)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
|
||||
pub async fn get_auth_profile(
|
||||
&self,
|
||||
auth_profile_id: &AuthProfileId,
|
||||
@@ -612,6 +766,7 @@ fn build_request_preview(
|
||||
"path": prepared.path_params,
|
||||
"query": prepared.query_params,
|
||||
"headers": prepared.headers,
|
||||
"grpc": prepared.grpc.unwrap_or(Value::Null),
|
||||
"variables": prepared.variables.unwrap_or(Value::Null),
|
||||
"body": prepared.body.unwrap_or(Value::Null)
|
||||
}))
|
||||
@@ -672,3 +827,25 @@ fn now_string() -> Result<String, ApiError> {
|
||||
.format(&Rfc3339)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))
|
||||
}
|
||||
|
||||
fn proto_service_summary(service: ProtoService) -> Result<GrpcServiceSummary, ApiError> {
|
||||
let methods = service
|
||||
.unary_methods()
|
||||
.map(|method| {
|
||||
Ok(GrpcMethodSummary {
|
||||
name: method.name.clone(),
|
||||
kind: "unary".to_owned(),
|
||||
input_schema: mcpaas_proto::message_to_schema(&method.input)
|
||||
.map_err(|error| ApiError::validation(error.to_string()))?,
|
||||
output_schema: mcpaas_proto::message_to_schema(&method.output)
|
||||
.map_err(|error| ApiError::validation(error.to_string()))?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, ApiError>>()?;
|
||||
|
||||
Ok(GrpcServiceSummary {
|
||||
package: service.package,
|
||||
service: service.name,
|
||||
methods,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use mcpaas_core::{OperationId, SampleId};
|
||||
use mcpaas_registry::SampleKind;
|
||||
use mcpaas_core::{DescriptorId, OperationId, SampleId};
|
||||
use mcpaas_registry::{DescriptorKind, SampleKind};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
use tokio::fs;
|
||||
@@ -57,6 +57,39 @@ impl LocalArtifactStorage {
|
||||
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
|
||||
pub async fn write_descriptor(
|
||||
&self,
|
||||
operation_id: &OperationId,
|
||||
version: u32,
|
||||
descriptor_kind: DescriptorKind,
|
||||
descriptor_id: &DescriptorId,
|
||||
source_name: Option<&str>,
|
||||
payload: &[u8],
|
||||
) -> Result<String, StorageError> {
|
||||
let file_name = match descriptor_kind {
|
||||
DescriptorKind::ProtoUpload => source_name.unwrap_or("schema.proto").to_owned(),
|
||||
DescriptorKind::DescriptorSet => source_name.unwrap_or("descriptor-set.bin").to_owned(),
|
||||
DescriptorKind::ReflectionSnapshot => {
|
||||
source_name.unwrap_or("reflection.bin").to_owned()
|
||||
}
|
||||
};
|
||||
let path = self
|
||||
.root
|
||||
.join("descriptors")
|
||||
.join(operation_id.as_str())
|
||||
.join(format!("v{version}"))
|
||||
.join(format!("{}_{}", descriptor_id.as_str(), file_name));
|
||||
|
||||
write_bytes(&path, payload).await?;
|
||||
|
||||
Ok(to_storage_ref(&path))
|
||||
}
|
||||
|
||||
pub async fn read_bytes(&self, storage_ref: &str) -> Result<Vec<u8>, StorageError> {
|
||||
let path = from_storage_ref(storage_ref)?;
|
||||
Ok(fs::read(path).await?)
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_json_file(path: &Path, payload: &Value) -> Result<(), StorageError> {
|
||||
@@ -69,6 +102,15 @@ async fn write_json_file(path: &Path, payload: &Value) -> Result<(), StorageErro
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_bytes(path: &Path, payload: &[u8]) -> Result<(), StorageError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
fs::write(path, payload).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_storage_ref(path: &Path) -> String {
|
||||
format!("file://{}", path.display())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user