From a6388e4353e0191c9fe340306cc77fdbe6265f05 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Tue, 7 Apr 2026 00:00:19 +0300 Subject: [PATCH] feat: add soap adapter foundation --- Cargo.lock | 22 +++ Cargo.toml | 2 + TASKS.md | 12 +- apps/admin-api/Cargo.toml | 1 + apps/admin-api/src/app.rs | 202 ++++++++++++++++++++- apps/admin-api/src/error.rs | 1 + apps/admin-api/src/routes/operations.rs | 56 ++++++ apps/admin-api/src/service.rs | 151 ++++++++++++++++ apps/admin-api/src/storage.rs | 2 + apps/mcp-server/src/app.rs | 1 + crates/crank-adapter-soap/Cargo.toml | 18 ++ crates/crank-adapter-soap/src/client.rs | 212 ++++++++++++++++++++++ crates/crank-adapter-soap/src/error.rs | 24 +++ crates/crank-adapter-soap/src/lib.rs | 12 ++ crates/crank-adapter-soap/src/model.rs | 40 +++++ crates/crank-adapter-soap/src/wsdl.rs | 165 +++++++++++++++++ crates/crank-adapter-soap/src/xml.rs | 229 ++++++++++++++++++++++++ crates/crank-registry/src/model.rs | 2 + crates/crank-runtime/Cargo.toml | 1 + crates/crank-runtime/src/error.rs | 3 + crates/crank-runtime/src/executor.rs | 164 ++++++++++++++++- docs/admin-api.md | 9 + docs/database-schema.md | 14 ++ docs/protocols/soap.md | 28 +-- 24 files changed, 1346 insertions(+), 25 deletions(-) create mode 100644 crates/crank-adapter-soap/Cargo.toml create mode 100644 crates/crank-adapter-soap/src/client.rs create mode 100644 crates/crank-adapter-soap/src/error.rs create mode 100644 crates/crank-adapter-soap/src/lib.rs create mode 100644 crates/crank-adapter-soap/src/model.rs create mode 100644 crates/crank-adapter-soap/src/wsdl.rs create mode 100644 crates/crank-adapter-soap/src/xml.rs diff --git a/Cargo.lock b/Cargo.lock index 882a9a6..9b9a369 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,7 @@ dependencies = [ "axum-extra", "base64", "crank-adapter-grpc", + "crank-adapter-soap", "crank-core", "crank-mapping", "crank-proto", @@ -391,6 +392,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "crank-adapter-soap" +version = "0.1.0" +dependencies = [ + "axum", + "crank-core", + "reqwest", + "roxmltree", + "serde", + "serde_json", + "thiserror", + "tokio", +] + [[package]] name = "crank-adapter-websocket" version = "0.1.0" @@ -462,6 +477,7 @@ dependencies = [ "crank-adapter-graphql", "crank-adapter-grpc", "crank-adapter-rest", + "crank-adapter-soap", "crank-adapter-websocket", "crank-core", "crank-mapping", @@ -1939,6 +1955,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc-hash" version = "2.1.1" diff --git a/Cargo.toml b/Cargo.toml index d5a4d81..5ed234e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/crank-adapter-graphql", "crates/crank-adapter-grpc", "crates/crank-adapter-websocket", + "crates/crank-adapter-soap", ] resolver = "3" @@ -33,6 +34,7 @@ prost-types = "0.14" protoc-bin-vendored = "3" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] } +roxmltree = "0.20" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/TASKS.md b/TASKS.md index 344edfa..e098667 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,19 +2,19 @@ ## Current -### `feat/soap-architecture-and-core-model` +### `feat/soap-adapter-foundation` Status: completed DoD: -- SOAP target model is defined in `crank-core` -- WSDL/XSD-derived metadata model is defined in code-level types -- capability matrix exposes SOAP as a first-class protocol -- docs and task state are synchronized +- `crank-adapter-soap` supports unary SOAP request-response execution +- WSDL upload and service/port/operation inspection are available in `admin-api` +- SOAP faults are normalized into runtime errors +- workspace-scoped SOAP test runs work through the same `Operation` lifecycle ## Next -- `feat/soap-adapter-foundation` +- `feat/auth-profile-secret-resolution` ## Backlog diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml index 1cab8b7..1ae9a23 100644 --- a/apps/admin-api/Cargo.toml +++ b/apps/admin-api/Cargo.toml @@ -12,6 +12,7 @@ axum.workspace = true axum-extra.workspace = true base64.workspace = true crank-adapter-grpc = { path = "../../crates/crank-adapter-grpc", features = ["test-support"] } +crank-adapter-soap = { path = "../../crates/crank-adapter-soap" } crank-core = { path = "../../crates/crank-core" } crank-mapping = { path = "../../crates/crank-mapping" } crank-proto = { path = "../../crates/crank-proto" } diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 2a7ea31..1bc8da9 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -24,8 +24,9 @@ use crate::{ operations::{ archive_operation, create_operation, create_version, delete_operation, export_operation, generate_draft, get_operation, get_operation_version, - list_grpc_services, list_operations, publish_operation, run_test, update_operation, - upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_descriptor, + list_grpc_services, list_operations, list_soap_services, publish_operation, run_test, + update_operation, upload_descriptor_set, upload_input_json, upload_output_json, + upload_proto_descriptor, upload_wsdl_descriptor, upload_xsd_descriptor, }, secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret}, streaming::{ @@ -77,10 +78,22 @@ pub fn build_app(state: AppState) -> Router { "/operations/{operation_id}/descriptors/descriptor-set", post(upload_descriptor_set), ) + .route( + "/operations/{operation_id}/descriptors/wsdl", + post(upload_wsdl_descriptor), + ) + .route( + "/operations/{operation_id}/descriptors/xsd", + post(upload_xsd_descriptor), + ) .route( "/operations/{operation_id}/grpc/services", get(list_grpc_services), ) + .route( + "/operations/{operation_id}/soap/services", + get(list_soap_services), + ) .route( "/operations/{operation_id}/drafts/generate", post(generate_draft), @@ -209,7 +222,8 @@ mod tests { use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ DescriptorId, ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, - MembershipRole, Protocol, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId, + MembershipRole, Protocol, RestTarget, SecretKind, SoapBindingStyle, SoapOperationMetadata, + SoapTarget, SoapVersion, Target, ToolDescription, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::PostgresRegistry; @@ -1427,6 +1441,77 @@ mod tests { assert_eq!(test_run["response_preview"]["message"], "hello"); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn uploads_wsdl_and_tests_soap_operation() { + let registry = test_registry().await; + let storage_root = test_storage_root("soap_runtime"); + let endpoint = spawn_soap_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_soap_operation_payload( + &endpoint, + "crm_create_lead_soap", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let uploaded = client + .post(format!( + "{base_url}/operations/{operation_id}/descriptors/wsdl" + )) + .header("x-file-name", "lead.wsdl") + .body(SOAP_TEST_WSDL) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + let services = client + .get(format!( + "{base_url}/operations/{operation_id}/soap/services" + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "email": "user@example.com" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(uploaded["version"], 1); + assert_eq!(services["services"][0]["service_name"], "LeadService"); + assert_eq!(services["services"][0]["ports"][0]["port_name"], "LeadPort"); + assert_eq!( + services["services"][0]["ports"][0]["operations"][0]["operation_name"], + "CreateLead" + ); + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["response_preview"]["id"], "lead_123"); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_auth_profiles_and_yaml_upsert() { @@ -1779,6 +1864,18 @@ mod tests { format!("http://{}", address) } + async fn spawn_soap_server() -> String { + let app = Router::new().route("/", post(soap_handler)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + async fn spawn_admin_api(app: Router) -> TestServer { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -1867,6 +1964,23 @@ mod tests { })) } + async fn soap_handler(body: String) -> (axum::http::StatusCode, String) { + assert!(body.contains("user@example.com")); + + ( + axum::http::StatusCode::OK, + r#" + + + lead_123 + created + + + "# + .to_owned(), + ) + } + async fn test_registry() -> PostgresRegistry { let database_url = env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:5432/crank".to_owned()); @@ -2098,6 +2212,88 @@ mod tests { } } + fn test_soap_operation_payload(endpoint: &str, name: &str) -> OperationPayload { + OperationPayload { + name: name.to_owned(), + display_name: "Create Lead SOAP".to_owned(), + category: "sales".to_owned(), + protocol: Protocol::Soap, + target: Target::Soap(SoapTarget { + wsdl_ref: "sample_wsdl".into(), + service_name: "LeadService".to_owned(), + port_name: "LeadPort".to_owned(), + operation_name: "CreateLead".to_owned(), + endpoint_override: Some(endpoint.to_owned()), + soap_version: SoapVersion::Soap11, + soap_action: Some("urn:createLead".to_owned()), + binding_style: SoapBindingStyle::DocumentLiteral, + headers: Vec::new(), + fault_contract: None, + metadata: SoapOperationMetadata { + input_part_names: vec!["CreateLeadRequest".to_owned()], + output_part_names: vec!["CreateLeadResponse".to_owned()], + namespaces: vec!["urn:crm".to_owned()], + }, + }), + input_schema: object_schema("email"), + output_schema: object_schema("id"), + input_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, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.body.id".to_owned(), + target: "$.output.id".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + execution_config: ExecutionConfig { + timeout_ms: 1_000, + retry_policy: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: ToolDescription { + title: "Create Lead SOAP".to_owned(), + description: "Creates a CRM lead through SOAP".to_owned(), + tags: vec!["crm".to_owned(), "soap".to_owned()], + examples: Vec::new(), + }, + } + } + + const SOAP_TEST_WSDL: &str = r#" + + + + + + + + + + + + +"#; + fn object_schema(field_name: &str) -> Schema { Schema { kind: SchemaKind::Object, diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 28b241b..9a5e5b6 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -238,6 +238,7 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error", RuntimeError::GrpcAdapter(_) => "runtime_grpc_error", RuntimeError::RestAdapter(_) => "runtime_rest_error", + RuntimeError::SoapAdapter(_) => "runtime_soap_error", RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error", RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error", RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error", diff --git a/apps/admin-api/src/routes/operations.rs b/apps/admin-api/src/routes/operations.rs index 6820ffb..68239ab 100644 --- a/apps/admin-api/src/routes/operations.rs +++ b/apps/admin-api/src/routes/operations.rs @@ -269,6 +269,46 @@ pub async fn upload_descriptor_set( Ok(Json(json!(descriptor))) } +pub async fn upload_wsdl_descriptor( + Path(path): Path, + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let source_name = header_file_name(&headers); + let descriptor = state + .service + .upload_wsdl_file( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + source_name.as_deref(), + body.as_ref(), + ) + .await?; + + Ok(Json(json!(descriptor))) +} + +pub async fn upload_xsd_descriptor( + Path(path): Path, + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let source_name = header_file_name(&headers); + let descriptor = state + .service + .upload_xsd_file( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + source_name.as_deref(), + body.as_ref(), + ) + .await?; + + Ok(Json(json!(descriptor))) +} + pub async fn list_grpc_services( Path(path): Path, Query(query): Query, @@ -285,6 +325,22 @@ pub async fn list_grpc_services( Ok(Json(json!({ "services": services }))) } +pub async fn list_soap_services( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result, ApiError> { + let services = state + .service + .list_soap_services( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + query.version, + ) + .await?; + Ok(Json(json!({ "services": services }))) +} + pub async fn generate_draft( Path(path): Path, State(state): State, diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index f0a9eec..517a320 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -6,6 +6,7 @@ use base64::{ engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, }; use crank_adapter_grpc::test_support as grpc_test_support; +use crank_adapter_soap::{SoapServiceSummary, inspect_wsdl}; use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode, AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, ExecutionMode, @@ -563,6 +564,8 @@ pub struct GrpcMethodSummary { pub output_schema: Schema, } +pub type SoapServiceCatalog = Vec; + fn default_operation_category() -> String { "general".to_owned() } @@ -2599,6 +2602,153 @@ impl AdminService { .collect::, _>>() } + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), source_name = source_name.unwrap_or("service.wsdl")))] + pub async fn upload_wsdl_file( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + source_name: Option<&str>, + payload: &[u8], + ) -> Result { + let summary = self.get_operation(workspace_id, operation_id).await?; + if summary.protocol != Protocol::Soap { + return Err(ApiError::validation( + "wsdl upload is only allowed for soap operations", + )); + } + + let services = + inspect_wsdl(payload).map_err(|error| ApiError::validation(error.to_string()))?; + let descriptor_id = crank_core::DescriptorId::new(new_prefixed_id("desc")); + let storage_ref = self + .storage + .write_descriptor( + operation_id, + summary.current_draft_version, + crank_registry::DescriptorKind::WsdlUpload, + &descriptor_id, + source_name, + payload, + ) + .await?; + + self.registry + .save_descriptor_metadata(SaveDescriptorMetadataRequest { + descriptor: &crank_registry::DescriptorMetadata { + id: descriptor_id.clone(), + operation_id: Some(operation_id.clone()), + version: Some(summary.current_draft_version), + descriptor_kind: crank_registry::DescriptorKind::WsdlUpload, + storage_ref, + source_name: source_name.map(ToOwned::to_owned), + package_index: Some( + serde_json::to_value(&services) + .map_err(|error| ApiError::internal(error.to_string()))?, + ), + created_at: now_string()?, + }, + }) + .await?; + info!( + operation_id = %operation_id.as_str(), + descriptor_id = %descriptor_id.as_str(), + version = summary.current_draft_version, + "wsdl uploaded" + ); + + Ok(DescriptorUploadResponse { + descriptor_id: descriptor_id.as_str().to_owned(), + version: summary.current_draft_version, + }) + } + + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), source_name = source_name.unwrap_or("schema.xsd")))] + pub async fn upload_xsd_file( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + source_name: Option<&str>, + payload: &[u8], + ) -> Result { + let summary = self.get_operation(workspace_id, operation_id).await?; + if summary.protocol != Protocol::Soap { + return Err(ApiError::validation( + "xsd upload is only allowed for soap operations", + )); + } + + let descriptor_id = crank_core::DescriptorId::new(new_prefixed_id("desc")); + let storage_ref = self + .storage + .write_descriptor( + operation_id, + summary.current_draft_version, + crank_registry::DescriptorKind::XsdUpload, + &descriptor_id, + source_name, + payload, + ) + .await?; + + self.registry + .save_descriptor_metadata(SaveDescriptorMetadataRequest { + descriptor: &crank_registry::DescriptorMetadata { + id: descriptor_id.clone(), + operation_id: Some(operation_id.clone()), + version: Some(summary.current_draft_version), + descriptor_kind: crank_registry::DescriptorKind::XsdUpload, + storage_ref, + source_name: source_name.map(ToOwned::to_owned), + package_index: None, + created_at: now_string()?, + }, + }) + .await?; + info!( + operation_id = %operation_id.as_str(), + descriptor_id = %descriptor_id.as_str(), + version = summary.current_draft_version, + "xsd uploaded" + ); + + Ok(DescriptorUploadResponse { + descriptor_id: descriptor_id.as_str().to_owned(), + version: summary.current_draft_version, + }) + } + + #[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version))] + pub async fn list_soap_services( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + version: Option, + ) -> Result { + let summary = self.get_operation(workspace_id, operation_id).await?; + if summary.protocol != Protocol::Soap { + return Err(ApiError::validation( + "soap services are only available for soap operations", + )); + } + + let version = version.unwrap_or(summary.current_draft_version); + let descriptor = self + .registry + .list_descriptor_metadata(operation_id, version) + .await? + .into_iter() + .rev() + .find(|descriptor| { + descriptor.descriptor_kind == crank_registry::DescriptorKind::WsdlUpload + }) + .ok_or_else(|| ApiError::not_found("wsdl was not uploaded"))?; + let package_index = descriptor + .package_index + .ok_or_else(|| ApiError::not_found("wsdl inspection metadata is not available"))?; + + serde_json::from_value(package_index).map_err(|error| ApiError::internal(error.to_string())) + } + #[instrument(skip(self))] pub async fn get_auth_profile( &self, @@ -4017,6 +4167,7 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { RuntimeError::GraphqlAdapter(_) => "graphql_error", RuntimeError::GrpcAdapter(_) => "grpc_error", RuntimeError::RestAdapter(_) => "rest_error", + RuntimeError::SoapAdapter(_) => "soap_error", RuntimeError::WebsocketAdapter(_) => "websocket_error", RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error", diff --git a/apps/admin-api/src/storage.rs b/apps/admin-api/src/storage.rs index 9e84a5d..f323f7c 100644 --- a/apps/admin-api/src/storage.rs +++ b/apps/admin-api/src/storage.rs @@ -73,6 +73,8 @@ impl LocalArtifactStorage { DescriptorKind::ReflectionSnapshot => { source_name.unwrap_or("reflection.bin").to_owned() } + DescriptorKind::WsdlUpload => source_name.unwrap_or("service.wsdl").to_owned(), + DescriptorKind::XsdUpload => source_name.unwrap_or("schema.xsd").to_owned(), }; let path = self .root diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 8488684..51346a0 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -1725,6 +1725,7 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { RuntimeError::GraphqlAdapter(_) => "adapter_execution_error", RuntimeError::GrpcAdapter(_) => "adapter_execution_error", RuntimeError::RestAdapter(_) => "adapter_execution_error", + RuntimeError::SoapAdapter(_) => "adapter_execution_error", RuntimeError::WebsocketAdapter(_) => "adapter_execution_error", RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error", diff --git a/crates/crank-adapter-soap/Cargo.toml b/crates/crank-adapter-soap/Cargo.toml new file mode 100644 index 0000000..0e1d376 --- /dev/null +++ b/crates/crank-adapter-soap/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "crank-adapter-soap" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +crank-core = { path = "../crank-core" } +reqwest.workspace = true +roxmltree.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true + +[dev-dependencies] +axum.workspace = true diff --git a/crates/crank-adapter-soap/src/client.rs b/crates/crank-adapter-soap/src/client.rs new file mode 100644 index 0000000..bcf67bc --- /dev/null +++ b/crates/crank-adapter-soap/src/client.rs @@ -0,0 +1,212 @@ +use std::{collections::BTreeMap, time::Duration}; + +use crank_core::{SoapTarget, SoapVersion}; +use reqwest::{ + Client, + header::{HeaderMap, HeaderName, HeaderValue}, +}; + +use crate::{SoapAdapterError, SoapRequest, SoapResponse, xml}; + +#[derive(Clone, Debug)] +pub struct SoapAdapter { + client: Client, +} + +impl Default for SoapAdapter { + fn default() -> Self { + Self::new() + } +} + +impl SoapAdapter { + pub fn new() -> Self { + Self { + client: Client::new(), + } + } + + pub async fn execute( + &self, + target: &SoapTarget, + request: &SoapRequest, + ) -> Result { + let endpoint = target + .endpoint_override + .as_deref() + .ok_or(SoapAdapterError::MissingEndpoint)?; + let headers = build_headers(target, request)?; + let envelope = xml::build_envelope( + &target.operation_name, + target.metadata.namespaces.first().map(String::as_str), + &request.body, + envelope_namespace(target.soap_version), + ); + let response = self + .client + .post(endpoint) + .headers(headers) + .body(envelope) + .timeout(Duration::from_millis(request.timeout_ms)) + .send() + .await?; + let status = response.status(); + let headers = normalize_headers(response.headers()); + let body_text = response.text().await?; + let body = xml::decode_envelope(&body_text)?; + + if !status.is_success() { + return Err(SoapAdapterError::UnexpectedStatus { + status: status.as_u16(), + body, + }); + } + + Ok(SoapResponse { + status_code: status.as_u16(), + headers, + body, + }) + } +} + +fn build_headers( + target: &SoapTarget, + request: &SoapRequest, +) -> Result { + let mut headers = HeaderMap::new(); + let content_type = match target.soap_version { + SoapVersion::Soap11 => "text/xml; charset=utf-8".to_owned(), + SoapVersion::Soap12 => match target.soap_action.as_deref() { + Some(action) => format!(r#"application/soap+xml; charset=utf-8; action="{action}""#), + None => "application/soap+xml; charset=utf-8".to_owned(), + }, + }; + headers.insert( + reqwest::header::CONTENT_TYPE, + HeaderValue::from_str(&content_type).map_err(|_| SoapAdapterError::MissingEndpoint)?, + ); + headers.insert( + reqwest::header::ACCEPT, + HeaderValue::from_static("application/soap+xml, text/xml, application/xml"), + ); + + if matches!(target.soap_version, SoapVersion::Soap11) { + if let Some(action) = target.soap_action.as_deref() { + headers.insert( + HeaderName::from_static("soapaction"), + HeaderValue::from_str(action).map_err(|_| SoapAdapterError::MissingEndpoint)?, + ); + } + } + + for (name, value) in &request.headers { + let header_name = + HeaderName::try_from(name.as_str()).map_err(|_| SoapAdapterError::MissingEndpoint)?; + let header_value = + HeaderValue::try_from(value).map_err(|_| SoapAdapterError::MissingEndpoint)?; + headers.insert(header_name, header_value); + } + + Ok(headers) +} + +fn envelope_namespace(version: SoapVersion) -> &'static str { + match version { + SoapVersion::Soap11 => "http://schemas.xmlsoap.org/soap/envelope/", + SoapVersion::Soap12 => "http://www.w3.org/2003/05/soap-envelope", + } +} + +fn normalize_headers(headers: &HeaderMap) -> BTreeMap { + headers + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_owned(), value.to_owned())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use axum::{Router, body::Bytes, routing::post}; + use crank_core::{SoapBindingStyle, SoapOperationMetadata, SoapTarget, SoapVersion, Target}; + use serde_json::json; + use tokio::net::TcpListener; + + use crate::{SoapAdapter, SoapRequest}; + + async fn spawn_server() -> String { + async fn handler(body: Bytes) -> String { + let text = String::from_utf8_lossy(&body); + assert!(text.contains("user@example.com")); + r#" + + + lead_123 + created + + + "# + .to_owned() + } + + let app = Router::new().route("/", post(handler)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + + fn test_target(endpoint: String) -> SoapTarget { + match Target::Soap(SoapTarget { + wsdl_ref: "sample_wsdl".into(), + service_name: "LeadService".to_owned(), + port_name: "LeadPort".to_owned(), + operation_name: "CreateLead".to_owned(), + endpoint_override: Some(endpoint), + soap_version: SoapVersion::Soap11, + soap_action: Some("urn:createLead".to_owned()), + binding_style: SoapBindingStyle::DocumentLiteral, + headers: Vec::new(), + fault_contract: None, + metadata: SoapOperationMetadata { + input_part_names: vec!["CreateLeadRequest".to_owned()], + output_part_names: vec!["CreateLeadResponse".to_owned()], + namespaces: vec!["urn:crm".to_owned()], + }, + }) { + Target::Soap(target) => target, + _ => unreachable!(), + } + } + + #[tokio::test] + async fn executes_soap_request_response() { + let endpoint = spawn_server().await; + let adapter = SoapAdapter::new(); + let response = adapter + .execute( + &test_target(endpoint), + &SoapRequest { + headers: BTreeMap::new(), + body: json!({ "email": "user@example.com" }), + timeout_ms: 1_000, + }, + ) + .await + .unwrap(); + + assert_eq!(response.status_code, 200); + assert_eq!(response.body["id"], "lead_123"); + } +} diff --git a/crates/crank-adapter-soap/src/error.rs b/crates/crank-adapter-soap/src/error.rs new file mode 100644 index 0000000..4fb8915 --- /dev/null +++ b/crates/crank-adapter-soap/src/error.rs @@ -0,0 +1,24 @@ +use serde_json::Value; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SoapAdapterError { + #[error("soap endpoint is missing")] + MissingEndpoint, + #[error("request failed")] + Transport(#[from] reqwest::Error), + #[error("invalid xml payload")] + InvalidXml(#[from] roxmltree::Error), + #[error("soap envelope body was not found")] + MissingBody, + #[error("soap response body was empty")] + EmptyBody, + #[error("soap endpoint returned status {status}")] + UnexpectedStatus { status: u16, body: Value }, + #[error("soap fault: {message}")] + SoapFault { + code: Option, + message: String, + detail: Option, + }, +} diff --git a/crates/crank-adapter-soap/src/lib.rs b/crates/crank-adapter-soap/src/lib.rs new file mode 100644 index 0000000..7927abe --- /dev/null +++ b/crates/crank-adapter-soap/src/lib.rs @@ -0,0 +1,12 @@ +mod client; +mod error; +mod model; +mod wsdl; +mod xml; + +pub use client::SoapAdapter; +pub use error::SoapAdapterError; +pub use model::{ + SoapOperationSummary, SoapPortSummary, SoapRequest, SoapResponse, SoapServiceSummary, +}; +pub use wsdl::inspect_wsdl; diff --git a/crates/crank-adapter-soap/src/model.rs b/crates/crank-adapter-soap/src/model.rs new file mode 100644 index 0000000..04580df --- /dev/null +++ b/crates/crank-adapter-soap/src/model.rs @@ -0,0 +1,40 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SoapRequest { + pub headers: BTreeMap, + pub body: Value, + pub timeout_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SoapResponse { + pub status_code: u16, + pub headers: BTreeMap, + pub body: Value, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SoapOperationSummary { + pub operation_name: String, + pub soap_action: Option, + pub binding_style: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SoapPortSummary { + pub port_name: String, + pub binding_name: String, + pub endpoint: Option, + pub soap_version: String, + pub operations: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SoapServiceSummary { + pub service_name: String, + pub ports: Vec, +} diff --git a/crates/crank-adapter-soap/src/wsdl.rs b/crates/crank-adapter-soap/src/wsdl.rs new file mode 100644 index 0000000..801298a --- /dev/null +++ b/crates/crank-adapter-soap/src/wsdl.rs @@ -0,0 +1,165 @@ +use std::collections::BTreeMap; + +use roxmltree::Document; + +use crate::{SoapAdapterError, SoapOperationSummary, SoapPortSummary, SoapServiceSummary}; + +#[derive(Clone, Debug)] +struct BindingSummary { + soap_version: String, + operations: Vec, +} + +pub fn inspect_wsdl(payload: &[u8]) -> Result, SoapAdapterError> { + let text = std::str::from_utf8(payload).map_err(|_| SoapAdapterError::EmptyBody)?; + let document = Document::parse(text)?; + let bindings = collect_bindings(&document); + + let services = document + .descendants() + .filter(|node| node.is_element() && node.tag_name().name() == "service") + .map(|service| { + let service_name = service.attribute("name").unwrap_or_default().to_owned(); + let ports = service + .children() + .filter(|node| node.is_element() && node.tag_name().name() == "port") + .map(|port| { + let port_name = port.attribute("name").unwrap_or_default().to_owned(); + let binding_name = local_name(port.attribute("binding").unwrap_or_default()); + let endpoint = port + .descendants() + .find(|node| node.is_element() && node.tag_name().name() == "address") + .and_then(|node| node.attribute("location")) + .map(ToOwned::to_owned); + let binding = bindings + .get(&binding_name) + .cloned() + .unwrap_or(BindingSummary { + soap_version: "soap_11".to_owned(), + operations: Vec::new(), + }); + + SoapPortSummary { + port_name, + binding_name, + endpoint, + soap_version: binding.soap_version, + operations: binding.operations, + } + }) + .collect(); + + SoapServiceSummary { + service_name, + ports, + } + }) + .collect(); + + Ok(services) +} + +fn collect_bindings(document: &Document<'_>) -> BTreeMap { + document + .descendants() + .filter(|node| node.is_element() && node.tag_name().name() == "binding") + .map(|binding| { + let name = binding.attribute("name").unwrap_or_default().to_owned(); + let soap_binding = binding + .children() + .find(|node| node.is_element() && node.tag_name().name() == "binding"); + let soap_namespace = soap_binding + .and_then(|node| node.tag_name().namespace()) + .unwrap_or("http://schemas.xmlsoap.org/wsdl/soap/"); + let soap_version = if soap_namespace.contains("soap12") { + "soap_12" + } else { + "soap_11" + } + .to_owned(); + let binding_style = soap_binding + .and_then(|node| node.attribute("style")) + .map(|value| match value { + "rpc" => "rpc_literal", + _ => "document_literal", + }) + .unwrap_or("document_literal") + .to_owned(); + let operations = binding + .children() + .filter(|node| node.is_element() && node.tag_name().name() == "operation") + .map(|operation| SoapOperationSummary { + operation_name: operation.attribute("name").unwrap_or_default().to_owned(), + soap_action: operation + .children() + .find(|node| node.is_element() && node.tag_name().name() == "operation") + .and_then(|node| node.attribute("soapAction")) + .map(ToOwned::to_owned), + binding_style: operation + .children() + .find(|node| node.is_element() && node.tag_name().name() == "operation") + .and_then(|node| node.attribute("style")) + .map(|value| match value { + "rpc" => "rpc_literal", + _ => "document_literal", + }) + .unwrap_or(binding_style.as_str()) + .to_owned(), + }) + .collect(); + + ( + name, + BindingSummary { + soap_version, + operations, + }, + ) + }) + .collect() +} + +fn local_name(value: &str) -> String { + value.rsplit(':').next().unwrap_or(value).to_owned() +} + +#[cfg(test)] +mod tests { + use super::inspect_wsdl; + + #[test] + fn parses_services_ports_and_operations_from_wsdl() { + let services = inspect_wsdl( + br#" + + + + + + + + + + + + + "#, + ) + .unwrap(); + + assert_eq!(services[0].service_name, "LeadService"); + assert_eq!(services[0].ports[0].port_name, "LeadPort"); + assert_eq!(services[0].ports[0].soap_version, "soap_11"); + assert_eq!( + services[0].ports[0].operations[0].operation_name, + "CreateLead" + ); + assert_eq!( + services[0].ports[0].operations[0].soap_action.as_deref(), + Some("urn:createLead") + ); + } +} diff --git a/crates/crank-adapter-soap/src/xml.rs b/crates/crank-adapter-soap/src/xml.rs new file mode 100644 index 0000000..92e39f2 --- /dev/null +++ b/crates/crank-adapter-soap/src/xml.rs @@ -0,0 +1,229 @@ +use roxmltree::{Document, Node}; +use serde_json::{Map, Value}; + +use crate::SoapAdapterError; + +pub fn build_envelope( + operation_name: &str, + namespace: Option<&str>, + body: &Value, + envelope_namespace: &str, +) -> String { + let mut xml = String::new(); + xml.push_str(&format!( + r#""# + )); + xml.push_str(""); + match namespace { + Some(namespace) => xml.push_str(&format!(r#""#)), + None => xml.push_str(&format!("<{operation_name}>")), + } + append_value_children(&mut xml, body); + match namespace { + Some(_) => xml.push_str(&format!("")), + None => xml.push_str(&format!("")), + } + xml.push_str(""); + xml +} + +pub fn decode_envelope(payload: &str) -> Result { + let document = Document::parse(payload)?; + let body = document + .descendants() + .find(|node| node.is_element() && node.tag_name().name() == "Body") + .ok_or(SoapAdapterError::MissingBody)?; + + let body_child = body + .children() + .find(|node| node.is_element()) + .ok_or(SoapAdapterError::EmptyBody)?; + + if body_child.tag_name().name() == "Fault" { + return Err(parse_fault(body_child)); + } + + Ok(node_to_json(body_child, true)) +} + +fn parse_fault(fault: Node<'_, '_>) -> SoapAdapterError { + let code = find_nested_text(fault, &["Code", "Value"]) + .or_else(|| find_nested_text(fault, &["faultcode"])); + let message = find_nested_text(fault, &["Reason", "Text"]) + .or_else(|| find_nested_text(fault, &["faultstring"])) + .unwrap_or_else(|| "SOAP fault".to_owned()); + let detail = fault + .children() + .find(|node| node.is_element() && node.tag_name().name() == "Detail") + .map(|node| node_to_json(node, false)); + + SoapAdapterError::SoapFault { + code, + message, + detail, + } +} + +fn find_nested_text(node: Node<'_, '_>, path: &[&str]) -> Option { + let mut current = node; + for segment in path { + current = current + .children() + .find(|child| child.is_element() && child.tag_name().name() == *segment)?; + } + + current + .text() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn append_value_children(xml: &mut String, value: &Value) { + match value { + Value::Object(object) => { + for (key, value) in object { + append_named_value(xml, key, value); + } + } + other => append_named_value(xml, "value", other), + } +} + +fn append_named_value(xml: &mut String, name: &str, value: &Value) { + match value { + Value::Array(items) => { + for item in items { + append_named_value(xml, name, item); + } + } + Value::Object(object) => { + xml.push('<'); + xml.push_str(name); + xml.push('>'); + for (child_name, child_value) in object { + append_named_value(xml, child_name, child_value); + } + xml.push_str("'); + } + Value::Null => { + xml.push('<'); + xml.push_str(name); + xml.push_str("/>"); + } + Value::Bool(value) => { + xml.push('<'); + xml.push_str(name); + xml.push('>'); + xml.push_str(if *value { "true" } else { "false" }); + xml.push_str("'); + } + Value::Number(value) => { + xml.push('<'); + xml.push_str(name); + xml.push('>'); + xml.push_str(&value.to_string()); + xml.push_str("'); + } + Value::String(value) => { + xml.push('<'); + xml.push_str(name); + xml.push('>'); + xml.push_str(&escape_xml(value)); + xml.push_str("'); + } + } +} + +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn node_to_json(node: Node<'_, '_>, _unwrap_root: bool) -> Value { + let children = node + .children() + .filter(|child| child.is_element()) + .collect::>(); + + if children.is_empty() { + return node + .text() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| Value::String(value.to_owned())) + .unwrap_or(Value::Null); + } + + let mut object = Map::new(); + for child in children { + let key = child.tag_name().name().to_owned(); + let value = node_to_json(child, false); + match object.get_mut(&key) { + Some(existing) => match existing { + Value::Array(array) => array.push(value), + other => { + let previous = other.take(); + *other = Value::Array(vec![previous, value]); + } + }, + None => { + object.insert(key, value); + } + } + } + + Value::Object(object) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{build_envelope, decode_envelope}; + + #[test] + fn builds_document_literal_envelope() { + let xml = build_envelope( + "CreateLead", + Some("urn:crm"), + &json!({"email":"user@example.com","source":"mcp"}), + "http://schemas.xmlsoap.org/soap/envelope/", + ); + + assert!(xml.contains("user@example.com")); + } + + #[test] + fn decodes_wrapped_response_body() { + let value = decode_envelope( + r#" + + + lead_123 + created + + + "#, + ) + .unwrap(); + + assert_eq!(value["id"], "lead_123"); + assert_eq!(value["status"], "created"); + } +} diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index a6146cf..ca2155c 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -269,6 +269,8 @@ pub enum DescriptorKind { ProtoUpload, DescriptorSet, ReflectionSnapshot, + WsdlUpload, + XsdUpload, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/crank-runtime/Cargo.toml b/crates/crank-runtime/Cargo.toml index 7445076..d89fc8b 100644 --- a/crates/crank-runtime/Cargo.toml +++ b/crates/crank-runtime/Cargo.toml @@ -9,6 +9,7 @@ version.workspace = true crank-adapter-graphql = { path = "../crank-adapter-graphql" } crank-adapter-grpc = { path = "../crank-adapter-grpc" } crank-adapter-rest = { path = "../crank-adapter-rest" } +crank-adapter-soap = { path = "../crank-adapter-soap" } crank-adapter-websocket = { path = "../crank-adapter-websocket" } crank-core = { path = "../crank-core" } crank-mapping = { path = "../crank-mapping" } diff --git a/crates/crank-runtime/src/error.rs b/crates/crank-runtime/src/error.rs index 2f88134..6c0d022 100644 --- a/crates/crank-runtime/src/error.rs +++ b/crates/crank-runtime/src/error.rs @@ -1,6 +1,7 @@ use crank_adapter_graphql::GraphqlAdapterError; use crank_adapter_grpc::GrpcAdapterError; use crank_adapter_rest::RestAdapterError; +use crank_adapter_soap::SoapAdapterError; use crank_adapter_websocket::WebsocketAdapterError; use crank_core::{ExecutionMode, Protocol}; use crank_mapping::MappingError; @@ -20,6 +21,8 @@ pub enum RuntimeError { #[error(transparent)] RestAdapter(#[from] RestAdapterError), #[error(transparent)] + SoapAdapter(#[from] SoapAdapterError), + #[error(transparent)] WebsocketAdapter(#[from] WebsocketAdapterError), #[error("protocol {protocol:?} is not supported by runtime")] UnsupportedProtocol { protocol: Protocol }, diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index c91e22f..5e355ad 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use crank_adapter_graphql::{GraphqlAdapter, GraphqlRequest}; use crank_adapter_grpc::{GrpcAdapter, GrpcRequest, GrpcWindowRequest}; use crank_adapter_rest::{RestAdapter, RestRequest, RestWindowRequest}; +use crank_adapter_soap::{SoapAdapter, SoapRequest}; use crank_adapter_websocket::{WebsocketAdapter, WebsocketWindowRequest}; use crank_core::{ExecutionMode, Target, TransportBehavior}; use serde_json::{Map, Value, json}; @@ -16,6 +17,7 @@ pub struct RuntimeExecutor { graphql_adapter: GraphqlAdapter, grpc_adapter: GrpcAdapter, rest_adapter: RestAdapter, + soap_adapter: SoapAdapter, websocket_adapter: WebsocketAdapter, } @@ -31,6 +33,7 @@ impl RuntimeExecutor { graphql_adapter: GraphqlAdapter::new(), grpc_adapter: GrpcAdapter::new(), rest_adapter: RestAdapter::new(), + soap_adapter: SoapAdapter::new(), websocket_adapter: WebsocketAdapter::new(), } } @@ -205,9 +208,28 @@ impl RuntimeExecutor { data: Value::Null, }) } - Target::Soap(_) => Err(RuntimeError::UnsupportedProtocol { - protocol: crank_core::Protocol::Soap, - }), + Target::Soap(target) => { + let request = SoapRequest { + headers: merge_headers( + &BTreeMap::new(), + &operation.execution_config.headers, + &prepared_request.headers, + ), + body: prepared_request + .body + .clone() + .unwrap_or(Value::Object(Map::new())), + timeout_ms: operation.execution_config.timeout_ms, + }; + let response = self.soap_adapter.execute(target, &request).await?; + + Ok(AdapterResponse { + status_code: response.status_code, + headers: response.headers, + body: response.body, + data: Value::Null, + }) + } Target::Websocket(_) => Err(RuntimeError::UnsupportedExecutionMode { operation_id: operation.operation_id.as_str().to_owned(), mode: ExecutionMode::Unary, @@ -326,8 +348,9 @@ impl RuntimeExecutor { data: Value::Null, }) } - Target::Soap(_) => Err(RuntimeError::UnsupportedProtocol { - protocol: crank_core::Protocol::Soap, + Target::Soap(_) => Err(RuntimeError::UnsupportedExecutionMode { + operation_id: operation.operation_id.as_str().to_owned(), + mode: ExecutionMode::Window, }), _ => self.execute_adapter(operation, prepared_request).await, } @@ -447,7 +470,8 @@ mod tests { AggregationMode, DescriptorId, ExecutionConfig, ExecutionMode, GeneratedDraft, GeneratedDraftStatus, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, Operation, OperationId, OperationStatus, Protocol, ProtocolOptions, RestTarget, Samples, - StreamingConfig, Target, ToolDescription, ToolExample, ToolFamilyConfig, TransportBehavior, + SoapBindingStyle, SoapOperationMetadata, SoapTarget, SoapVersion, StreamingConfig, Target, + ToolDescription, ToolExample, ToolFamilyConfig, TransportBehavior, WebsocketProtocolOptions, WebsocketTarget, }; use crank_mapping::{MappingRule, MappingSet}; @@ -501,6 +525,20 @@ mod tests { assert_eq!(output, json!({ "message": "hello" })); } + #[tokio::test] + async fn executes_soap_operation_end_to_end() { + let endpoint = spawn_soap_server().await; + let executor = RuntimeExecutor::new(); + let operation = test_soap_operation(&endpoint); + + let output = executor + .execute(&operation, &json!({ "email": "user@example.com" })) + .await + .unwrap(); + + assert_eq!(output, json!({ "id": "lead_123" })); + } + #[tokio::test] async fn executes_grpc_window_mode_with_server_stream() { let server_addr = grpc_test_support::spawn_unary_echo_server().await; @@ -729,6 +767,18 @@ mod tests { format!("http://{}", address) } + async fn spawn_soap_server() -> String { + let app = Router::new().route("/", post(soap_handler)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + async fn spawn_websocket_server() -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -853,6 +903,23 @@ mod tests { })) } + async fn soap_handler(body: String) -> (axum::http::StatusCode, String) { + assert!(body.contains("user@example.com")); + + ( + axum::http::StatusCode::OK, + r#" + + + lead_123 + created + + + "# + .to_owned(), + ) + } + fn test_rest_operation( base_url: &str, should_fail: bool, @@ -1181,6 +1248,90 @@ mod tests { }) } + fn test_soap_operation(endpoint: &str) -> RuntimeOperation { + RuntimeOperation::from(Operation { + id: OperationId::new("op_soap_runtime"), + name: "crm_create_lead_soap".to_owned(), + display_name: "Create Lead SOAP".to_owned(), + category: "sales".to_owned(), + protocol: Protocol::Soap, + status: OperationStatus::Published, + version: 1, + target: Target::Soap(SoapTarget { + wsdl_ref: "sample_wsdl".into(), + service_name: "LeadService".to_owned(), + port_name: "LeadPort".to_owned(), + operation_name: "CreateLead".to_owned(), + endpoint_override: Some(endpoint.to_owned()), + soap_version: SoapVersion::Soap11, + soap_action: Some("urn:createLead".to_owned()), + binding_style: SoapBindingStyle::DocumentLiteral, + headers: Vec::new(), + fault_contract: None, + metadata: SoapOperationMetadata { + input_part_names: vec!["CreateLeadRequest".to_owned()], + output_part_names: vec!["CreateLeadResponse".to_owned()], + namespaces: vec!["urn:crm".to_owned()], + }, + }), + input_schema: object_schema("email", SchemaKind::String), + output_schema: object_schema("id", SchemaKind::String), + input_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, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.body.id".to_owned(), + target: "$.output.id".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + execution_config: ExecutionConfig { + timeout_ms: 1_000, + retry_policy: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: ToolDescription { + title: "Create Lead SOAP".to_owned(), + description: "Creates a CRM lead through SOAP".to_owned(), + tags: vec!["crm".to_owned(), "soap".to_owned()], + examples: vec![ToolExample { + input: json!({ "email": "user@example.com" }), + }], + }, + samples: Some(Samples::default()), + generated_draft: Some(GeneratedDraft { + status: GeneratedDraftStatus::Available, + source_types: vec!["wsdl".to_owned()], + generated_at: Some("2026-04-06T12:00:00Z".to_owned()), + input_schema_generated: true, + output_schema_generated: true, + input_mapping_generated: true, + output_mapping_generated: true, + warnings: Vec::new(), + }), + config_export: None, + created_at: "2026-04-06T12:00:00Z".to_owned(), + updated_at: "2026-04-06T12:00:00Z".to_owned(), + published_at: Some("2026-04-06T12:00:00Z".to_owned()), + }) + } + fn test_window_snapshot_operation( base_url: &str, aggregation_mode: AggregationMode, @@ -1340,6 +1491,7 @@ mod tests { reconnect_max_attempts: Some(1), reconnect_backoff_ms: Some(10), }), + soap: None, }), streaming: Some(StreamingConfig { mode: ExecutionMode::Window, diff --git a/docs/admin-api.md b/docs/admin-api.md index b994db8..f121f57 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -117,7 +117,16 @@ - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/drafts/generate` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/descriptors/proto` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/descriptors/descriptor-set` +- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/descriptors/wsdl` +- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/descriptors/xsd` - `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/grpc/services` +- `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/soap/services` + +Контракт: + +- `POST /descriptors/wsdl` принимает raw WSDL payload и сохраняет inspection metadata для SOAP; +- `POST /descriptors/xsd` принимает supporting XSD artifacts для SOAP operation; +- `GET /soap/services` возвращает нормализованный список `service -> port -> operation` из последнего WSDL текущего draft version. ### 5.5. Upstream auth profiles diff --git a/docs/database-schema.md b/docs/database-schema.md index 88eccf3..1ace2af 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -125,6 +125,20 @@ - `package_index_json` - `created_at` +`descriptor_kind` дополнительно поддерживает: + +- `wsdl_upload` +- `xsd_upload` + +`package_index_json` для `wsdl_upload` хранит нормализованный inspection result: + +- `service_name` +- `port_name` +- `binding_name` +- `endpoint` +- `soap_version` +- `operations[]` + ### `yaml_import_jobs` - `id` diff --git a/docs/protocols/soap.md b/docs/protocols/soap.md index 5ad61f9..b5762ba 100644 --- a/docs/protocols/soap.md +++ b/docs/protocols/soap.md @@ -96,19 +96,27 @@ Execution constraints: - `Protocol::Soap` поддерживает только `unary` и `async_job`; - `window` и `session` для SOAP не поддерживаются; -- runtime до появления SOAP adapter обязан отклонять выполнение как `unsupported protocol`. +- runtime foundation поддерживает `unary` SOAP request-response execution; +- `window` и `session` execution по-прежнему отклоняются как unsupported mode. ## 7. Как оператор настраивает SOAP operation 1. Загружает WSDL. -2. Система извлекает services, ports, operations и XSD schemas. -3. Оператор выбирает service, port и operation. -4. UI показывает input/output schema в JSON-oriented виде. -5. Оператор настраивает mapping `MCP input -> SOAP body`. -6. При необходимости задает SOAP headers и auth profile. -7. Настраивает output mapping и fault handling. -8. Выполняет test run. -9. Публикует operation как MCP tool. +2. При необходимости загружает supporting XSD. +3. Система извлекает services, ports и operations из WSDL. +4. Оператор выбирает service, port и operation. +5. UI показывает input/output schema в JSON-oriented виде. +6. Оператор настраивает mapping `MCP input -> SOAP body`. +7. При необходимости задает SOAP headers и auth profile. +8. Настраивает output mapping и fault handling. +9. Выполняет test run. +10. Публикует operation как MCP tool. + +Foundation routes: + +- `POST /operations/{operation_id}/descriptors/wsdl` +- `POST /operations/{operation_id}/descriptors/xsd` +- `GET /operations/{operation_id}/soap/services` ## 8. Поведение runtime @@ -116,7 +124,7 @@ Execution constraints: 1. Валидировать MCP input. 2. Построить XML envelope. -3. Подставить namespaces и headers. +3. Подставить namespaces, `Content-Type` и `SOAPAction`. 4. Выполнить HTTP request. 5. Разобрать SOAP response и SOAP Fault. 6. Нормализовать XML result в JSON.