From 7901a0365ae08f06138aafed1aea2d69d63aeae7 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Wed, 25 Mar 2026 16:06:43 +0300 Subject: [PATCH] feat: add protobuf schema bridge contracts --- TASKS.md | 3 +- crates/mcpaas-proto/src/convert/mod.rs | 1 + crates/mcpaas-proto/src/convert/to_schema.rs | 381 +++++++++++++++++++ crates/mcpaas-proto/src/errors.rs | 7 + crates/mcpaas-proto/src/lib.rs | 13 +- crates/mcpaas-proto/src/model/field.rs | 54 +++ crates/mcpaas-proto/src/model/message.rs | 16 + crates/mcpaas-proto/src/model/method.rs | 18 + crates/mcpaas-proto/src/model/mod.rs | 11 + crates/mcpaas-proto/src/model/service.rs | 16 + docs/data-model.md | 9 + docs/protocols/grpc.md | 2 + 12 files changed, 527 insertions(+), 4 deletions(-) create mode 100644 crates/mcpaas-proto/src/convert/mod.rs create mode 100644 crates/mcpaas-proto/src/convert/to_schema.rs create mode 100644 crates/mcpaas-proto/src/errors.rs create mode 100644 crates/mcpaas-proto/src/model/field.rs create mode 100644 crates/mcpaas-proto/src/model/message.rs create mode 100644 crates/mcpaas-proto/src/model/method.rs create mode 100644 crates/mcpaas-proto/src/model/mod.rs create mode 100644 crates/mcpaas-proto/src/model/service.rs diff --git a/TASKS.md b/TASKS.md index 2b9f295..a873576 100644 --- a/TASKS.md +++ b/TASKS.md @@ -4,13 +4,14 @@ ### `feat/schema-engine` -Status: in_progress +Status: completed DoD: - schema model exists - schema validation works - JSON sample normalization works +- protobuf -> schema bridge contracts exist ## Next diff --git a/crates/mcpaas-proto/src/convert/mod.rs b/crates/mcpaas-proto/src/convert/mod.rs new file mode 100644 index 0000000..0fb1eac --- /dev/null +++ b/crates/mcpaas-proto/src/convert/mod.rs @@ -0,0 +1 @@ +pub mod to_schema; diff --git a/crates/mcpaas-proto/src/convert/to_schema.rs b/crates/mcpaas-proto/src/convert/to_schema.rs new file mode 100644 index 0000000..183fc6d --- /dev/null +++ b/crates/mcpaas-proto/src/convert/to_schema.rs @@ -0,0 +1,381 @@ +use std::collections::BTreeMap; + +use mcpaas_schema::{Schema, SchemaKind}; + +use crate::{ + errors::ProtoError, + model::{ + ProtoEnum, ProtoField, ProtoFieldCardinality, ProtoFieldType, ProtoMapField, ProtoMessage, + ProtoOneof, ProtoScalarKind, + }, +}; + +pub fn message_to_schema(message: &ProtoMessage) -> Result { + let mut fields = BTreeMap::new(); + + for field in &message.fields { + let (name, schema) = convert_field(field)?; + fields.insert(name, schema); + } + + for oneof in &message.oneofs { + let (name, schema) = convert_oneof(oneof)?; + fields.insert(name, schema); + } + + Ok(object_schema(fields)) +} + +fn convert_field(field: &ProtoField) -> Result<(String, Schema), ProtoError> { + let mut schema = convert_field_type(&field.field_type)?; + + schema.required = matches!(field.cardinality, ProtoFieldCardinality::Required); + schema.nullable = false; + + if matches!(field.cardinality, ProtoFieldCardinality::Repeated) { + schema = Schema { + kind: SchemaKind::Array, + description: None, + required: false, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: Some(Box::new(schema)), + enum_values: Vec::new(), + variants: Vec::new(), + }; + } + + Ok((field.name.clone(), schema)) +} + +fn convert_field_type(field_type: &ProtoFieldType) -> Result { + match field_type { + ProtoFieldType::Scalar { scalar } => Ok(scalar_schema(scalar)), + ProtoFieldType::Message { message } => message_to_schema(message), + ProtoFieldType::Enum { enumeration } => Ok(enum_schema(enumeration)), + ProtoFieldType::Map { map } => convert_map(map), + } +} + +fn convert_map(map: &ProtoMapField) -> Result { + let entry = object_schema(BTreeMap::from([ + ("key".to_owned(), scalar_schema(&map.key)), + ("value".to_owned(), convert_field_type(&map.value)?), + ])); + + Ok(Schema { + kind: SchemaKind::Array, + description: None, + required: false, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: Some(Box::new(entry)), + enum_values: Vec::new(), + variants: Vec::new(), + }) +} + +fn convert_oneof(oneof: &ProtoOneof) -> Result<(String, Schema), ProtoError> { + if oneof.variants.is_empty() { + return Err(ProtoError::EmptyOneof { + oneof_name: oneof.name.clone(), + }); + } + + let mut variants = Vec::with_capacity(oneof.variants.len()); + + for field in &oneof.variants { + let (field_name, field_schema) = convert_field(field)?; + variants.push(object_schema(BTreeMap::from([(field_name, field_schema)]))); + } + + Ok(( + oneof.name.clone(), + Schema { + kind: SchemaKind::Oneof, + description: None, + required: false, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants, + }, + )) +} + +fn object_schema(fields: BTreeMap) -> Schema { + Schema { + kind: SchemaKind::Object, + description: None, + required: true, + nullable: false, + default_value: None, + fields, + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } +} + +fn enum_schema(enumeration: &ProtoEnum) -> Schema { + Schema { + kind: SchemaKind::Enum, + description: None, + required: false, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: enumeration.values.clone(), + variants: Vec::new(), + } +} + +fn scalar_schema(kind: &ProtoScalarKind) -> Schema { + let schema_kind = match kind { + ProtoScalarKind::String | ProtoScalarKind::Bytes => SchemaKind::String, + ProtoScalarKind::Bool => SchemaKind::Boolean, + ProtoScalarKind::Int32 + | ProtoScalarKind::Int64 + | ProtoScalarKind::Uint32 + | ProtoScalarKind::Uint64 => SchemaKind::Integer, + ProtoScalarKind::Float | ProtoScalarKind::Double => SchemaKind::Number, + }; + + Schema { + kind: schema_kind, + description: None, + required: false, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use mcpaas_schema::SchemaKind; + + use crate::{ + ProtoEnum, ProtoField, ProtoFieldCardinality, ProtoFieldType, ProtoMapField, ProtoMessage, + ProtoMethod, ProtoOneof, ProtoScalarKind, ProtoService, message_to_schema, + }; + + #[test] + fn service_filters_unary_methods() { + let unary = ProtoMethod { + name: "GetLead".to_owned(), + input: empty_message("GetLeadRequest"), + output: empty_message("GetLeadResponse"), + client_streaming: false, + server_streaming: false, + }; + + let streaming = ProtoMethod { + name: "StreamLeads".to_owned(), + input: empty_message("StreamLeadsRequest"), + output: empty_message("StreamLeadsResponse"), + client_streaming: false, + server_streaming: true, + }; + + let service = ProtoService { + package: "crm.v1".to_owned(), + name: "LeadService".to_owned(), + methods: vec![unary.clone(), streaming], + }; + + let unary_methods = service.unary_methods().collect::>(); + + assert_eq!(unary_methods.len(), 1); + assert_eq!(unary_methods[0].name, unary.name); + } + + #[test] + fn converts_nested_message_to_schema() { + let request = ProtoMessage { + name: "CreateLeadRequest".to_owned(), + fields: vec![ProtoField { + name: "lead".to_owned(), + json_name: "lead".to_owned(), + cardinality: ProtoFieldCardinality::Required, + field_type: ProtoFieldType::Message { + message: Box::new(ProtoMessage { + name: "Lead".to_owned(), + fields: vec![ProtoField { + name: "email".to_owned(), + json_name: "email".to_owned(), + cardinality: ProtoFieldCardinality::Required, + field_type: ProtoFieldType::Scalar { + scalar: ProtoScalarKind::String, + }, + }], + oneofs: Vec::new(), + }), + }, + }], + oneofs: Vec::new(), + }; + + let schema = message_to_schema(&request).unwrap(); + + assert_eq!(schema.kind, SchemaKind::Object); + assert_eq!( + schema.field("lead").map(|field| field.kind.clone()), + Some(SchemaKind::Object) + ); + assert_eq!( + schema + .field("lead") + .and_then(|field| field.field("email")) + .map(|field| field.kind.clone()), + Some(SchemaKind::String) + ); + assert_eq!( + schema + .field("lead") + .and_then(|field| field.field("email")) + .map(|field| field.required), + Some(true) + ); + } + + #[test] + fn converts_enum_map_and_oneof_contracts() { + let response = ProtoMessage { + name: "GetLeadResponse".to_owned(), + fields: vec![ + ProtoField { + name: "status".to_owned(), + json_name: "status".to_owned(), + cardinality: ProtoFieldCardinality::Optional, + field_type: ProtoFieldType::Enum { + enumeration: ProtoEnum { + name: "LeadStatus".to_owned(), + values: vec!["OPEN".to_owned(), "CLOSED".to_owned()], + }, + }, + }, + ProtoField { + name: "attributes".to_owned(), + json_name: "attributes".to_owned(), + cardinality: ProtoFieldCardinality::Optional, + field_type: ProtoFieldType::Map { + map: ProtoMapField { + key: ProtoScalarKind::String, + value: Box::new(ProtoFieldType::Scalar { + scalar: ProtoScalarKind::String, + }), + }, + }, + }, + ], + oneofs: vec![ProtoOneof { + name: "contact".to_owned(), + variants: vec![ + ProtoField { + name: "email".to_owned(), + json_name: "email".to_owned(), + cardinality: ProtoFieldCardinality::Optional, + field_type: ProtoFieldType::Scalar { + scalar: ProtoScalarKind::String, + }, + }, + ProtoField { + name: "phone".to_owned(), + json_name: "phone".to_owned(), + cardinality: ProtoFieldCardinality::Optional, + field_type: ProtoFieldType::Scalar { + scalar: ProtoScalarKind::String, + }, + }, + ], + }], + }; + + let schema = message_to_schema(&response).unwrap(); + + assert_eq!( + schema.field("status").map(|field| field.kind.clone()), + Some(SchemaKind::Enum) + ); + assert_eq!( + schema + .field("status") + .map(|field| field.enum_values.clone()) + .unwrap(), + vec!["OPEN".to_owned(), "CLOSED".to_owned()] + ); + + let attributes = schema.field("attributes").unwrap(); + assert_eq!(attributes.kind, SchemaKind::Array); + assert_eq!( + attributes + .items + .as_deref() + .and_then(|entry| entry.field("key")) + .map(|field| field.kind.clone()), + Some(SchemaKind::String) + ); + assert_eq!( + attributes + .items + .as_deref() + .and_then(|entry| entry.field("value")) + .map(|field| field.kind.clone()), + Some(SchemaKind::String) + ); + + let contact = schema.field("contact").unwrap(); + assert_eq!(contact.kind, SchemaKind::Oneof); + assert_eq!(contact.variants.len(), 2); + assert_eq!( + contact.variants[0] + .field("email") + .map(|field| field.kind.clone()), + Some(SchemaKind::String) + ); + assert_eq!( + contact.variants[1] + .field("phone") + .map(|field| field.kind.clone()), + Some(SchemaKind::String) + ); + } + + #[test] + fn rejects_empty_oneof() { + let message = ProtoMessage { + name: "BrokenMessage".to_owned(), + fields: Vec::new(), + oneofs: vec![ProtoOneof { + name: "selection".to_owned(), + variants: Vec::new(), + }], + }; + + let error = message_to_schema(&message).unwrap_err(); + + assert_eq!( + error, + crate::ProtoError::EmptyOneof { + oneof_name: "selection".to_owned(), + } + ); + } + + fn empty_message(name: &str) -> ProtoMessage { + ProtoMessage { + name: name.to_owned(), + fields: Vec::new(), + oneofs: Vec::new(), + } + } +} diff --git a/crates/mcpaas-proto/src/errors.rs b/crates/mcpaas-proto/src/errors.rs new file mode 100644 index 0000000..11dfc56 --- /dev/null +++ b/crates/mcpaas-proto/src/errors.rs @@ -0,0 +1,7 @@ +use thiserror::Error; + +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum ProtoError { + #[error("oneof {oneof_name} must contain at least one variant")] + EmptyOneof { oneof_name: String }, +} diff --git a/crates/mcpaas-proto/src/lib.rs b/crates/mcpaas-proto/src/lib.rs index d914202..69c90a9 100644 --- a/crates/mcpaas-proto/src/lib.rs +++ b/crates/mcpaas-proto/src/lib.rs @@ -1,3 +1,10 @@ -pub fn crate_name() -> &'static str { - "mcpaas-proto" -} +pub mod convert; +pub mod errors; +pub mod model; + +pub use convert::to_schema::message_to_schema; +pub use errors::ProtoError; +pub use model::{ + ProtoEnum, ProtoField, ProtoFieldCardinality, ProtoFieldType, ProtoMapField, ProtoMessage, + ProtoMethod, ProtoOneof, ProtoScalarKind, ProtoService, +}; diff --git a/crates/mcpaas-proto/src/model/field.rs b/crates/mcpaas-proto/src/model/field.rs new file mode 100644 index 0000000..9277e00 --- /dev/null +++ b/crates/mcpaas-proto/src/model/field.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; + +use crate::model::ProtoMessage; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProtoScalarKind { + String, + Bool, + Int32, + Int64, + Uint32, + Uint64, + Float, + Double, + Bytes, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtoEnum { + pub name: String, + pub values: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtoMapField { + pub key: ProtoScalarKind, + pub value: Box, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ProtoFieldType { + Scalar { scalar: ProtoScalarKind }, + Message { message: Box }, + Enum { enumeration: ProtoEnum }, + Map { map: ProtoMapField }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProtoFieldCardinality { + Optional, + Required, + Repeated, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtoField { + pub name: String, + pub json_name: String, + pub cardinality: ProtoFieldCardinality, + pub field_type: ProtoFieldType, +} diff --git a/crates/mcpaas-proto/src/model/message.rs b/crates/mcpaas-proto/src/model/message.rs new file mode 100644 index 0000000..258d51f --- /dev/null +++ b/crates/mcpaas-proto/src/model/message.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +use crate::model::ProtoField; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtoOneof { + pub name: String, + pub variants: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtoMessage { + pub name: String, + pub fields: Vec, + pub oneofs: Vec, +} diff --git a/crates/mcpaas-proto/src/model/method.rs b/crates/mcpaas-proto/src/model/method.rs new file mode 100644 index 0000000..d95fde1 --- /dev/null +++ b/crates/mcpaas-proto/src/model/method.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +use crate::model::ProtoMessage; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtoMethod { + pub name: String, + pub input: ProtoMessage, + pub output: ProtoMessage, + pub client_streaming: bool, + pub server_streaming: bool, +} + +impl ProtoMethod { + pub fn is_unary(&self) -> bool { + !self.client_streaming && !self.server_streaming + } +} diff --git a/crates/mcpaas-proto/src/model/mod.rs b/crates/mcpaas-proto/src/model/mod.rs new file mode 100644 index 0000000..216eb10 --- /dev/null +++ b/crates/mcpaas-proto/src/model/mod.rs @@ -0,0 +1,11 @@ +mod field; +mod message; +mod method; +mod service; + +pub use field::{ + ProtoEnum, ProtoField, ProtoFieldCardinality, ProtoFieldType, ProtoMapField, ProtoScalarKind, +}; +pub use message::{ProtoMessage, ProtoOneof}; +pub use method::ProtoMethod; +pub use service::ProtoService; diff --git a/crates/mcpaas-proto/src/model/service.rs b/crates/mcpaas-proto/src/model/service.rs new file mode 100644 index 0000000..fd4ba4b --- /dev/null +++ b/crates/mcpaas-proto/src/model/service.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +use crate::model::ProtoMethod; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtoService { + pub package: String, + pub name: String, + pub methods: Vec, +} + +impl ProtoService { + pub fn unary_methods(&self) -> impl Iterator { + self.methods.iter().filter(|method| method.is_unary()) + } +} diff --git a/docs/data-model.md b/docs/data-model.md index c1e1a51..9dff2e7 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -310,6 +310,15 @@ } ``` +### Нормализация protobuf-структур + +Для protobuf -> schema bridge дополнительно фиксируются такие правила: + +- `repeated` поле преобразуется в `array`; +- `enum` преобразуется в `type: enum` со списком `enum_values`; +- `map` преобразуется в `array` объектов `{ key, value }`; +- `oneof` преобразуется в `type: oneof`, где каждый вариант представлен объектом с одним допустимым полем. + ## 6. `MappingSet` и `MappingRule` `MappingSet` - набор правил преобразования между внутренним MCP input/output и protocol-specific request/response model. diff --git a/docs/protocols/grpc.md b/docs/protocols/grpc.md index efdfe57..ace02c1 100644 --- a/docs/protocols/grpc.md +++ b/docs/protocols/grpc.md @@ -91,6 +91,8 @@ gRPC operation должна включать: - `.proto` и descriptor handling должны быть отделены от runtime-вызова; - protobuf discovery не должен жить внутри gRPC adapter; - `oneof`, `enum`, `repeated`, `map` и well-known types требуют отдельной нормализации; +- `map` на слое нормализованной schema модели представляется как `array` объектов вида `{ key, value }`; +- `oneof` на слое нормализованной schema модели представляется как `oneof` с вариантами-объектами, каждый из которых содержит одно допустимое поле; - схема сообщения должна быть представлена в UI как обычная форма полей, а не как сырой protobuf descriptor; - пользователь не должен видеть внутреннюю сложность protobuf-контракта больше, чем это нужно для настройки operation. - `JSONPath` используется как единый способ точечной адресации вложенных полей при настройке mapping поверх нормализованной JSON-модели.