feat: add soap adapter foundation
This commit is contained in:
Generated
+22
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"axum-extra",
|
"axum-extra",
|
||||||
"base64",
|
"base64",
|
||||||
"crank-adapter-grpc",
|
"crank-adapter-grpc",
|
||||||
|
"crank-adapter-soap",
|
||||||
"crank-core",
|
"crank-core",
|
||||||
"crank-mapping",
|
"crank-mapping",
|
||||||
"crank-proto",
|
"crank-proto",
|
||||||
@@ -391,6 +392,20 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crank-adapter-soap"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"crank-core",
|
||||||
|
"reqwest",
|
||||||
|
"roxmltree",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crank-adapter-websocket"
|
name = "crank-adapter-websocket"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -462,6 +477,7 @@ dependencies = [
|
|||||||
"crank-adapter-graphql",
|
"crank-adapter-graphql",
|
||||||
"crank-adapter-grpc",
|
"crank-adapter-grpc",
|
||||||
"crank-adapter-rest",
|
"crank-adapter-rest",
|
||||||
|
"crank-adapter-soap",
|
||||||
"crank-adapter-websocket",
|
"crank-adapter-websocket",
|
||||||
"crank-core",
|
"crank-core",
|
||||||
"crank-mapping",
|
"crank-mapping",
|
||||||
@@ -1939,6 +1955,12 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ members = [
|
|||||||
"crates/crank-adapter-graphql",
|
"crates/crank-adapter-graphql",
|
||||||
"crates/crank-adapter-grpc",
|
"crates/crank-adapter-grpc",
|
||||||
"crates/crank-adapter-websocket",
|
"crates/crank-adapter-websocket",
|
||||||
|
"crates/crank-adapter-soap",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ prost-types = "0.14"
|
|||||||
protoc-bin-vendored = "3"
|
protoc-bin-vendored = "3"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] }
|
||||||
|
roxmltree = "0.20"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/soap-architecture-and-core-model`
|
### `feat/soap-adapter-foundation`
|
||||||
|
|
||||||
Status: completed
|
Status: completed
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- SOAP target model is defined in `crank-core`
|
- `crank-adapter-soap` supports unary SOAP request-response execution
|
||||||
- WSDL/XSD-derived metadata model is defined in code-level types
|
- WSDL upload and service/port/operation inspection are available in `admin-api`
|
||||||
- capability matrix exposes SOAP as a first-class protocol
|
- SOAP faults are normalized into runtime errors
|
||||||
- docs and task state are synchronized
|
- workspace-scoped SOAP test runs work through the same `Operation` lifecycle
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/soap-adapter-foundation`
|
- `feat/auth-profile-secret-resolution`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ axum.workspace = true
|
|||||||
axum-extra.workspace = true
|
axum-extra.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
crank-adapter-grpc = { path = "../../crates/crank-adapter-grpc", features = ["test-support"] }
|
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-core = { path = "../../crates/crank-core" }
|
||||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||||
crank-proto = { path = "../../crates/crank-proto" }
|
crank-proto = { path = "../../crates/crank-proto" }
|
||||||
|
|||||||
+199
-3
@@ -24,8 +24,9 @@ use crate::{
|
|||||||
operations::{
|
operations::{
|
||||||
archive_operation, create_operation, create_version, delete_operation,
|
archive_operation, create_operation, create_version, delete_operation,
|
||||||
export_operation, generate_draft, get_operation, get_operation_version,
|
export_operation, generate_draft, get_operation, get_operation_version,
|
||||||
list_grpc_services, list_operations, publish_operation, run_test, update_operation,
|
list_grpc_services, list_operations, list_soap_services, publish_operation, run_test,
|
||||||
upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_descriptor,
|
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},
|
secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret},
|
||||||
streaming::{
|
streaming::{
|
||||||
@@ -77,10 +78,22 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
"/operations/{operation_id}/descriptors/descriptor-set",
|
"/operations/{operation_id}/descriptors/descriptor-set",
|
||||||
post(upload_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(
|
.route(
|
||||||
"/operations/{operation_id}/grpc/services",
|
"/operations/{operation_id}/grpc/services",
|
||||||
get(list_grpc_services),
|
get(list_grpc_services),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/operations/{operation_id}/soap/services",
|
||||||
|
get(list_soap_services),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/operations/{operation_id}/drafts/generate",
|
"/operations/{operation_id}/drafts/generate",
|
||||||
post(generate_draft),
|
post(generate_draft),
|
||||||
@@ -209,7 +222,8 @@ mod tests {
|
|||||||
use crank_adapter_grpc::test_support as grpc_test_support;
|
use crank_adapter_grpc::test_support as grpc_test_support;
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
DescriptorId, ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
|
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_mapping::{MappingRule, MappingSet};
|
||||||
use crank_registry::PostgresRegistry;
|
use crank_registry::PostgresRegistry;
|
||||||
@@ -1427,6 +1441,77 @@ mod tests {
|
|||||||
assert_eq!(test_run["response_preview"]["message"], "hello");
|
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::<Value>()
|
||||||
|
.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::<Value>()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let services = client
|
||||||
|
.get(format!(
|
||||||
|
"{base_url}/operations/{operation_id}/soap/services"
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json::<Value>()
|
||||||
|
.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::<Value>()
|
||||||
|
.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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn manages_auth_profiles_and_yaml_upsert() {
|
async fn manages_auth_profiles_and_yaml_upsert() {
|
||||||
@@ -1779,6 +1864,18 @@ mod tests {
|
|||||||
format!("http://{}", address)
|
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 {
|
async fn spawn_admin_api(app: Router) -> TestServer {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let address = listener.local_addr().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("<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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn test_registry() -> PostgresRegistry {
|
async fn test_registry() -> PostgresRegistry {
|
||||||
let database_url = env::var("TEST_DATABASE_URL")
|
let database_url = env::var("TEST_DATABASE_URL")
|
||||||
.unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:5432/crank".to_owned());
|
.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#"<?xml version="1.0"?>
|
||||||
|
<definitions xmlns="http://schemas.xmlsoap.org/wsdl/"
|
||||||
|
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
|
||||||
|
xmlns:tns="urn:crm"
|
||||||
|
targetNamespace="urn:crm">
|
||||||
|
<binding name="LeadBinding" type="tns:LeadPortType">
|
||||||
|
<soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
|
||||||
|
<operation name="CreateLead">
|
||||||
|
<soap:operation soapAction="urn:createLead"/>
|
||||||
|
</operation>
|
||||||
|
</binding>
|
||||||
|
<service name="LeadService">
|
||||||
|
<port name="LeadPort" binding="tns:LeadBinding">
|
||||||
|
<soap:address location="https://soap.example.com/lead"/>
|
||||||
|
</port>
|
||||||
|
</service>
|
||||||
|
</definitions>"#;
|
||||||
|
|
||||||
fn object_schema(field_name: &str) -> Schema {
|
fn object_schema(field_name: &str) -> Schema {
|
||||||
Schema {
|
Schema {
|
||||||
kind: SchemaKind::Object,
|
kind: SchemaKind::Object,
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
|
|||||||
RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error",
|
RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error",
|
||||||
RuntimeError::GrpcAdapter(_) => "runtime_grpc_error",
|
RuntimeError::GrpcAdapter(_) => "runtime_grpc_error",
|
||||||
RuntimeError::RestAdapter(_) => "runtime_rest_error",
|
RuntimeError::RestAdapter(_) => "runtime_rest_error",
|
||||||
|
RuntimeError::SoapAdapter(_) => "runtime_soap_error",
|
||||||
RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error",
|
RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error",
|
||||||
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
|
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
|
||||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
|
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
|
||||||
|
|||||||
@@ -269,6 +269,46 @@ pub async fn upload_descriptor_set(
|
|||||||
Ok(Json(json!(descriptor)))
|
Ok(Json(json!(descriptor)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn upload_wsdl_descriptor(
|
||||||
|
Path(path): Path<WorkspaceOperationPath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Result<Json<Value>, 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<WorkspaceOperationPath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Result<Json<Value>, 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(
|
pub async fn list_grpc_services(
|
||||||
Path(path): Path<WorkspaceOperationPath>,
|
Path(path): Path<WorkspaceOperationPath>,
|
||||||
Query(query): Query<ExportQuery>,
|
Query(query): Query<ExportQuery>,
|
||||||
@@ -285,6 +325,22 @@ pub async fn list_grpc_services(
|
|||||||
Ok(Json(json!({ "services": services })))
|
Ok(Json(json!({ "services": services })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_soap_services(
|
||||||
|
Path(path): Path<WorkspaceOperationPath>,
|
||||||
|
Query(query): Query<ExportQuery>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Value>, 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(
|
pub async fn generate_draft(
|
||||||
Path(path): Path<WorkspaceOperationPath>,
|
Path(path): Path<WorkspaceOperationPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use base64::{
|
|||||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||||
};
|
};
|
||||||
use crank_adapter_grpc::test_support as grpc_test_support;
|
use crank_adapter_grpc::test_support as grpc_test_support;
|
||||||
|
use crank_adapter_soap::{SoapServiceSummary, inspect_wsdl};
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode,
|
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode,
|
||||||
AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, ExecutionMode,
|
AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, ExecutionMode,
|
||||||
@@ -563,6 +564,8 @@ pub struct GrpcMethodSummary {
|
|||||||
pub output_schema: Schema,
|
pub output_schema: Schema,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub type SoapServiceCatalog = Vec<SoapServiceSummary>;
|
||||||
|
|
||||||
fn default_operation_category() -> String {
|
fn default_operation_category() -> String {
|
||||||
"general".to_owned()
|
"general".to_owned()
|
||||||
}
|
}
|
||||||
@@ -2599,6 +2602,153 @@ impl AdminService {
|
|||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<DescriptorUploadResponse, ApiError> {
|
||||||
|
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<DescriptorUploadResponse, ApiError> {
|
||||||
|
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<u32>,
|
||||||
|
) -> Result<SoapServiceCatalog, ApiError> {
|
||||||
|
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))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_auth_profile(
|
pub async fn get_auth_profile(
|
||||||
&self,
|
&self,
|
||||||
@@ -4017,6 +4167,7 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str {
|
|||||||
RuntimeError::GraphqlAdapter(_) => "graphql_error",
|
RuntimeError::GraphqlAdapter(_) => "graphql_error",
|
||||||
RuntimeError::GrpcAdapter(_) => "grpc_error",
|
RuntimeError::GrpcAdapter(_) => "grpc_error",
|
||||||
RuntimeError::RestAdapter(_) => "rest_error",
|
RuntimeError::RestAdapter(_) => "rest_error",
|
||||||
|
RuntimeError::SoapAdapter(_) => "soap_error",
|
||||||
RuntimeError::WebsocketAdapter(_) => "websocket_error",
|
RuntimeError::WebsocketAdapter(_) => "websocket_error",
|
||||||
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
||||||
RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error",
|
RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error",
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ impl LocalArtifactStorage {
|
|||||||
DescriptorKind::ReflectionSnapshot => {
|
DescriptorKind::ReflectionSnapshot => {
|
||||||
source_name.unwrap_or("reflection.bin").to_owned()
|
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
|
let path = self
|
||||||
.root
|
.root
|
||||||
|
|||||||
@@ -1725,6 +1725,7 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str {
|
|||||||
RuntimeError::GraphqlAdapter(_) => "adapter_execution_error",
|
RuntimeError::GraphqlAdapter(_) => "adapter_execution_error",
|
||||||
RuntimeError::GrpcAdapter(_) => "adapter_execution_error",
|
RuntimeError::GrpcAdapter(_) => "adapter_execution_error",
|
||||||
RuntimeError::RestAdapter(_) => "adapter_execution_error",
|
RuntimeError::RestAdapter(_) => "adapter_execution_error",
|
||||||
|
RuntimeError::SoapAdapter(_) => "adapter_execution_error",
|
||||||
RuntimeError::WebsocketAdapter(_) => "adapter_execution_error",
|
RuntimeError::WebsocketAdapter(_) => "adapter_execution_error",
|
||||||
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
||||||
RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error",
|
RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<SoapResponse, SoapAdapterError> {
|
||||||
|
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<HeaderMap, SoapAdapterError> {
|
||||||
|
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<String, String> {
|
||||||
|
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("<email>user@example.com</email>"));
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
message: String,
|
||||||
|
detail: Option<Value>,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<String, String>,
|
||||||
|
pub body: Value,
|
||||||
|
pub timeout_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SoapResponse {
|
||||||
|
pub status_code: u16,
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
pub body: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SoapOperationSummary {
|
||||||
|
pub operation_name: String,
|
||||||
|
pub soap_action: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
pub soap_version: String,
|
||||||
|
pub operations: Vec<SoapOperationSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SoapServiceSummary {
|
||||||
|
pub service_name: String,
|
||||||
|
pub ports: Vec<SoapPortSummary>,
|
||||||
|
}
|
||||||
@@ -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<SoapOperationSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inspect_wsdl(payload: &[u8]) -> Result<Vec<SoapServiceSummary>, 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<String, BindingSummary> {
|
||||||
|
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#"<?xml version="1.0"?>
|
||||||
|
<definitions xmlns="http://schemas.xmlsoap.org/wsdl/"
|
||||||
|
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
|
||||||
|
xmlns:tns="urn:crm"
|
||||||
|
targetNamespace="urn:crm">
|
||||||
|
<binding name="LeadBinding" type="tns:LeadPortType">
|
||||||
|
<soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
|
||||||
|
<operation name="CreateLead">
|
||||||
|
<soap:operation soapAction="urn:createLead"/>
|
||||||
|
</operation>
|
||||||
|
</binding>
|
||||||
|
<service name="LeadService">
|
||||||
|
<port name="LeadPort" binding="tns:LeadBinding">
|
||||||
|
<soap:address location="https://soap.example.com/lead"/>
|
||||||
|
</port>
|
||||||
|
</service>
|
||||||
|
</definitions>"#,
|
||||||
|
)
|
||||||
|
.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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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#"<soap:Envelope xmlns:soap="{envelope_namespace}">"#
|
||||||
|
));
|
||||||
|
xml.push_str("<soap:Body>");
|
||||||
|
match namespace {
|
||||||
|
Some(namespace) => xml.push_str(&format!(r#"<m:{operation_name}" xmlns:m="{namespace}">"#)),
|
||||||
|
None => xml.push_str(&format!("<{operation_name}>")),
|
||||||
|
}
|
||||||
|
append_value_children(&mut xml, body);
|
||||||
|
match namespace {
|
||||||
|
Some(_) => xml.push_str(&format!("</m:{operation_name}>")),
|
||||||
|
None => xml.push_str(&format!("</{operation_name}>")),
|
||||||
|
}
|
||||||
|
xml.push_str("</soap:Body></soap:Envelope>");
|
||||||
|
xml
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_envelope(payload: &str) -> Result<Value, SoapAdapterError> {
|
||||||
|
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<String> {
|
||||||
|
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("</");
|
||||||
|
xml.push_str(name);
|
||||||
|
xml.push('>');
|
||||||
|
}
|
||||||
|
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("</");
|
||||||
|
xml.push_str(name);
|
||||||
|
xml.push('>');
|
||||||
|
}
|
||||||
|
Value::Number(value) => {
|
||||||
|
xml.push('<');
|
||||||
|
xml.push_str(name);
|
||||||
|
xml.push('>');
|
||||||
|
xml.push_str(&value.to_string());
|
||||||
|
xml.push_str("</");
|
||||||
|
xml.push_str(name);
|
||||||
|
xml.push('>');
|
||||||
|
}
|
||||||
|
Value::String(value) => {
|
||||||
|
xml.push('<');
|
||||||
|
xml.push_str(name);
|
||||||
|
xml.push('>');
|
||||||
|
xml.push_str(&escape_xml(value));
|
||||||
|
xml.push_str("</");
|
||||||
|
xml.push_str(name);
|
||||||
|
xml.push('>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<Vec<_>>();
|
||||||
|
|
||||||
|
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("<soap:Envelope"));
|
||||||
|
assert!(xml.contains("<m:CreateLead"));
|
||||||
|
assert!(xml.contains(r#"xmlns:m="urn:crm""#));
|
||||||
|
assert!(xml.contains("<email>user@example.com</email>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_wrapped_response_body() {
|
||||||
|
let value = decode_envelope(
|
||||||
|
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>"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["id"], "lead_123");
|
||||||
|
assert_eq!(value["status"], "created");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -269,6 +269,8 @@ pub enum DescriptorKind {
|
|||||||
ProtoUpload,
|
ProtoUpload,
|
||||||
DescriptorSet,
|
DescriptorSet,
|
||||||
ReflectionSnapshot,
|
ReflectionSnapshot,
|
||||||
|
WsdlUpload,
|
||||||
|
XsdUpload,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ version.workspace = true
|
|||||||
crank-adapter-graphql = { path = "../crank-adapter-graphql" }
|
crank-adapter-graphql = { path = "../crank-adapter-graphql" }
|
||||||
crank-adapter-grpc = { path = "../crank-adapter-grpc" }
|
crank-adapter-grpc = { path = "../crank-adapter-grpc" }
|
||||||
crank-adapter-rest = { path = "../crank-adapter-rest" }
|
crank-adapter-rest = { path = "../crank-adapter-rest" }
|
||||||
|
crank-adapter-soap = { path = "../crank-adapter-soap" }
|
||||||
crank-adapter-websocket = { path = "../crank-adapter-websocket" }
|
crank-adapter-websocket = { path = "../crank-adapter-websocket" }
|
||||||
crank-core = { path = "../crank-core" }
|
crank-core = { path = "../crank-core" }
|
||||||
crank-mapping = { path = "../crank-mapping" }
|
crank-mapping = { path = "../crank-mapping" }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crank_adapter_graphql::GraphqlAdapterError;
|
use crank_adapter_graphql::GraphqlAdapterError;
|
||||||
use crank_adapter_grpc::GrpcAdapterError;
|
use crank_adapter_grpc::GrpcAdapterError;
|
||||||
use crank_adapter_rest::RestAdapterError;
|
use crank_adapter_rest::RestAdapterError;
|
||||||
|
use crank_adapter_soap::SoapAdapterError;
|
||||||
use crank_adapter_websocket::WebsocketAdapterError;
|
use crank_adapter_websocket::WebsocketAdapterError;
|
||||||
use crank_core::{ExecutionMode, Protocol};
|
use crank_core::{ExecutionMode, Protocol};
|
||||||
use crank_mapping::MappingError;
|
use crank_mapping::MappingError;
|
||||||
@@ -20,6 +21,8 @@ pub enum RuntimeError {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
RestAdapter(#[from] RestAdapterError),
|
RestAdapter(#[from] RestAdapterError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
|
SoapAdapter(#[from] SoapAdapterError),
|
||||||
|
#[error(transparent)]
|
||||||
WebsocketAdapter(#[from] WebsocketAdapterError),
|
WebsocketAdapter(#[from] WebsocketAdapterError),
|
||||||
#[error("protocol {protocol:?} is not supported by runtime")]
|
#[error("protocol {protocol:?} is not supported by runtime")]
|
||||||
UnsupportedProtocol { protocol: Protocol },
|
UnsupportedProtocol { protocol: Protocol },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
|
|||||||
use crank_adapter_graphql::{GraphqlAdapter, GraphqlRequest};
|
use crank_adapter_graphql::{GraphqlAdapter, GraphqlRequest};
|
||||||
use crank_adapter_grpc::{GrpcAdapter, GrpcRequest, GrpcWindowRequest};
|
use crank_adapter_grpc::{GrpcAdapter, GrpcRequest, GrpcWindowRequest};
|
||||||
use crank_adapter_rest::{RestAdapter, RestRequest, RestWindowRequest};
|
use crank_adapter_rest::{RestAdapter, RestRequest, RestWindowRequest};
|
||||||
|
use crank_adapter_soap::{SoapAdapter, SoapRequest};
|
||||||
use crank_adapter_websocket::{WebsocketAdapter, WebsocketWindowRequest};
|
use crank_adapter_websocket::{WebsocketAdapter, WebsocketWindowRequest};
|
||||||
use crank_core::{ExecutionMode, Target, TransportBehavior};
|
use crank_core::{ExecutionMode, Target, TransportBehavior};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
@@ -16,6 +17,7 @@ pub struct RuntimeExecutor {
|
|||||||
graphql_adapter: GraphqlAdapter,
|
graphql_adapter: GraphqlAdapter,
|
||||||
grpc_adapter: GrpcAdapter,
|
grpc_adapter: GrpcAdapter,
|
||||||
rest_adapter: RestAdapter,
|
rest_adapter: RestAdapter,
|
||||||
|
soap_adapter: SoapAdapter,
|
||||||
websocket_adapter: WebsocketAdapter,
|
websocket_adapter: WebsocketAdapter,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ impl RuntimeExecutor {
|
|||||||
graphql_adapter: GraphqlAdapter::new(),
|
graphql_adapter: GraphqlAdapter::new(),
|
||||||
grpc_adapter: GrpcAdapter::new(),
|
grpc_adapter: GrpcAdapter::new(),
|
||||||
rest_adapter: RestAdapter::new(),
|
rest_adapter: RestAdapter::new(),
|
||||||
|
soap_adapter: SoapAdapter::new(),
|
||||||
websocket_adapter: WebsocketAdapter::new(),
|
websocket_adapter: WebsocketAdapter::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,9 +208,28 @@ impl RuntimeExecutor {
|
|||||||
data: Value::Null,
|
data: Value::Null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Target::Soap(_) => Err(RuntimeError::UnsupportedProtocol {
|
Target::Soap(target) => {
|
||||||
protocol: crank_core::Protocol::Soap,
|
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 {
|
Target::Websocket(_) => Err(RuntimeError::UnsupportedExecutionMode {
|
||||||
operation_id: operation.operation_id.as_str().to_owned(),
|
operation_id: operation.operation_id.as_str().to_owned(),
|
||||||
mode: ExecutionMode::Unary,
|
mode: ExecutionMode::Unary,
|
||||||
@@ -326,8 +348,9 @@ impl RuntimeExecutor {
|
|||||||
data: Value::Null,
|
data: Value::Null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Target::Soap(_) => Err(RuntimeError::UnsupportedProtocol {
|
Target::Soap(_) => Err(RuntimeError::UnsupportedExecutionMode {
|
||||||
protocol: crank_core::Protocol::Soap,
|
operation_id: operation.operation_id.as_str().to_owned(),
|
||||||
|
mode: ExecutionMode::Window,
|
||||||
}),
|
}),
|
||||||
_ => self.execute_adapter(operation, prepared_request).await,
|
_ => self.execute_adapter(operation, prepared_request).await,
|
||||||
}
|
}
|
||||||
@@ -447,7 +470,8 @@ mod tests {
|
|||||||
AggregationMode, DescriptorId, ExecutionConfig, ExecutionMode, GeneratedDraft,
|
AggregationMode, DescriptorId, ExecutionConfig, ExecutionMode, GeneratedDraft,
|
||||||
GeneratedDraftStatus, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
|
GeneratedDraftStatus, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
|
||||||
Operation, OperationId, OperationStatus, Protocol, ProtocolOptions, RestTarget, Samples,
|
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,
|
WebsocketProtocolOptions, WebsocketTarget,
|
||||||
};
|
};
|
||||||
use crank_mapping::{MappingRule, MappingSet};
|
use crank_mapping::{MappingRule, MappingSet};
|
||||||
@@ -501,6 +525,20 @@ mod tests {
|
|||||||
assert_eq!(output, json!({ "message": "hello" }));
|
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]
|
#[tokio::test]
|
||||||
async fn executes_grpc_window_mode_with_server_stream() {
|
async fn executes_grpc_window_mode_with_server_stream() {
|
||||||
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
|
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
|
||||||
@@ -729,6 +767,18 @@ mod tests {
|
|||||||
format!("http://{}", address)
|
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 {
|
async fn spawn_websocket_server() -> String {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let address = listener.local_addr().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(
|
fn test_rest_operation(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
should_fail: bool,
|
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(
|
fn test_window_snapshot_operation(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
aggregation_mode: AggregationMode,
|
aggregation_mode: AggregationMode,
|
||||||
@@ -1340,6 +1491,7 @@ mod tests {
|
|||||||
reconnect_max_attempts: Some(1),
|
reconnect_max_attempts: Some(1),
|
||||||
reconnect_backoff_ms: Some(10),
|
reconnect_backoff_ms: Some(10),
|
||||||
}),
|
}),
|
||||||
|
soap: None,
|
||||||
}),
|
}),
|
||||||
streaming: Some(StreamingConfig {
|
streaming: Some(StreamingConfig {
|
||||||
mode: ExecutionMode::Window,
|
mode: ExecutionMode::Window,
|
||||||
|
|||||||
@@ -117,7 +117,16 @@
|
|||||||
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/drafts/generate`
|
- `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/proto`
|
||||||
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/descriptors/descriptor-set`
|
- `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}/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
|
### 5.5. Upstream auth profiles
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,20 @@
|
|||||||
- `package_index_json`
|
- `package_index_json`
|
||||||
- `created_at`
|
- `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`
|
### `yaml_import_jobs`
|
||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
|
|||||||
+18
-10
@@ -96,19 +96,27 @@ Execution constraints:
|
|||||||
|
|
||||||
- `Protocol::Soap` поддерживает только `unary` и `async_job`;
|
- `Protocol::Soap` поддерживает только `unary` и `async_job`;
|
||||||
- `window` и `session` для SOAP не поддерживаются;
|
- `window` и `session` для SOAP не поддерживаются;
|
||||||
- runtime до появления SOAP adapter обязан отклонять выполнение как `unsupported protocol`.
|
- runtime foundation поддерживает `unary` SOAP request-response execution;
|
||||||
|
- `window` и `session` execution по-прежнему отклоняются как unsupported mode.
|
||||||
|
|
||||||
## 7. Как оператор настраивает SOAP operation
|
## 7. Как оператор настраивает SOAP operation
|
||||||
|
|
||||||
1. Загружает WSDL.
|
1. Загружает WSDL.
|
||||||
2. Система извлекает services, ports, operations и XSD schemas.
|
2. При необходимости загружает supporting XSD.
|
||||||
3. Оператор выбирает service, port и operation.
|
3. Система извлекает services, ports и operations из WSDL.
|
||||||
4. UI показывает input/output schema в JSON-oriented виде.
|
4. Оператор выбирает service, port и operation.
|
||||||
5. Оператор настраивает mapping `MCP input -> SOAP body`.
|
5. UI показывает input/output schema в JSON-oriented виде.
|
||||||
6. При необходимости задает SOAP headers и auth profile.
|
6. Оператор настраивает mapping `MCP input -> SOAP body`.
|
||||||
7. Настраивает output mapping и fault handling.
|
7. При необходимости задает SOAP headers и auth profile.
|
||||||
8. Выполняет test run.
|
8. Настраивает output mapping и fault handling.
|
||||||
9. Публикует operation как MCP tool.
|
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
|
## 8. Поведение runtime
|
||||||
|
|
||||||
@@ -116,7 +124,7 @@ Execution constraints:
|
|||||||
|
|
||||||
1. Валидировать MCP input.
|
1. Валидировать MCP input.
|
||||||
2. Построить XML envelope.
|
2. Построить XML envelope.
|
||||||
3. Подставить namespaces и headers.
|
3. Подставить namespaces, `Content-Type` и `SOAPAction`.
|
||||||
4. Выполнить HTTP request.
|
4. Выполнить HTTP request.
|
||||||
5. Разобрать SOAP response и SOAP Fault.
|
5. Разобрать SOAP response и SOAP Fault.
|
||||||
6. Нормализовать XML result в JSON.
|
6. Нормализовать XML result в JSON.
|
||||||
|
|||||||
Reference in New Issue
Block a user