feat: add soap adapter foundation

This commit is contained in:
a.tolmachev
2026-04-07 00:00:19 +03:00
parent 31fbdfdc02
commit a6388e4353
24 changed files with 1346 additions and 25 deletions
+1
View File
@@ -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" }
+3
View File
@@ -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 },
+158 -6
View File
@@ -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("<email>user@example.com</email>"));
(
axum::http::StatusCode::OK,
r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<CreateLeadResponse>
<id>lead_123</id>
<status>created</status>
</CreateLeadResponse>
</soap:Body>
</soap:Envelope>"#
.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,