feat: add protobuf schema bridge contracts

This commit is contained in:
a.tolmachev
2026-03-25 16:06:43 +03:00
parent ceb37ad0d3
commit 7901a0365a
12 changed files with 527 additions and 4 deletions
+2 -1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
pub mod to_schema;
@@ -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<Schema, ProtoError> {
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<Schema, ProtoError> {
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<Schema, ProtoError> {
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<String, Schema>) -> 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::<Vec<_>>();
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(),
}
}
}
+7
View File
@@ -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 },
}
+10 -3
View File
@@ -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,
};
+54
View File
@@ -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<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtoMapField {
pub key: ProtoScalarKind,
pub value: Box<ProtoFieldType>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProtoFieldType {
Scalar { scalar: ProtoScalarKind },
Message { message: Box<ProtoMessage> },
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,
}
+16
View File
@@ -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<ProtoField>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtoMessage {
pub name: String,
pub fields: Vec<ProtoField>,
pub oneofs: Vec<ProtoOneof>,
}
+18
View File
@@ -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
}
}
+11
View File
@@ -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;
+16
View File
@@ -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<ProtoMethod>,
}
impl ProtoService {
pub fn unary_methods(&self) -> impl Iterator<Item = &ProtoMethod> {
self.methods.iter().filter(|method| method.is_unary())
}
}
+9
View File
@@ -310,6 +310,15 @@
}
```
### Нормализация protobuf-структур
Для protobuf -> schema bridge дополнительно фиксируются такие правила:
- `repeated` поле преобразуется в `array`;
- `enum` преобразуется в `type: enum` со списком `enum_values`;
- `map<K, V>` преобразуется в `array` объектов `{ key, value }`;
- `oneof` преобразуется в `type: oneof`, где каждый вариант представлен объектом с одним допустимым полем.
## 6. `MappingSet` и `MappingRule`
`MappingSet` - набор правил преобразования между внутренним MCP input/output и protocol-specific request/response model.
+2
View File
@@ -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-модели.