feat: add protobuf schema bridge contracts
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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-модели.
|
||||
|
||||
Reference in New Issue
Block a user