use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ ids::{AuthProfileId, DescriptorId, OperationId, SampleId}, protocol::{ExportMode, GraphqlOperationType, HttpMethod, Protocol}, }; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OperationStatus { Draft, Testing, Published, Archived, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RestTarget { pub base_url: String, pub method: HttpMethod, pub path_template: String, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub static_headers: BTreeMap, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct GraphqlTarget { pub endpoint: String, pub operation_type: GraphqlOperationType, pub operation_name: String, pub query_template: String, pub response_path: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct GrpcTarget { pub server_addr: String, pub package: String, pub service: String, pub method: String, pub descriptor_ref: DescriptorId, pub descriptor_set_b64: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Target { Rest(RestTarget), Graphql(GraphqlTarget), Grpc(GrpcTarget), } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RetryPolicy { pub max_attempts: u32, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct GrpcProtocolOptions { pub use_tls: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct ProtocolOptions { #[serde(skip_serializing_if = "Option::is_none")] pub grpc: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ExecutionConfig { pub timeout_ms: u64, #[serde(skip_serializing_if = "Option::is_none")] pub retry_policy: Option, #[serde(skip_serializing_if = "Option::is_none")] pub auth_profile_ref: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub headers: BTreeMap, #[serde(skip_serializing_if = "Option::is_none")] pub protocol_options: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolExample { pub input: Value, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolDescription { pub title: String, pub description: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub examples: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct Samples { #[serde(skip_serializing_if = "Option::is_none")] pub input_json_sample_ref: Option, #[serde(skip_serializing_if = "Option::is_none")] pub output_json_sample_ref: Option, #[serde(skip_serializing_if = "Option::is_none")] pub proto_file_ref: Option, #[serde(skip_serializing_if = "Option::is_none")] pub descriptor_ref: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GeneratedDraftStatus { None, Available, Stale, Failed, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct GeneratedDraft { pub status: GeneratedDraftStatus, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub source_types: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub generated_at: Option, pub input_schema_generated: bool, pub output_schema_generated: bool, pub input_mapping_generated: bool, pub output_mapping_generated: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConfigExport { pub format_version: String, pub export_mode: ExportMode, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Operation { pub id: OperationId, pub name: String, pub display_name: String, pub protocol: Protocol, pub status: OperationStatus, pub version: u32, pub target: Target, pub input_schema: TSchema, pub output_schema: TSchema, pub input_mapping: TMapping, pub output_mapping: TMapping, pub execution_config: ExecutionConfig, pub tool_description: ToolDescription, #[serde(skip_serializing_if = "Option::is_none")] pub samples: Option, #[serde(skip_serializing_if = "Option::is_none")] pub generated_draft: Option, #[serde(skip_serializing_if = "Option::is_none")] pub config_export: Option, pub created_at: String, pub updated_at: String, #[serde(skip_serializing_if = "Option::is_none")] pub published_at: Option, } impl Operation { pub fn tool_name(&self) -> &str { &self.name } pub fn is_draft(&self) -> bool { self.status == OperationStatus::Draft } pub fn is_published(&self) -> bool { self.status == OperationStatus::Published } pub fn protocol(&self) -> Protocol { self.protocol } pub fn auth_profile_ref(&self) -> Option<&AuthProfileId> { self.execution_config.auth_profile_ref.as_ref() } } #[cfg(test)] mod tests { use std::collections::BTreeMap; use serde_json::json; use crate::{ auth::{AuthConfig, AuthProfile, BearerAuthConfig, SecretRef}, ids::{AuthProfileId, OperationId}, operation::{ ConfigExport, ExecutionConfig, GraphqlTarget, Operation, OperationStatus, ProtocolOptions, RestTarget, Samples, Target, ToolDescription, ToolExample, }, protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol}, }; #[test] fn rest_target_serializes_with_kind_tag() { let target = Target::Rest(RestTarget { base_url: "https://api.example.com".to_owned(), method: HttpMethod::Post, path_template: "/v1/leads".to_owned(), static_headers: BTreeMap::new(), }); let value = serde_json::to_value(target).unwrap(); assert_eq!(value["kind"], "rest"); assert_eq!(value["method"], "POST"); } #[test] fn graphql_target_serializes_response_path() { let target = Target::Graphql(GraphqlTarget { endpoint: "https://api.example.com/graphql".to_owned(), operation_type: GraphqlOperationType::Mutation, operation_name: "CreateLead".to_owned(), query_template: "mutation {}".to_owned(), response_path: "$.response.body.data.createLead".to_owned(), }); let value = serde_json::to_value(target).unwrap(); assert_eq!(value["kind"], "graphql"); assert_eq!(value["operation_type"], "mutation"); } #[test] fn operation_exposes_local_domain_helpers() { let operation = Operation { id: OperationId::new("op_01"), name: "crm_create_lead".to_owned(), display_name: "Create Lead".to_owned(), protocol: Protocol::Rest, status: OperationStatus::Draft, version: 1, target: Target::Rest(RestTarget { base_url: "https://api.example.com".to_owned(), method: HttpMethod::Post, path_template: "/v1/leads".to_owned(), static_headers: BTreeMap::new(), }), input_schema: json!({"type":"object"}), output_schema: json!({"type":"object"}), input_mapping: json!({"rules":[]}), output_mapping: json!({"rules":[]}), execution_config: ExecutionConfig { timeout_ms: 10_000, retry_policy: None, auth_profile_ref: Some(AuthProfileId::new("auth_01")), headers: BTreeMap::new(), protocol_options: Some(ProtocolOptions::default()), }, tool_description: ToolDescription { title: "Create CRM lead".to_owned(), description: "Creates a new lead.".to_owned(), tags: Vec::new(), examples: Vec::new(), }, samples: Some(Samples::default()), generated_draft: None, config_export: None, created_at: "2026-03-25T08:00:00Z".to_owned(), updated_at: "2026-03-25T08:00:00Z".to_owned(), published_at: None, }; assert_eq!(operation.tool_name(), "crm_create_lead"); assert!(operation.is_draft()); assert!(!operation.is_published()); assert_eq!(operation.protocol(), Protocol::Rest); assert_eq!( operation.auth_profile_ref().map(|value| value.as_str()), Some("auth_01") ); } #[test] fn auth_profile_serializes_secret_refs_without_secret_values() { let profile = AuthProfile { id: AuthProfileId::new("auth_01"), workspace_id: crate::ids::WorkspaceId::new("ws_01"), name: "crm-prod-bearer".to_owned(), kind: AuthKind::Bearer, config: AuthConfig::Bearer(BearerAuthConfig { header_name: "Authorization".to_owned(), secret_ref: SecretRef::new("secret://auth/crm-prod-token"), }), created_at: "2026-03-25T08:00:00Z".to_owned(), updated_at: "2026-03-25T08:00:00Z".to_owned(), }; let value = serde_json::to_value(profile).unwrap(); assert_eq!(value["kind"], "bearer"); assert_eq!( value["config"]["bearer"]["secret_ref"], "secret://auth/crm-prod-token" ); } #[test] fn operation_roundtrips_through_yaml() { let operation = Operation { id: OperationId::new("op_01"), name: "crm_create_lead".to_owned(), display_name: "Create Lead".to_owned(), protocol: Protocol::Rest, status: OperationStatus::Published, version: 3, target: Target::Rest(RestTarget { base_url: "https://api.example.com".to_owned(), method: HttpMethod::Post, path_template: "/v1/leads".to_owned(), static_headers: BTreeMap::from([("X-App-Source".to_owned(), "crank".to_owned())]), }), input_schema: json!({"type":"object","fields":{"email":{"type":"string","required":true}}}), output_schema: json!({"type":"object","fields":{"id":{"type":"string","required":true}}}), input_mapping: json!({"rules":[{"source":"$.mcp.email","target":"$.request.body.email"}]}), output_mapping: json!({"rules":[{"source":"$.response.body.id","target":"$.output.id"}]}), execution_config: ExecutionConfig { timeout_ms: 10_000, retry_policy: None, auth_profile_ref: Some(AuthProfileId::new("auth_01")), headers: BTreeMap::new(), protocol_options: Some(ProtocolOptions::default()), }, tool_description: ToolDescription { title: "Create CRM lead".to_owned(), description: "Creates a new lead.".to_owned(), tags: vec!["crm".to_owned()], examples: vec![ToolExample { input: json!({"email":"user@example.com"}), }], }, samples: Some(Samples::default()), generated_draft: None, config_export: Some(ConfigExport { format_version: "1".to_owned(), export_mode: ExportMode::Portable, }), created_at: "2026-03-25T08:00:00Z".to_owned(), updated_at: "2026-03-25T08:10:00Z".to_owned(), published_at: Some("2026-03-25T08:15:00Z".to_owned()), }; let yaml = serde_yaml::to_string(&operation).unwrap(); let restored: Operation = serde_yaml::from_str(&yaml).unwrap(); assert!(yaml.contains("protocol: rest")); assert!(yaml.contains("export_mode: portable")); assert_eq!(restored, operation); } }