merge: integrate registry storage
This commit is contained in:
@@ -15,6 +15,22 @@ jobs:
|
||||
rust:
|
||||
name: Rust Checks
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: rmcp_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d rmcp_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/rmcp_test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
Generated
+1163
-5
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ axum = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -2,32 +2,24 @@
|
||||
|
||||
## Current
|
||||
|
||||
### `feat/schema-engine`
|
||||
### `feat/registry-storage`
|
||||
|
||||
Status: completed
|
||||
|
||||
DoD:
|
||||
|
||||
- schema model exists
|
||||
- schema validation works
|
||||
- JSON sample normalization works
|
||||
- protobuf -> schema bridge contracts exist
|
||||
- migrations create tables from `database-schema`
|
||||
- version snapshots persist and load correctly
|
||||
- publish linkage works
|
||||
- auth profiles and artifact metadata are stored
|
||||
- registry integration tests run on a real SQLite database
|
||||
|
||||
## Next
|
||||
|
||||
### `feat/mapping-engine`
|
||||
|
||||
Status: pending
|
||||
|
||||
DoD:
|
||||
|
||||
- JSONPath parsing works
|
||||
- input and output mapping work
|
||||
- draft generation from samples works
|
||||
- `feat/rest-vertical-slice`
|
||||
|
||||
## Backlog
|
||||
|
||||
- `feat/registry-storage`
|
||||
- `feat/rest-vertical-slice`
|
||||
- `feat/admin-api-v1`
|
||||
- `feat/ui-v1`
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Error)]
|
||||
pub enum MappingError {
|
||||
#[error("invalid JSONPath: {path}")]
|
||||
InvalidJsonPath { path: String },
|
||||
#[error("unsupported source root {root} for {context} mapping")]
|
||||
UnsupportedSourceRoot { root: String, context: &'static str },
|
||||
#[error("unsupported target root {root} for {context} mapping")]
|
||||
UnsupportedTargetRoot { root: String, context: &'static str },
|
||||
#[error("mixed mapping target contexts are not allowed")]
|
||||
MixedTargetContext,
|
||||
#[error("missing required value at {path}")]
|
||||
MissingRequiredValue { path: String },
|
||||
#[error("transform {transform} cannot be applied to {actual}")]
|
||||
InvalidTransformInput {
|
||||
transform: &'static str,
|
||||
actual: &'static str,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
use serde_json::{Map, Number, Value};
|
||||
|
||||
use crate::{
|
||||
JsonPath, JsonPathSegment, MappingError, MappingSet, MappingTargetContext, TransformKind,
|
||||
};
|
||||
|
||||
impl MappingSet {
|
||||
pub fn apply(&self, source: &Value) -> Result<Value, MappingError> {
|
||||
self.validate_paths()?;
|
||||
|
||||
let mut target = empty_target_context(self.target_context());
|
||||
|
||||
for rule in &self.rules {
|
||||
if let Some(condition) = &rule.condition {
|
||||
let condition_path = JsonPath::parse(&condition.source)?;
|
||||
let condition_value = read_path(source, &condition_path);
|
||||
|
||||
if condition_value != Some(&condition.equals) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let source_path = JsonPath::parse(&rule.source)?;
|
||||
let target_path = JsonPath::parse(&rule.target)?;
|
||||
|
||||
let Some(source_value) = read_path(source, &source_path)
|
||||
.cloned()
|
||||
.or(rule.default_value.clone())
|
||||
else {
|
||||
if rule.required {
|
||||
return Err(MappingError::MissingRequiredValue {
|
||||
path: source_path.to_string_path(),
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
};
|
||||
|
||||
let value = apply_transform(
|
||||
source_value,
|
||||
rule.transform.as_ref().map(|value| &value.kind),
|
||||
)?;
|
||||
write_path(&mut target, &target_path, value)?;
|
||||
}
|
||||
|
||||
Ok(target)
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_target_context(context: MappingTargetContext) -> Value {
|
||||
match context {
|
||||
MappingTargetContext::Input => Value::Object(Map::from_iter([(
|
||||
"request".to_owned(),
|
||||
Value::Object(Map::from_iter([
|
||||
("path".to_owned(), Value::Object(Map::new())),
|
||||
("query".to_owned(), Value::Object(Map::new())),
|
||||
("headers".to_owned(), Value::Object(Map::new())),
|
||||
("body".to_owned(), Value::Object(Map::new())),
|
||||
("variables".to_owned(), Value::Object(Map::new())),
|
||||
("grpc".to_owned(), Value::Object(Map::new())),
|
||||
])),
|
||||
)])),
|
||||
MappingTargetContext::Output => Value::Object(Map::from_iter([(
|
||||
"output".to_owned(),
|
||||
Value::Object(Map::new()),
|
||||
)])),
|
||||
_ => Value::Object(Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_path<'a>(value: &'a Value, path: &JsonPath) -> Option<&'a Value> {
|
||||
let mut current = value;
|
||||
|
||||
for field in path.root.root_fields() {
|
||||
current = current.get(*field)?;
|
||||
}
|
||||
|
||||
for segment in &path.segments {
|
||||
current = match segment {
|
||||
JsonPathSegment::Field(field) => current.get(field)?,
|
||||
JsonPathSegment::Index(index) => current.get(*index)?,
|
||||
};
|
||||
}
|
||||
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn write_path(target: &mut Value, path: &JsonPath, value: Value) -> Result<(), MappingError> {
|
||||
let mut segments = path
|
||||
.root
|
||||
.root_fields()
|
||||
.iter()
|
||||
.map(|field| JsonPathSegment::Field((*field).to_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
segments.extend(path.segments.clone());
|
||||
|
||||
write_segments(target, &segments, value)
|
||||
}
|
||||
|
||||
fn write_segments(
|
||||
target: &mut Value,
|
||||
segments: &[JsonPathSegment],
|
||||
value: Value,
|
||||
) -> Result<(), MappingError> {
|
||||
if segments.is_empty() {
|
||||
*target = value;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match &segments[0] {
|
||||
JsonPathSegment::Field(field) => {
|
||||
if !target.is_object() {
|
||||
*target = Value::Object(Map::new());
|
||||
}
|
||||
|
||||
let Some(object) = target.as_object_mut() else {
|
||||
return Err(MappingError::InvalidJsonPath {
|
||||
path: field.clone(),
|
||||
});
|
||||
};
|
||||
let entry = object.entry(field.clone()).or_insert(Value::Null);
|
||||
|
||||
write_segments(entry, &segments[1..], value)
|
||||
}
|
||||
JsonPathSegment::Index(index) => {
|
||||
if !target.is_array() {
|
||||
*target = Value::Array(Vec::new());
|
||||
}
|
||||
|
||||
let Some(array) = target.as_array_mut() else {
|
||||
return Err(MappingError::InvalidJsonPath {
|
||||
path: index.to_string(),
|
||||
});
|
||||
};
|
||||
while array.len() <= *index {
|
||||
array.push(Value::Null);
|
||||
}
|
||||
|
||||
write_segments(&mut array[*index], &segments[1..], value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_transform(value: Value, transform: Option<&TransformKind>) -> Result<Value, MappingError> {
|
||||
let Some(transform) = transform else {
|
||||
return Ok(value);
|
||||
};
|
||||
|
||||
match transform {
|
||||
TransformKind::Identity => Ok(value),
|
||||
TransformKind::ToString => Ok(Value::String(to_string_value(&value))),
|
||||
TransformKind::ToNumber => to_number(value),
|
||||
TransformKind::ToBoolean => to_boolean(value),
|
||||
TransformKind::Join => join_values(value),
|
||||
TransformKind::Split => split_value(value),
|
||||
TransformKind::WrapArray => Ok(match value {
|
||||
Value::Array(values) => Value::Array(values),
|
||||
other => Value::Array(vec![other]),
|
||||
}),
|
||||
TransformKind::UnwrapSingleton => unwrap_singleton(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_string_value(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => "null".to_owned(),
|
||||
Value::Bool(boolean) => boolean.to_string(),
|
||||
Value::Number(number) => number.to_string(),
|
||||
Value::String(text) => text.clone(),
|
||||
Value::Array(_) | Value::Object(_) => serde_json::to_string(value).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_number(value: Value) -> Result<Value, MappingError> {
|
||||
let number = match value {
|
||||
Value::Number(number) => return Ok(Value::Number(number)),
|
||||
Value::String(text) => {
|
||||
if let Ok(integer) = text.parse::<i64>() {
|
||||
Number::from(integer)
|
||||
} else {
|
||||
Number::from_f64(text.parse::<f64>().map_err(|_| {
|
||||
MappingError::InvalidTransformInput {
|
||||
transform: "to_number",
|
||||
actual: "string",
|
||||
}
|
||||
})?)
|
||||
.ok_or(MappingError::InvalidTransformInput {
|
||||
transform: "to_number",
|
||||
actual: "string",
|
||||
})?
|
||||
}
|
||||
}
|
||||
Value::Bool(boolean) => Number::from(if boolean { 1 } else { 0 }),
|
||||
other => {
|
||||
return Err(MappingError::InvalidTransformInput {
|
||||
transform: "to_number",
|
||||
actual: type_name(&other),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Value::Number(number))
|
||||
}
|
||||
|
||||
fn to_boolean(value: Value) -> Result<Value, MappingError> {
|
||||
let boolean = match value {
|
||||
Value::Bool(boolean) => boolean,
|
||||
Value::Number(number) => {
|
||||
number.as_i64().unwrap_or_default() != 0 || number.as_f64().unwrap_or_default() != 0.0
|
||||
}
|
||||
Value::String(text) => match text.to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "yes" => true,
|
||||
"false" | "0" | "no" => false,
|
||||
_ => {
|
||||
return Err(MappingError::InvalidTransformInput {
|
||||
transform: "to_boolean",
|
||||
actual: "string",
|
||||
});
|
||||
}
|
||||
},
|
||||
other => {
|
||||
return Err(MappingError::InvalidTransformInput {
|
||||
transform: "to_boolean",
|
||||
actual: type_name(&other),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Value::Bool(boolean))
|
||||
}
|
||||
|
||||
fn join_values(value: Value) -> Result<Value, MappingError> {
|
||||
let Value::Array(values) = value else {
|
||||
return Err(MappingError::InvalidTransformInput {
|
||||
transform: "join",
|
||||
actual: type_name(&value),
|
||||
});
|
||||
};
|
||||
|
||||
Ok(Value::String(
|
||||
values
|
||||
.iter()
|
||||
.map(to_string_value)
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
))
|
||||
}
|
||||
|
||||
fn split_value(value: Value) -> Result<Value, MappingError> {
|
||||
let Value::String(text) = value else {
|
||||
return Err(MappingError::InvalidTransformInput {
|
||||
transform: "split",
|
||||
actual: type_name(&value),
|
||||
});
|
||||
};
|
||||
|
||||
Ok(Value::Array(
|
||||
text.split(',')
|
||||
.map(|part| Value::String(part.trim().to_owned()))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn unwrap_singleton(value: Value) -> Result<Value, MappingError> {
|
||||
match value {
|
||||
Value::Array(mut values) if values.len() == 1 => Ok(values.remove(0)),
|
||||
Value::Array(_) => Err(MappingError::InvalidTransformInput {
|
||||
transform: "unwrap_singleton",
|
||||
actual: "array",
|
||||
}),
|
||||
other => Err(MappingError::InvalidTransformInput {
|
||||
transform: "unwrap_singleton",
|
||||
actual: type_name(&other),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn type_name(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{MappingRule, MappingSet, Transform, TransformKind};
|
||||
|
||||
#[test]
|
||||
fn applies_input_mapping_into_request_context() {
|
||||
let mapping = MappingSet {
|
||||
rules: vec![
|
||||
MappingRule {
|
||||
source: "$.mcp.email".to_owned(),
|
||||
target: "$.request.body.contact.email".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
},
|
||||
MappingRule {
|
||||
source: "$.mcp.tags".to_owned(),
|
||||
target: "$.request.query.tags".to_owned(),
|
||||
required: false,
|
||||
default_value: None,
|
||||
transform: Some(Transform {
|
||||
kind: TransformKind::Join,
|
||||
}),
|
||||
condition: None,
|
||||
notes: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let result = mapping
|
||||
.apply(&json!({
|
||||
"mcp": {
|
||||
"email": "user@example.com",
|
||||
"tags": ["warm", "priority"]
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["request"]["body"]["contact"]["email"],
|
||||
"user@example.com"
|
||||
);
|
||||
assert_eq!(result["request"]["query"]["tags"], "warm,priority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_output_mapping_into_output_context() {
|
||||
let mapping = MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.response.body.lead_id".to_owned(),
|
||||
target: "$.output.id".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: Some(Transform {
|
||||
kind: TransformKind::ToString,
|
||||
}),
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let result = mapping
|
||||
.apply(&json!({
|
||||
"response": {
|
||||
"body": {
|
||||
"lead_id": 42
|
||||
}
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result["output"]["id"], "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_default_value_for_missing_optional_source() {
|
||||
let mapping = MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.mcp.region".to_owned(),
|
||||
target: "$.request.headers.X-Region".to_owned(),
|
||||
required: false,
|
||||
default_value: Some(json!("eu-central")),
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let result = mapping.apply(&json!({ "mcp": {} })).unwrap();
|
||||
|
||||
assert_eq!(result["request"]["headers"]["X-Region"], "eu-central");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{JsonPathRoot, MappingRule, MappingSet};
|
||||
|
||||
pub fn infer_mapping_from_samples(
|
||||
source_sample: &Value,
|
||||
source_root: JsonPathRoot,
|
||||
target_sample: &Value,
|
||||
target_root: JsonPathRoot,
|
||||
) -> MappingSet {
|
||||
let source_paths = collect_leaf_paths(source_sample, source_root);
|
||||
let target_paths = collect_leaf_paths(target_sample, target_root);
|
||||
let mut rules = Vec::new();
|
||||
|
||||
for (leaf_name, source_path) in source_paths {
|
||||
let Some(target_path) = target_paths.get(&leaf_name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
rules.push(MappingRule {
|
||||
source: source_path,
|
||||
target: target_path.clone(),
|
||||
required: false,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
});
|
||||
}
|
||||
|
||||
MappingSet { rules }
|
||||
}
|
||||
|
||||
fn collect_leaf_paths(sample: &Value, root: JsonPathRoot) -> BTreeMap<String, String> {
|
||||
let mut collected = BTreeMap::new();
|
||||
let mut segments = Vec::new();
|
||||
|
||||
collect_leaf_paths_recursive(sample, &mut segments, &mut collected);
|
||||
|
||||
collected
|
||||
.into_iter()
|
||||
.map(|(leaf, path_segments)| {
|
||||
let mut path = format!("$.{}", root.as_str());
|
||||
|
||||
for segment in path_segments {
|
||||
path.push('.');
|
||||
path.push_str(&segment);
|
||||
}
|
||||
|
||||
(leaf, path)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_leaf_paths_recursive(
|
||||
sample: &Value,
|
||||
segments: &mut Vec<String>,
|
||||
collected: &mut BTreeMap<String, Vec<String>>,
|
||||
) {
|
||||
match sample {
|
||||
Value::Object(object) => {
|
||||
for (field, value) in object {
|
||||
segments.push(field.clone());
|
||||
collect_leaf_paths_recursive(value, segments, collected);
|
||||
segments.pop();
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
if let Some(first) = values.first() {
|
||||
collect_leaf_paths_recursive(first, segments, collected);
|
||||
}
|
||||
}
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
|
||||
if let Some(leaf) = segments.last() {
|
||||
collected
|
||||
.entry(leaf.clone())
|
||||
.or_insert_with(|| segments.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{JsonPathRoot, infer_mapping_from_samples};
|
||||
|
||||
#[test]
|
||||
fn infers_rules_from_matching_leaf_names() {
|
||||
let mapping = infer_mapping_from_samples(
|
||||
&json!({
|
||||
"lead": {
|
||||
"email": "user@example.com",
|
||||
"name": "Ada"
|
||||
}
|
||||
}),
|
||||
JsonPathRoot::Mcp,
|
||||
&json!({
|
||||
"contact": {
|
||||
"email": "user@example.com"
|
||||
},
|
||||
"name": "Ada"
|
||||
}),
|
||||
JsonPathRoot::RequestBody,
|
||||
);
|
||||
|
||||
assert_eq!(mapping.rules.len(), 2);
|
||||
assert_eq!(mapping.rules[0].source, "$.mcp.lead.email");
|
||||
assert_eq!(mapping.rules[0].target, "$.request.body.contact.email");
|
||||
assert_eq!(mapping.rules[1].source, "$.mcp.lead.name");
|
||||
assert_eq!(mapping.rules[1].target, "$.request.body.name");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use crate::MappingError;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum JsonPathRoot {
|
||||
Mcp,
|
||||
RequestPath,
|
||||
RequestQuery,
|
||||
RequestHeaders,
|
||||
RequestBody,
|
||||
RequestVariables,
|
||||
RequestGrpc,
|
||||
ResponseBody,
|
||||
ResponseData,
|
||||
ResponseGrpc,
|
||||
Output,
|
||||
}
|
||||
|
||||
impl JsonPathRoot {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Mcp => "mcp",
|
||||
Self::RequestPath => "request.path",
|
||||
Self::RequestQuery => "request.query",
|
||||
Self::RequestHeaders => "request.headers",
|
||||
Self::RequestBody => "request.body",
|
||||
Self::RequestVariables => "request.variables",
|
||||
Self::RequestGrpc => "request.grpc",
|
||||
Self::ResponseBody => "response.body",
|
||||
Self::ResponseData => "response.data",
|
||||
Self::ResponseGrpc => "response.grpc",
|
||||
Self::Output => "output",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root_fields(&self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Mcp => &["mcp"],
|
||||
Self::RequestPath => &["request", "path"],
|
||||
Self::RequestQuery => &["request", "query"],
|
||||
Self::RequestHeaders => &["request", "headers"],
|
||||
Self::RequestBody => &["request", "body"],
|
||||
Self::RequestVariables => &["request", "variables"],
|
||||
Self::RequestGrpc => &["request", "grpc"],
|
||||
Self::ResponseBody => &["response", "body"],
|
||||
Self::ResponseData => &["response", "data"],
|
||||
Self::ResponseGrpc => &["response", "grpc"],
|
||||
Self::Output => &["output"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum JsonPathSegment {
|
||||
Field(String),
|
||||
Index(usize),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct JsonPath {
|
||||
pub root: JsonPathRoot,
|
||||
pub segments: Vec<JsonPathSegment>,
|
||||
}
|
||||
|
||||
impl JsonPath {
|
||||
pub fn parse(path: &str) -> Result<Self, MappingError> {
|
||||
let body = path
|
||||
.strip_prefix("$.")
|
||||
.ok_or_else(|| MappingError::InvalidJsonPath {
|
||||
path: path.to_owned(),
|
||||
})?;
|
||||
|
||||
let (root, rest) = match_root(body).ok_or_else(|| MappingError::InvalidJsonPath {
|
||||
path: path.to_owned(),
|
||||
})?;
|
||||
|
||||
if !(rest.is_empty() || rest.starts_with('.') || rest.starts_with('[')) {
|
||||
return Err(MappingError::InvalidJsonPath {
|
||||
path: path.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let segments = parse_segments(rest, path)?;
|
||||
|
||||
Ok(Self { root, segments })
|
||||
}
|
||||
|
||||
pub fn to_string_path(&self) -> String {
|
||||
let mut path = format!("$.{}", self.root.as_str());
|
||||
|
||||
for segment in &self.segments {
|
||||
match segment {
|
||||
JsonPathSegment::Field(field) => {
|
||||
path.push('.');
|
||||
path.push_str(field);
|
||||
}
|
||||
JsonPathSegment::Index(index) => {
|
||||
path.push('[');
|
||||
path.push_str(&index.to_string());
|
||||
path.push(']');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
fn match_root(input: &str) -> Option<(JsonPathRoot, &str)> {
|
||||
const ROOTS: [(&str, JsonPathRoot); 11] = [
|
||||
("request.variables", JsonPathRoot::RequestVariables),
|
||||
("request.headers", JsonPathRoot::RequestHeaders),
|
||||
("response.body", JsonPathRoot::ResponseBody),
|
||||
("response.data", JsonPathRoot::ResponseData),
|
||||
("response.grpc", JsonPathRoot::ResponseGrpc),
|
||||
("request.path", JsonPathRoot::RequestPath),
|
||||
("request.query", JsonPathRoot::RequestQuery),
|
||||
("request.body", JsonPathRoot::RequestBody),
|
||||
("request.grpc", JsonPathRoot::RequestGrpc),
|
||||
("output", JsonPathRoot::Output),
|
||||
("mcp", JsonPathRoot::Mcp),
|
||||
];
|
||||
|
||||
for (prefix, root) in ROOTS {
|
||||
if let Some(rest) = input.strip_prefix(prefix) {
|
||||
return Some((root, rest));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_segments(mut input: &str, original: &str) -> Result<Vec<JsonPathSegment>, MappingError> {
|
||||
let mut segments = Vec::new();
|
||||
|
||||
while !input.is_empty() {
|
||||
if let Some(rest) = input.strip_prefix('.') {
|
||||
let end = rest.find(['.', '[']).unwrap_or(rest.len());
|
||||
let field = &rest[..end];
|
||||
|
||||
if field.is_empty() || !is_valid_field(field) {
|
||||
return Err(MappingError::InvalidJsonPath {
|
||||
path: original.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
segments.push(JsonPathSegment::Field(field.to_owned()));
|
||||
input = &rest[end..];
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = input.strip_prefix('[') {
|
||||
let Some(end) = rest.find(']') else {
|
||||
return Err(MappingError::InvalidJsonPath {
|
||||
path: original.to_owned(),
|
||||
});
|
||||
};
|
||||
|
||||
let index =
|
||||
rest[..end]
|
||||
.parse::<usize>()
|
||||
.map_err(|_| MappingError::InvalidJsonPath {
|
||||
path: original.to_owned(),
|
||||
})?;
|
||||
|
||||
segments.push(JsonPathSegment::Index(index));
|
||||
input = &rest[end + 1..];
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(MappingError::InvalidJsonPath {
|
||||
path: original.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
fn is_valid_field(value: &str) -> bool {
|
||||
value
|
||||
.chars()
|
||||
.all(|char| char.is_ascii_alphanumeric() || char == '_' || char == '-')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{JsonPath, JsonPathRoot, JsonPathSegment};
|
||||
|
||||
#[test]
|
||||
fn parses_nested_jsonpath_with_array_index() {
|
||||
let path = JsonPath::parse("$.request.body.contacts[0].email").unwrap();
|
||||
|
||||
assert_eq!(path.root, JsonPathRoot::RequestBody);
|
||||
assert_eq!(
|
||||
path.segments,
|
||||
vec![
|
||||
JsonPathSegment::Field("contacts".to_owned()),
|
||||
JsonPathSegment::Index(0),
|
||||
JsonPathSegment::Field("email".to_owned())
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_root() {
|
||||
let error = JsonPath::parse("$.unknown.field").unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
crate::MappingError::InvalidJsonPath {
|
||||
path: "$.unknown.field".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,129 +1,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
mod error;
|
||||
mod execute;
|
||||
mod infer;
|
||||
mod jsonpath;
|
||||
mod model;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MappingCondition {
|
||||
pub source: String,
|
||||
pub equals: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransformKind {
|
||||
Identity,
|
||||
ToString,
|
||||
ToNumber,
|
||||
ToBoolean,
|
||||
Join,
|
||||
Split,
|
||||
WrapArray,
|
||||
UnwrapSingleton,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Transform {
|
||||
pub kind: TransformKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MappingRule {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_value: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub transform: Option<Transform>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub condition: Option<MappingCondition>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct MappingSet {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rules: Vec<MappingRule>,
|
||||
}
|
||||
|
||||
impl MappingSet {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.rules.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{MappingRule, MappingSet, Transform, TransformKind};
|
||||
|
||||
#[test]
|
||||
fn mapping_set_reports_non_empty_rules() {
|
||||
let mapping = MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.mcp.email".to_owned(),
|
||||
target: "$.request.body.contact.email".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: Some(Transform {
|
||||
kind: TransformKind::Identity,
|
||||
}),
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
};
|
||||
|
||||
assert!(!mapping.is_empty());
|
||||
assert_eq!(mapping.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_rule_serializes_jsonpath_fields() {
|
||||
let mapping = MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.response.body.id".to_owned(),
|
||||
target: "$.output.id".to_owned(),
|
||||
required: false,
|
||||
default_value: Some(json!("lead_123")),
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: Some("map identifier".to_owned()),
|
||||
}],
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(mapping).unwrap();
|
||||
|
||||
assert_eq!(value["rules"][0]["source"], "$.response.body.id");
|
||||
assert_eq!(value["rules"][0]["target"], "$.output.id");
|
||||
assert_eq!(value["rules"][0]["default_value"], "lead_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_set_roundtrips_through_yaml() {
|
||||
let mapping = MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.mcp.tags".to_owned(),
|
||||
target: "$.request.body.tags".to_owned(),
|
||||
required: false,
|
||||
default_value: None,
|
||||
transform: Some(Transform {
|
||||
kind: TransformKind::WrapArray,
|
||||
}),
|
||||
condition: None,
|
||||
notes: Some("normalize tags".to_owned()),
|
||||
}],
|
||||
};
|
||||
|
||||
let yaml = serde_yaml::to_string(&mapping).unwrap();
|
||||
let restored: MappingSet = serde_yaml::from_str(&yaml).unwrap();
|
||||
|
||||
assert!(yaml.contains("source: $.mcp.tags"));
|
||||
assert_eq!(restored, mapping);
|
||||
}
|
||||
}
|
||||
pub use error::MappingError;
|
||||
pub use infer::infer_mapping_from_samples;
|
||||
pub use jsonpath::{JsonPath, JsonPathRoot, JsonPathSegment};
|
||||
pub use model::{
|
||||
MappingCondition, MappingRule, MappingSet, MappingTargetContext, Transform, TransformKind,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{JsonPath, JsonPathRoot, MappingError};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MappingTargetContext {
|
||||
Unknown,
|
||||
Input,
|
||||
Output,
|
||||
Mixed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MappingCondition {
|
||||
pub source: String,
|
||||
pub equals: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransformKind {
|
||||
Identity,
|
||||
ToString,
|
||||
ToNumber,
|
||||
ToBoolean,
|
||||
Join,
|
||||
Split,
|
||||
WrapArray,
|
||||
UnwrapSingleton,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Transform {
|
||||
pub kind: TransformKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MappingRule {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_value: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub transform: Option<Transform>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub condition: Option<MappingCondition>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct MappingSet {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rules: Vec<MappingRule>,
|
||||
}
|
||||
|
||||
impl MappingSet {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.rules.len()
|
||||
}
|
||||
|
||||
pub fn target_context(&self) -> MappingTargetContext {
|
||||
let mut detected = MappingTargetContext::Unknown;
|
||||
|
||||
for rule in &self.rules {
|
||||
let Ok(path) = JsonPath::parse(&rule.target) else {
|
||||
return MappingTargetContext::Mixed;
|
||||
};
|
||||
|
||||
let current = match target_root_context(path.root) {
|
||||
Some(context) => context,
|
||||
None => return MappingTargetContext::Mixed,
|
||||
};
|
||||
|
||||
detected = match (detected, current) {
|
||||
(MappingTargetContext::Unknown, context) => context,
|
||||
(existing, current) if existing == current => existing,
|
||||
_ => MappingTargetContext::Mixed,
|
||||
};
|
||||
}
|
||||
|
||||
detected
|
||||
}
|
||||
|
||||
pub fn validate_paths(&self) -> Result<(), MappingError> {
|
||||
let context = self.target_context();
|
||||
|
||||
if context == MappingTargetContext::Mixed {
|
||||
return Err(MappingError::MixedTargetContext);
|
||||
}
|
||||
|
||||
for rule in &self.rules {
|
||||
let source = JsonPath::parse(&rule.source)?;
|
||||
let target = JsonPath::parse(&rule.target)?;
|
||||
|
||||
validate_source_root(source.root, context)?;
|
||||
validate_target_root(target.root, context)?;
|
||||
|
||||
if let Some(condition) = &rule.condition {
|
||||
let condition_path = JsonPath::parse(&condition.source)?;
|
||||
validate_source_root(condition_path.root, context)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn target_root_context(root: JsonPathRoot) -> Option<MappingTargetContext> {
|
||||
match root {
|
||||
JsonPathRoot::RequestPath
|
||||
| JsonPathRoot::RequestQuery
|
||||
| JsonPathRoot::RequestHeaders
|
||||
| JsonPathRoot::RequestBody
|
||||
| JsonPathRoot::RequestVariables
|
||||
| JsonPathRoot::RequestGrpc => Some(MappingTargetContext::Input),
|
||||
JsonPathRoot::Output => Some(MappingTargetContext::Output),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_source_root(
|
||||
root: JsonPathRoot,
|
||||
context: MappingTargetContext,
|
||||
) -> Result<(), MappingError> {
|
||||
let valid = match context {
|
||||
MappingTargetContext::Unknown => true,
|
||||
MappingTargetContext::Input => root == JsonPathRoot::Mcp,
|
||||
MappingTargetContext::Output => {
|
||||
matches!(
|
||||
root,
|
||||
JsonPathRoot::ResponseBody
|
||||
| JsonPathRoot::ResponseData
|
||||
| JsonPathRoot::ResponseGrpc
|
||||
)
|
||||
}
|
||||
MappingTargetContext::Mixed => false,
|
||||
};
|
||||
|
||||
if valid {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(MappingError::UnsupportedSourceRoot {
|
||||
root: root.as_str().to_owned(),
|
||||
context: context_name(context),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_target_root(
|
||||
root: JsonPathRoot,
|
||||
context: MappingTargetContext,
|
||||
) -> Result<(), MappingError> {
|
||||
let valid = match context {
|
||||
MappingTargetContext::Unknown => true,
|
||||
MappingTargetContext::Input => matches!(
|
||||
root,
|
||||
JsonPathRoot::RequestPath
|
||||
| JsonPathRoot::RequestQuery
|
||||
| JsonPathRoot::RequestHeaders
|
||||
| JsonPathRoot::RequestBody
|
||||
| JsonPathRoot::RequestVariables
|
||||
| JsonPathRoot::RequestGrpc
|
||||
),
|
||||
MappingTargetContext::Output => root == JsonPathRoot::Output,
|
||||
MappingTargetContext::Mixed => false,
|
||||
};
|
||||
|
||||
if valid {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(MappingError::UnsupportedTargetRoot {
|
||||
root: root.as_str().to_owned(),
|
||||
context: context_name(context),
|
||||
})
|
||||
}
|
||||
|
||||
fn context_name(context: MappingTargetContext) -> &'static str {
|
||||
match context {
|
||||
MappingTargetContext::Unknown => "unknown",
|
||||
MappingTargetContext::Input => "input",
|
||||
MappingTargetContext::Output => "output",
|
||||
MappingTargetContext::Mixed => "mixed",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{MappingRule, MappingSet, MappingTargetContext, Transform, TransformKind};
|
||||
|
||||
#[test]
|
||||
fn mapping_set_reports_non_empty_rules() {
|
||||
let mapping = MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.mcp.email".to_owned(),
|
||||
target: "$.request.body.contact.email".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: Some(Transform {
|
||||
kind: TransformKind::Identity,
|
||||
}),
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
};
|
||||
|
||||
assert!(!mapping.is_empty());
|
||||
assert_eq!(mapping.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_rule_serializes_jsonpath_fields() {
|
||||
let mapping = MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.response.body.id".to_owned(),
|
||||
target: "$.output.id".to_owned(),
|
||||
required: false,
|
||||
default_value: Some(json!("lead_123")),
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: Some("map identifier".to_owned()),
|
||||
}],
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(mapping).unwrap();
|
||||
|
||||
assert_eq!(value["rules"][0]["source"], "$.response.body.id");
|
||||
assert_eq!(value["rules"][0]["target"], "$.output.id");
|
||||
assert_eq!(value["rules"][0]["default_value"], "lead_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_set_roundtrips_through_yaml() {
|
||||
let mapping = MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.mcp.tags".to_owned(),
|
||||
target: "$.request.body.tags".to_owned(),
|
||||
required: false,
|
||||
default_value: None,
|
||||
transform: Some(Transform {
|
||||
kind: TransformKind::WrapArray,
|
||||
}),
|
||||
condition: None,
|
||||
notes: Some("normalize tags".to_owned()),
|
||||
}],
|
||||
};
|
||||
|
||||
let yaml = serde_yaml::to_string(&mapping).unwrap();
|
||||
let restored: MappingSet = serde_yaml::from_str(&yaml).unwrap();
|
||||
|
||||
assert!(yaml.contains("source: $.mcp.tags"));
|
||||
assert_eq!(restored, mapping);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_input_context_from_target_roots() {
|
||||
let mapping = MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.mcp.email".to_owned(),
|
||||
target: "$.request.body.email".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
};
|
||||
|
||||
assert_eq!(mapping.target_context(), MappingTargetContext::Input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_mixed_target_contexts() {
|
||||
let mapping = MappingSet {
|
||||
rules: vec![
|
||||
MappingRule {
|
||||
source: "$.mcp.email".to_owned(),
|
||||
target: "$.request.body.email".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
},
|
||||
MappingRule {
|
||||
source: "$.response.body.id".to_owned(),
|
||||
target: "$.output.id".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let error = mapping.validate_paths().unwrap_err();
|
||||
|
||||
assert_eq!(error, crate::MappingError::MixedTargetContext);
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,8 @@ mcpaas-mapping = { path = "../mcpaas-mapping" }
|
||||
mcpaas-schema = { path = "../mcpaas-schema" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RegistryError {
|
||||
#[error(transparent)]
|
||||
Storage(#[from] sqlx::Error),
|
||||
#[error(transparent)]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
#[error("operation {operation_id} already exists")]
|
||||
OperationAlreadyExists { operation_id: String },
|
||||
#[error("operation {operation_id} was not found")]
|
||||
OperationNotFound { operation_id: String },
|
||||
#[error("operation version {version} for {operation_id} was not found")]
|
||||
OperationVersionNotFound { operation_id: String, version: u32 },
|
||||
#[error("operation {operation_id} must start with version 1, got {version}")]
|
||||
InvalidInitialVersion { operation_id: String, version: u32 },
|
||||
#[error("operation {operation_id} expected next version {expected}, got {actual}")]
|
||||
InvalidVersionSequence {
|
||||
operation_id: String,
|
||||
expected: u32,
|
||||
actual: u32,
|
||||
},
|
||||
#[error("operation {operation_id} changed immutable field {field}")]
|
||||
ImmutableOperationFieldChanged {
|
||||
operation_id: String,
|
||||
field: &'static str,
|
||||
},
|
||||
#[error("auth profile {auth_profile_id} was not found")]
|
||||
AuthProfileNotFound { auth_profile_id: String },
|
||||
#[error("yaml import job {job_id} was not found")]
|
||||
YamlImportJobNotFound { job_id: String },
|
||||
#[error("unsupported enum representation for field {field}")]
|
||||
InvalidEnumRepresentation { field: &'static str },
|
||||
#[error("invalid numeric value for field {field}: {value}")]
|
||||
InvalidNumericValue { field: &'static str, value: i64 },
|
||||
}
|
||||
@@ -1,3 +1,14 @@
|
||||
pub fn crate_name() -> &'static str {
|
||||
"mcpaas-registry"
|
||||
}
|
||||
mod error;
|
||||
mod migrations;
|
||||
mod model;
|
||||
mod postgres;
|
||||
|
||||
pub use error::RegistryError;
|
||||
pub use model::{
|
||||
CreateVersionRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata,
|
||||
OperationSampleMetadata, OperationSummary, OperationVersionRecord, PublishRequest,
|
||||
RegistryOperation, SampleKind, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
||||
YamlImportJobStatus,
|
||||
};
|
||||
pub use postgres::PostgresRegistry;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
use sqlx::{PgPool, query};
|
||||
|
||||
pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
query(
|
||||
"create table if not exists operations (
|
||||
id text primary key,
|
||||
name text not null unique,
|
||||
display_name text not null,
|
||||
protocol text not null,
|
||||
status text not null,
|
||||
current_draft_version integer not null default 1,
|
||||
latest_published_version integer null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null,
|
||||
published_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists operation_versions (
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
version integer not null,
|
||||
status text not null,
|
||||
target_json jsonb not null,
|
||||
input_schema_json jsonb not null,
|
||||
output_schema_json jsonb not null,
|
||||
input_mapping_json jsonb not null,
|
||||
output_mapping_json jsonb not null,
|
||||
execution_config_json jsonb not null,
|
||||
tool_description_json jsonb not null,
|
||||
samples_json jsonb null,
|
||||
generated_draft_json jsonb null,
|
||||
config_export_json jsonb null,
|
||||
change_note text null,
|
||||
created_at timestamptz not null,
|
||||
created_by text null,
|
||||
primary key (operation_id, version)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists published_operations (
|
||||
operation_id text primary key references operations(id) on delete cascade,
|
||||
version integer not null,
|
||||
published_at timestamptz not null,
|
||||
published_by text null,
|
||||
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists operation_samples (
|
||||
id text primary key,
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
version integer not null,
|
||||
sample_kind text not null,
|
||||
storage_ref text not null,
|
||||
content_type text not null,
|
||||
file_name text null,
|
||||
created_at timestamptz not null,
|
||||
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists descriptors (
|
||||
id text primary key,
|
||||
operation_id text null references operations(id) on delete cascade,
|
||||
version integer null,
|
||||
descriptor_kind text not null,
|
||||
storage_ref text not null,
|
||||
source_name text null,
|
||||
package_index_json jsonb null,
|
||||
created_at timestamptz not null,
|
||||
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists auth_profiles (
|
||||
id text primary key,
|
||||
name text not null unique,
|
||||
kind text not null,
|
||||
config_json jsonb not null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists yaml_import_jobs (
|
||||
id text primary key,
|
||||
source_sample_id text null references operation_samples(id) on delete set null,
|
||||
status text not null,
|
||||
format_version text not null,
|
||||
mode text not null,
|
||||
result_operation_id text null references operations(id) on delete set null,
|
||||
result_version integer null,
|
||||
error_text text null,
|
||||
created_at timestamptz not null,
|
||||
finished_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use mcpaas_core::{
|
||||
AuthProfile, DescriptorId, ExportMode, Operation, OperationId, OperationStatus, Protocol,
|
||||
SampleId,
|
||||
};
|
||||
use mcpaas_mapping::MappingSet;
|
||||
use mcpaas_schema::Schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
macro_rules! define_registry_id {
|
||||
($name:ident) => {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct $name(String);
|
||||
|
||||
impl $name {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for $name {
|
||||
fn from(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for $name {
|
||||
fn from(value: &str) -> Self {
|
||||
Self(value.to_owned())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_registry_id!(YamlImportJobId);
|
||||
|
||||
pub type RegistryOperation = Operation<Schema, MappingSet>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OperationSummary {
|
||||
pub id: OperationId,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub protocol: Protocol,
|
||||
pub status: OperationStatus,
|
||||
pub current_draft_version: u32,
|
||||
pub latest_published_version: Option<u32>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub published_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OperationVersionRecord {
|
||||
pub operation_id: OperationId,
|
||||
pub version: u32,
|
||||
pub status: OperationStatus,
|
||||
pub change_note: Option<String>,
|
||||
pub created_at: String,
|
||||
pub created_by: Option<String>,
|
||||
pub snapshot: RegistryOperation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SampleKind {
|
||||
InputJson,
|
||||
OutputJson,
|
||||
YamlImportSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OperationSampleMetadata {
|
||||
pub id: SampleId,
|
||||
pub operation_id: OperationId,
|
||||
pub version: u32,
|
||||
pub sample_kind: SampleKind,
|
||||
pub storage_ref: String,
|
||||
pub content_type: String,
|
||||
pub file_name: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DescriptorKind {
|
||||
ProtoUpload,
|
||||
DescriptorSet,
|
||||
ReflectionSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DescriptorMetadata {
|
||||
pub id: DescriptorId,
|
||||
pub operation_id: Option<OperationId>,
|
||||
pub version: Option<u32>,
|
||||
pub descriptor_kind: DescriptorKind,
|
||||
pub storage_ref: String,
|
||||
pub source_name: Option<String>,
|
||||
pub package_index: Option<Value>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum YamlImportJobStatus {
|
||||
Pending,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct YamlImportJob {
|
||||
pub id: YamlImportJobId,
|
||||
pub source_sample_id: Option<SampleId>,
|
||||
pub status: YamlImportJobStatus,
|
||||
pub format_version: String,
|
||||
pub mode: ExportMode,
|
||||
pub result_operation_id: Option<OperationId>,
|
||||
pub result_version: Option<u32>,
|
||||
pub error_text: Option<String>,
|
||||
pub created_at: String,
|
||||
pub finished_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct YamlImportJobCompletion {
|
||||
pub status: YamlImportJobStatus,
|
||||
pub result_operation_id: Option<OperationId>,
|
||||
pub result_version: Option<u32>,
|
||||
pub error_text: Option<String>,
|
||||
pub finished_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CreateVersionRequest<'a> {
|
||||
pub snapshot: &'a RegistryOperation,
|
||||
pub change_note: Option<&'a str>,
|
||||
pub created_by: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PublishRequest<'a> {
|
||||
pub operation_id: &'a OperationId,
|
||||
pub version: u32,
|
||||
pub published_at: &'a str,
|
||||
pub published_by: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CreateYamlImportJobRequest<'a> {
|
||||
pub id: &'a YamlImportJobId,
|
||||
pub source_sample_id: Option<&'a SampleId>,
|
||||
pub format_version: &'a str,
|
||||
pub mode: ExportMode,
|
||||
pub created_at: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SaveAuthProfileRequest<'a> {
|
||||
pub profile: &'a AuthProfile,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SaveSampleMetadataRequest<'a> {
|
||||
pub sample: &'a OperationSampleMetadata,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SaveDescriptorMetadataRequest<'a> {
|
||||
pub descriptor: &'a DescriptorMetadata,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-rmcp}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rmcp} -d ${POSTGRES_DB:-rmcp}"]
|
||||
interval: 10s
|
||||
|
||||
@@ -268,6 +268,12 @@ gRPC target:
|
||||
|
||||
Черновой mapping может генерироваться автоматически на основе загруженных примеров данных, но итоговая конфигурация всегда остается явной и редактируемой оператором.
|
||||
|
||||
Для MVP допустима простая стратегия draft generation:
|
||||
|
||||
- искать уникальные leaf-поля с одинаковыми именами;
|
||||
- предлагать только однозначные соответствия;
|
||||
- не пытаться автоматически разрешать конфликты и неоднозначности.
|
||||
|
||||
Каноническая логическая модель остается общей для runtime и БД, но система должна уметь сериализовать и десериализовать ее также в `YAML`.
|
||||
|
||||
## 9. Основные компоненты
|
||||
@@ -428,7 +434,7 @@ mcpaas/
|
||||
- `tokio` как async runtime
|
||||
- `axum` для HTTP API
|
||||
- `serde` и `serde_json`
|
||||
- `sqlx` для PostgreSQL или SQLite
|
||||
- `sqlx` для PostgreSQL
|
||||
- `reqwest` для REST и GraphQL транспорта
|
||||
- `tonic` и `prost` для работы с gRPC
|
||||
- `tower` для middleware
|
||||
|
||||
@@ -383,6 +383,13 @@
|
||||
- `$.response.grpc.*`
|
||||
- `$.output.*`
|
||||
|
||||
Для MVP достаточно управляемого подмножества `JSONPath`:
|
||||
|
||||
- путь всегда начинается с фиксированного root context;
|
||||
- дальше используются dot-separated поля;
|
||||
- для массивов допускаются numeric indexes вида `[0]`;
|
||||
- quoted selectors, filter expressions и произвольные функции не поддерживаются.
|
||||
|
||||
### `Transform`
|
||||
|
||||
Для MVP transformations должны быть ограничены:
|
||||
@@ -404,6 +411,14 @@
|
||||
}
|
||||
```
|
||||
|
||||
### Черновая генерация mapping
|
||||
|
||||
Для MVP generation draft mapping может опираться на простое правило:
|
||||
|
||||
- система сопоставляет уникальные leaf-поля с одинаковыми именами в source sample и target sample;
|
||||
- неоднозначные совпадения автоматически не связываются;
|
||||
- результат всегда остается черновиком и требует ручной проверки оператором.
|
||||
|
||||
## 7. `ExecutionConfig`
|
||||
|
||||
`ExecutionConfig` задает параметры выполнения operation.
|
||||
|
||||
+11
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
Этот документ фиксирует структуру хранения конфигураций, версий операций, загруженных артефактов и published runtime-view. Его цель - дать основу для SQL-миграций и для реализации `mcpaas-registry`.
|
||||
|
||||
В документе предполагается реляционная модель, ориентированная на `PostgreSQL`. Для MVP допускается адаптация под `SQLite`, но канонической считается схема, совместимая с `PostgreSQL`.
|
||||
В документе предполагается реляционная модель, ориентированная на `PostgreSQL`. Канонической считается схема, совместимая с `PostgreSQL`.
|
||||
|
||||
## 2. Общие принципы хранения
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
Конфигурация operation не должна храниться только в одной "живой" записи. Каждое существенное изменение должно приводить к появлению новой версии конфигурации.
|
||||
|
||||
Для MVP в registry version snapshot хранит protocol-specific конфигурацию, схемы, mapping и execution settings. Поля identity и listing view (`name`, `display_name`, `protocol`) считаются стабильными и хранятся в `operations`.
|
||||
|
||||
### 2.2. Published и draft разделяются логически
|
||||
|
||||
- `draft` может меняться;
|
||||
@@ -28,6 +30,14 @@
|
||||
|
||||
Для MVP рекомендуется отдельная таблица `auth_profiles`, где metadata и secret refs отделены от operation versions.
|
||||
|
||||
### 2.5. Тестовая изоляция
|
||||
|
||||
Integration tests для registry должны выполняться на реальной `PostgreSQL`, но без влияния на runtime-данные. Предпочтительный способ:
|
||||
|
||||
- отдельная test database;
|
||||
- либо отдельная временная schema на время теста;
|
||||
- обязательная очистка после завершения тестов.
|
||||
|
||||
## 3. Основные таблицы
|
||||
|
||||
Минимальный набор таблиц:
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ flowchart LR
|
||||
REST[adapter-rest]
|
||||
GQL[adapter-graphql]
|
||||
GRPC[adapter-grpc]
|
||||
DB[(PostgreSQL/SQLite)]
|
||||
DB[(PostgreSQL)]
|
||||
STORE[(Artifact Storage)]
|
||||
|
||||
UI --> ADMIN
|
||||
|
||||
@@ -311,7 +311,6 @@ mcpaas-registry/
|
||||
get_runtime_view.rs
|
||||
storage/
|
||||
mod.rs
|
||||
sqlite.rs
|
||||
postgres.rs
|
||||
cache/
|
||||
mod.rs
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
## 2. Базовые решения для MVP
|
||||
|
||||
- каноническая БД: `PostgreSQL`
|
||||
- допустимый упрощенный режим разработки: `SQLite`
|
||||
- локальная разработка и тесты используют ту же `PostgreSQL`-модель хранения
|
||||
- artifact storage: локальная файловая система
|
||||
- MCP transport: `Streamable HTTP`
|
||||
- admin API и mcp-server запускаются как отдельные приложения
|
||||
@@ -100,7 +100,7 @@ var/mcpaas/
|
||||
|
||||
Local development:
|
||||
|
||||
- `SQLite` допустим;
|
||||
- локальное окружение должно иметь доступ к `PostgreSQL`;
|
||||
- локальный storage;
|
||||
- упрощенная auth-модель admin-api.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user