feat: add soap core domain model
This commit is contained in:
@@ -2,19 +2,19 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/websocket-upstream-adapter`
|
### `feat/soap-architecture-and-core-model`
|
||||||
|
|
||||||
Status: completed
|
Status: completed
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- bounded WebSocket window execution is supported
|
- SOAP target model is defined in `crank-core`
|
||||||
- WebSocket protocol is exposed through capability APIs
|
- WSDL/XSD-derived metadata model is defined in code-level types
|
||||||
- session mode can seed from WebSocket window collection
|
- capability matrix exposes SOAP as a first-class protocol
|
||||||
- runtime and adapter tests cover WebSocket collection and reconnects
|
- docs and task state are synchronized
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/soap-architecture-and-core-model`
|
- `feat/soap-adapter-foundation`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
@@ -31,9 +31,6 @@ DoD:
|
|||||||
- `feat/soap-adapter-foundation`
|
- `feat/soap-adapter-foundation`
|
||||||
- `feat/streaming-ui-config`
|
- `feat/streaming-ui-config`
|
||||||
- `feat/streaming-e2e`
|
- `feat/streaming-e2e`
|
||||||
- `feat/websocket-upstream-adapter`
|
|
||||||
- `feat/soap-architecture-and-core-model`
|
|
||||||
- `feat/soap-adapter-foundation`
|
|
||||||
- `feat/auth-profile-secret-resolution`
|
- `feat/auth-profile-secret-resolution`
|
||||||
- `feat/runtime-upstream-auth`
|
- `feat/runtime-upstream-auth`
|
||||||
- `feat/secrets-ui`
|
- `feat/secrets-ui`
|
||||||
|
|||||||
@@ -1352,6 +1352,7 @@ impl AdminService {
|
|||||||
Protocol::Graphql,
|
Protocol::Graphql,
|
||||||
Protocol::Grpc,
|
Protocol::Grpc,
|
||||||
Protocol::Websocket,
|
Protocol::Websocket,
|
||||||
|
Protocol::Soap,
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(protocol_capability_view)
|
.map(protocol_capability_view)
|
||||||
@@ -3607,6 +3608,7 @@ fn validate_protocol_target(protocol: Protocol, target: &Target) -> Result<(), A
|
|||||||
| (Protocol::Graphql, Target::Graphql(_))
|
| (Protocol::Graphql, Target::Graphql(_))
|
||||||
| (Protocol::Grpc, Target::Grpc(_))
|
| (Protocol::Grpc, Target::Grpc(_))
|
||||||
| (Protocol::Websocket, Target::Websocket(_))
|
| (Protocol::Websocket, Target::Websocket(_))
|
||||||
|
| (Protocol::Soap, Target::Soap(_))
|
||||||
);
|
);
|
||||||
|
|
||||||
if is_match {
|
if is_match {
|
||||||
@@ -3834,6 +3836,7 @@ fn demo_grpc_operation_payload() -> OperationPayload {
|
|||||||
protocol_options: Some(crank_core::ProtocolOptions {
|
protocol_options: Some(crank_core::ProtocolOptions {
|
||||||
grpc: Some(crank_core::GrpcProtocolOptions { use_tls: false }),
|
grpc: Some(crank_core::GrpcProtocolOptions { use_tls: false }),
|
||||||
websocket: None,
|
websocket: None,
|
||||||
|
soap: None,
|
||||||
}),
|
}),
|
||||||
streaming: None,
|
streaming: None,
|
||||||
},
|
},
|
||||||
@@ -4044,6 +4047,7 @@ fn protocol_capability_view(protocol: Protocol) -> ProtocolCapabilityView {
|
|||||||
let supports_upload_artifacts = match protocol {
|
let supports_upload_artifacts = match protocol {
|
||||||
Protocol::Rest | Protocol::Graphql | Protocol::Websocket => Vec::new(),
|
Protocol::Rest | Protocol::Graphql | Protocol::Websocket => Vec::new(),
|
||||||
Protocol::Grpc => vec!["proto".to_owned(), "descriptor_set".to_owned()],
|
Protocol::Grpc => vec!["proto".to_owned(), "descriptor_set".to_owned()],
|
||||||
|
Protocol::Soap => vec!["wsdl".to_owned(), "xsd".to_owned()],
|
||||||
};
|
};
|
||||||
|
|
||||||
ProtocolCapabilityView {
|
ProtocolCapabilityView {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
<option value="graphql">GraphQL</option>
|
<option value="graphql">GraphQL</option>
|
||||||
<option value="grpc">gRPC</option>
|
<option value="grpc">gRPC</option>
|
||||||
<option value="websocket">WebSocket</option>
|
<option value="websocket">WebSocket</option>
|
||||||
|
<option value="soap">SOAP</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="form-hint" data-i18n="workspace_setup.protocol_hint">Pre-selected in the operation wizard.</div>
|
<div class="form-hint" data-i18n="workspace_setup.protocol_hint">Pre-selected in the operation wizard.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -181,13 +181,13 @@
|
|||||||
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
|
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu" x-show="openDropdown === 'protocol'" x-transition>
|
<div class="dropdown-menu" x-show="openDropdown === 'protocol'" x-transition>
|
||||||
<template x-for="p in ['rest','graphql','grpc','websocket']" :key="p">
|
<template x-for="p in ['rest','graphql','grpc','websocket','soap']" :key="p">
|
||||||
<button
|
<button
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
:class="{ selected: filterProtocol === p }"
|
:class="{ selected: filterProtocol === p }"
|
||||||
@click="setProtocol(p)"
|
@click="setProtocol(p)"
|
||||||
>
|
>
|
||||||
<span x-text="p === 'rest' ? 'REST' : p === 'graphql' ? 'GraphQL' : p === 'grpc' ? 'gRPC' : 'WebSocket'"></span>
|
<span x-text="p === 'rest' ? 'REST' : p === 'graphql' ? 'GraphQL' : p === 'grpc' ? 'gRPC' : p === 'websocket' ? 'WebSocket' : 'SOAP'"></span>
|
||||||
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
|
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ document.addEventListener('alpine:init', function() {
|
|||||||
if (operation.protocol === 'rest') return 'badge badge-rest';
|
if (operation.protocol === 'rest') return 'badge badge-rest';
|
||||||
if (operation.protocol === 'graphql') return 'badge badge-graphql';
|
if (operation.protocol === 'graphql') return 'badge badge-graphql';
|
||||||
if (operation.protocol === 'websocket') return 'badge badge-rest';
|
if (operation.protocol === 'websocket') return 'badge badge-rest';
|
||||||
|
if (operation.protocol === 'soap') return 'badge badge-rest';
|
||||||
return 'badge badge-grpc';
|
return 'badge badge-grpc';
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -424,6 +425,7 @@ document.addEventListener('alpine:init', function() {
|
|||||||
if (operation.protocol === 'rest') return 'REST';
|
if (operation.protocol === 'rest') return 'REST';
|
||||||
if (operation.protocol === 'graphql') return 'GQL';
|
if (operation.protocol === 'graphql') return 'GQL';
|
||||||
if (operation.protocol === 'websocket') return 'WS';
|
if (operation.protocol === 'websocket') return 'WS';
|
||||||
|
if (operation.protocol === 'soap') return 'SOAP';
|
||||||
return 'gRPC';
|
return 'gRPC';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ document.addEventListener('alpine:init', function() {
|
|||||||
}
|
}
|
||||||
if (operation.protocol === 'graphql') return 'GraphQL';
|
if (operation.protocol === 'graphql') return 'GraphQL';
|
||||||
if (operation.protocol === 'websocket') return 'WebSocket';
|
if (operation.protocol === 'websocket') return 'WebSocket';
|
||||||
|
if (operation.protocol === 'soap') return 'SOAP';
|
||||||
return 'gRPC';
|
return 'gRPC';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ var TRANSLATIONS = {
|
|||||||
'workspace_setup.custom_protocol.graphql': 'GraphQL',
|
'workspace_setup.custom_protocol.graphql': 'GraphQL',
|
||||||
'workspace_setup.custom_protocol.grpc': 'gRPC',
|
'workspace_setup.custom_protocol.grpc': 'gRPC',
|
||||||
'workspace_setup.custom_protocol.websocket': 'WebSocket',
|
'workspace_setup.custom_protocol.websocket': 'WebSocket',
|
||||||
|
'workspace_setup.custom_protocol.soap': 'SOAP',
|
||||||
|
|
||||||
// Wizard
|
// Wizard
|
||||||
'wizard.back_catalog': 'Operations',
|
'wizard.back_catalog': 'Operations',
|
||||||
@@ -447,6 +448,8 @@ var TRANSLATIONS = {
|
|||||||
'wizard.step1.grpc_tagline': 'High-performance binary RPC over HTTP/2. Ideal for low-latency internal services.',
|
'wizard.step1.grpc_tagline': 'High-performance binary RPC over HTTP/2. Ideal for low-latency internal services.',
|
||||||
'wizard.step1.websocket_name': 'WebSocket',
|
'wizard.step1.websocket_name': 'WebSocket',
|
||||||
'wizard.step1.websocket_tagline': 'Stateful event streams and push-oriented integrations normalized into bounded MCP streaming tools.',
|
'wizard.step1.websocket_tagline': 'Stateful event streams and push-oriented integrations normalized into bounded MCP streaming tools.',
|
||||||
|
'wizard.step1.soap_name': 'SOAP',
|
||||||
|
'wizard.step1.soap_tagline': 'Enterprise WSDL/XSD service contracts normalized into MCP tools with structured XML mapping.',
|
||||||
'wizard.step1.query': 'Query',
|
'wizard.step1.query': 'Query',
|
||||||
'wizard.step1.mutation': 'Mutation',
|
'wizard.step1.mutation': 'Mutation',
|
||||||
'wizard.step1.unary': 'Unary',
|
'wizard.step1.unary': 'Unary',
|
||||||
@@ -1196,6 +1199,7 @@ var TRANSLATIONS = {
|
|||||||
'workspace_setup.custom_protocol.graphql': 'GraphQL',
|
'workspace_setup.custom_protocol.graphql': 'GraphQL',
|
||||||
'workspace_setup.custom_protocol.grpc': 'gRPC',
|
'workspace_setup.custom_protocol.grpc': 'gRPC',
|
||||||
'workspace_setup.custom_protocol.websocket': 'WebSocket',
|
'workspace_setup.custom_protocol.websocket': 'WebSocket',
|
||||||
|
'workspace_setup.custom_protocol.soap': 'SOAP',
|
||||||
|
|
||||||
// Wizard
|
// Wizard
|
||||||
'wizard.back_catalog': 'Операции',
|
'wizard.back_catalog': 'Операции',
|
||||||
@@ -1232,6 +1236,8 @@ var TRANSLATIONS = {
|
|||||||
'wizard.step1.grpc_tagline': 'Высокопроизводительный бинарный RPC поверх HTTP/2. Подходит для низколатентных внутренних сервисов.',
|
'wizard.step1.grpc_tagline': 'Высокопроизводительный бинарный RPC поверх HTTP/2. Подходит для низколатентных внутренних сервисов.',
|
||||||
'wizard.step1.websocket_name': 'WebSocket',
|
'wizard.step1.websocket_name': 'WebSocket',
|
||||||
'wizard.step1.websocket_tagline': 'Состояние соединения, event streams и push-oriented интеграции, нормализованные в bounded MCP streaming tools.',
|
'wizard.step1.websocket_tagline': 'Состояние соединения, event streams и push-oriented интеграции, нормализованные в bounded MCP streaming tools.',
|
||||||
|
'wizard.step1.soap_name': 'SOAP',
|
||||||
|
'wizard.step1.soap_tagline': 'Enterprise WSDL/XSD-контракты, нормализованные в MCP-инструменты со структурированным XML mapping.',
|
||||||
'wizard.step1.query': 'Запрос',
|
'wizard.step1.query': 'Запрос',
|
||||||
'wizard.step1.mutation': 'Мутация',
|
'wizard.step1.mutation': 'Мутация',
|
||||||
'wizard.step1.unary': 'Unary',
|
'wizard.step1.unary': 'Unary',
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (protocol === 'websocket' || protocol === 'Websocket') {
|
if (protocol === 'websocket' || protocol === 'Websocket') {
|
||||||
return 'var(--teal)';
|
return 'var(--teal)';
|
||||||
}
|
}
|
||||||
|
if (protocol === 'soap' || protocol === 'Soap') {
|
||||||
|
return 'var(--blue)';
|
||||||
|
}
|
||||||
return 'var(--blue)';
|
return 'var(--blue)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +77,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (protocol === 'websocket' || protocol === 'Websocket') {
|
if (protocol === 'websocket' || protocol === 'Websocket') {
|
||||||
return 'WebSocket';
|
return 'WebSocket';
|
||||||
}
|
}
|
||||||
|
if (protocol === 'soap' || protocol === 'Soap') {
|
||||||
|
return 'SOAP';
|
||||||
|
}
|
||||||
return 'REST';
|
return 'REST';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
var currentStep = 1;
|
var currentStep = 1;
|
||||||
var TOTAL_STEPS = 5;
|
var TOTAL_STEPS = 5;
|
||||||
var wizardProtocol = 'rest'; // 'rest' | 'graphql' | 'grpc' | 'websocket'
|
var wizardProtocol = 'rest'; // 'rest' | 'graphql' | 'grpc' | 'websocket' | 'soap'
|
||||||
var wizardMode = 'create'; // 'create' | 'edit'
|
var wizardMode = 'create'; // 'create' | 'edit'
|
||||||
var wizardEditId = null;
|
var wizardEditId = null;
|
||||||
var wizardWorkspaceId = null;
|
var wizardWorkspaceId = null;
|
||||||
@@ -36,6 +36,10 @@ function defaultProtocolCapabilities() {
|
|||||||
supports_execution_modes: ['window', 'session', 'async_job'],
|
supports_execution_modes: ['window', 'session', 'async_job'],
|
||||||
supports_transport_behaviors: ['server_stream', 'stateful_session', 'deferred_result'],
|
supports_transport_behaviors: ['server_stream', 'stateful_session', 'deferred_result'],
|
||||||
},
|
},
|
||||||
|
soap: {
|
||||||
|
supports_execution_modes: ['unary', 'async_job'],
|
||||||
|
supports_transport_behaviors: ['request_response', 'server_stream', 'deferred_result'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod observability;
|
|||||||
pub mod operation;
|
pub mod operation;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod secret;
|
pub mod secret;
|
||||||
|
pub mod soap;
|
||||||
pub mod stream_session;
|
pub mod stream_session;
|
||||||
pub mod streaming;
|
pub mod streaming;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
@@ -30,11 +31,14 @@ pub use observability::{
|
|||||||
pub use operation::{
|
pub use operation::{
|
||||||
ConfigExport, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlTarget,
|
ConfigExport, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlTarget,
|
||||||
GrpcProtocolOptions, GrpcTarget, Operation, OperationStatus, ProtocolOptions, RestTarget,
|
GrpcProtocolOptions, GrpcTarget, Operation, OperationStatus, ProtocolOptions, RestTarget,
|
||||||
RetryPolicy, Samples, Target, ToolDescription, ToolExample, WebsocketProtocolOptions,
|
RetryPolicy, Samples, SoapProtocolOptions, SoapTarget, Target, ToolDescription, ToolExample,
|
||||||
WebsocketTarget,
|
WebsocketProtocolOptions, WebsocketTarget,
|
||||||
};
|
};
|
||||||
pub use protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol};
|
pub use protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol};
|
||||||
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||||
|
pub use soap::{
|
||||||
|
SoapBindingStyle, SoapFaultContract, SoapHeaderConfig, SoapOperationMetadata, SoapVersion,
|
||||||
|
};
|
||||||
pub use stream_session::{AsyncJobHandle, JobStatus, StreamSession, StreamStatus};
|
pub use stream_session::{AsyncJobHandle, JobStatus, StreamSession, StreamStatus};
|
||||||
pub use streaming::{
|
pub use streaming::{
|
||||||
AggregationMode, ExecutionMode, StreamingConfig, StreamingConfigError, ToolFamilyConfig,
|
AggregationMode, ExecutionMode, StreamingConfig, StreamingConfigError, ToolFamilyConfig,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ use serde_json::Value;
|
|||||||
use crate::{
|
use crate::{
|
||||||
ids::{AuthProfileId, DescriptorId, OperationId, SampleId},
|
ids::{AuthProfileId, DescriptorId, OperationId, SampleId},
|
||||||
protocol::{ExportMode, GraphqlOperationType, HttpMethod, Protocol},
|
protocol::{ExportMode, GraphqlOperationType, HttpMethod, Protocol},
|
||||||
|
soap::{
|
||||||
|
SoapBindingStyle, SoapFaultContract, SoapHeaderConfig, SoapOperationMetadata, SoapVersion,
|
||||||
|
},
|
||||||
streaming::StreamingConfig,
|
streaming::StreamingConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,6 +66,26 @@ pub struct WebsocketTarget {
|
|||||||
pub static_headers: BTreeMap<String, String>,
|
pub static_headers: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SoapTarget {
|
||||||
|
pub wsdl_ref: SampleId,
|
||||||
|
pub service_name: String,
|
||||||
|
pub port_name: String,
|
||||||
|
pub operation_name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub endpoint_override: Option<String>,
|
||||||
|
pub soap_version: SoapVersion,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub soap_action: Option<String>,
|
||||||
|
pub binding_style: SoapBindingStyle,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub headers: Vec<SoapHeaderConfig>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fault_contract: Option<SoapFaultContract>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub metadata: SoapOperationMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum Target {
|
pub enum Target {
|
||||||
@@ -70,6 +93,7 @@ pub enum Target {
|
|||||||
Graphql(GraphqlTarget),
|
Graphql(GraphqlTarget),
|
||||||
Grpc(GrpcTarget),
|
Grpc(GrpcTarget),
|
||||||
Websocket(WebsocketTarget),
|
Websocket(WebsocketTarget),
|
||||||
|
Soap(SoapTarget),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
@@ -92,12 +116,24 @@ pub struct WebsocketProtocolOptions {
|
|||||||
pub reconnect_backoff_ms: Option<u64>,
|
pub reconnect_backoff_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SoapProtocolOptions {
|
||||||
|
#[serde(default)]
|
||||||
|
pub use_tls: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub validate_certificate: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ws_security_profile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
pub struct ProtocolOptions {
|
pub struct ProtocolOptions {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub grpc: Option<GrpcProtocolOptions>,
|
pub grpc: Option<GrpcProtocolOptions>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub websocket: Option<WebsocketProtocolOptions>,
|
pub websocket: Option<WebsocketProtocolOptions>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub soap: Option<SoapProtocolOptions>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -231,13 +267,17 @@ mod tests {
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{AuthConfig, AuthProfile, BearerAuthConfig, SecretRef},
|
auth::{AuthConfig, AuthProfile, BearerAuthConfig, SecretRef},
|
||||||
ids::{AuthProfileId, OperationId},
|
ids::{AuthProfileId, OperationId, SampleId},
|
||||||
operation::{
|
operation::{
|
||||||
ConfigExport, ExecutionConfig, GraphqlTarget, Operation, OperationStatus,
|
ConfigExport, ExecutionConfig, GraphqlTarget, Operation, OperationStatus,
|
||||||
ProtocolOptions, RestTarget, Samples, Target, ToolDescription, ToolExample,
|
ProtocolOptions, RestTarget, Samples, SoapProtocolOptions, SoapTarget, Target,
|
||||||
WebsocketProtocolOptions, WebsocketTarget,
|
ToolDescription, ToolExample, WebsocketProtocolOptions, WebsocketTarget,
|
||||||
},
|
},
|
||||||
protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol},
|
protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol},
|
||||||
|
soap::{
|
||||||
|
SoapBindingStyle, SoapFaultContract, SoapHeaderConfig, SoapOperationMetadata,
|
||||||
|
SoapVersion,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -288,6 +328,43 @@ mod tests {
|
|||||||
assert_eq!(value["subscribe_message_template"]["type"], "subscribe");
|
assert_eq!(value["subscribe_message_template"]["type"], "subscribe");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn soap_target_serializes_binding_metadata() {
|
||||||
|
let target = Target::Soap(SoapTarget {
|
||||||
|
wsdl_ref: SampleId::new("sample_wsdl_01"),
|
||||||
|
service_name: "LeadService".to_owned(),
|
||||||
|
port_name: "LeadPort".to_owned(),
|
||||||
|
operation_name: "CreateLead".to_owned(),
|
||||||
|
endpoint_override: Some("https://soap.example.com/lead".to_owned()),
|
||||||
|
soap_version: SoapVersion::Soap12,
|
||||||
|
soap_action: Some("urn:createLead".to_owned()),
|
||||||
|
binding_style: SoapBindingStyle::DocumentLiteral,
|
||||||
|
headers: vec![SoapHeaderConfig {
|
||||||
|
name: "CorrelationId".to_owned(),
|
||||||
|
namespace_uri: Some("urn:crm".to_owned()),
|
||||||
|
required: true,
|
||||||
|
value_path: Some("$.mcp.correlation_id".to_owned()),
|
||||||
|
}],
|
||||||
|
fault_contract: Some(SoapFaultContract {
|
||||||
|
code_path: Some("$.Envelope.Body.Fault.Code.Value".to_owned()),
|
||||||
|
message_path: Some("$.Envelope.Body.Fault.Reason.Text".to_owned()),
|
||||||
|
detail_path: Some("$.Envelope.Body.Fault.Detail".to_owned()),
|
||||||
|
}),
|
||||||
|
metadata: SoapOperationMetadata {
|
||||||
|
input_part_names: vec!["LeadRequest".to_owned()],
|
||||||
|
output_part_names: vec!["LeadResponse".to_owned()],
|
||||||
|
namespaces: vec!["urn:crm".to_owned()],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let value = serde_json::to_value(target).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["kind"], "soap");
|
||||||
|
assert_eq!(value["soap_version"], "soap_12");
|
||||||
|
assert_eq!(value["binding_style"], "document_literal");
|
||||||
|
assert_eq!(value["metadata"]["input_part_names"][0], "LeadRequest");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn operation_exposes_local_domain_helpers() {
|
fn operation_exposes_local_domain_helpers() {
|
||||||
let operation = Operation {
|
let operation = Operation {
|
||||||
@@ -320,6 +397,11 @@ mod tests {
|
|||||||
reconnect_max_attempts: Some(3),
|
reconnect_max_attempts: Some(3),
|
||||||
reconnect_backoff_ms: Some(250),
|
reconnect_backoff_ms: Some(250),
|
||||||
}),
|
}),
|
||||||
|
soap: Some(SoapProtocolOptions {
|
||||||
|
use_tls: true,
|
||||||
|
validate_certificate: true,
|
||||||
|
ws_security_profile: Some("username_token".to_owned()),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
streaming: None,
|
streaming: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub enum Protocol {
|
|||||||
Graphql,
|
Graphql,
|
||||||
Grpc,
|
Grpc,
|
||||||
Websocket,
|
Websocket,
|
||||||
|
Soap,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -51,6 +52,7 @@ impl Protocol {
|
|||||||
Self::Graphql => matches!(mode, ExecutionMode::Unary),
|
Self::Graphql => matches!(mode, ExecutionMode::Unary),
|
||||||
Self::Grpc => true,
|
Self::Grpc => true,
|
||||||
Self::Websocket => !matches!(mode, ExecutionMode::Unary),
|
Self::Websocket => !matches!(mode, ExecutionMode::Unary),
|
||||||
|
Self::Soap => matches!(mode, ExecutionMode::Unary | ExecutionMode::AsyncJob),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ impl Protocol {
|
|||||||
Self::Graphql => matches!(behavior, TransportBehavior::RequestResponse),
|
Self::Graphql => matches!(behavior, TransportBehavior::RequestResponse),
|
||||||
Self::Grpc => !matches!(behavior, TransportBehavior::DeferredResult),
|
Self::Grpc => !matches!(behavior, TransportBehavior::DeferredResult),
|
||||||
Self::Websocket => !matches!(behavior, TransportBehavior::RequestResponse),
|
Self::Websocket => !matches!(behavior, TransportBehavior::RequestResponse),
|
||||||
|
Self::Soap => !matches!(behavior, TransportBehavior::StatefulSession),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,4 +101,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(Protocol::Websocket.supports_transport_behavior(TransportBehavior::DeferredResult));
|
assert!(Protocol::Websocket.supports_transport_behavior(TransportBehavior::DeferredResult));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn soap_is_request_response_first_with_async_job_support() {
|
||||||
|
assert!(Protocol::Soap.supports_execution_mode(ExecutionMode::Unary));
|
||||||
|
assert!(!Protocol::Soap.supports_execution_mode(ExecutionMode::Window));
|
||||||
|
assert!(!Protocol::Soap.supports_execution_mode(ExecutionMode::Session));
|
||||||
|
assert!(Protocol::Soap.supports_execution_mode(ExecutionMode::AsyncJob));
|
||||||
|
assert!(Protocol::Soap.supports_transport_behavior(TransportBehavior::RequestResponse));
|
||||||
|
assert!(Protocol::Soap.supports_transport_behavior(TransportBehavior::ServerStream));
|
||||||
|
assert!(!Protocol::Soap.supports_transport_behavior(TransportBehavior::StatefulSession));
|
||||||
|
assert!(Protocol::Soap.supports_transport_behavior(TransportBehavior::DeferredResult));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum SoapVersion {
|
||||||
|
#[serde(rename = "soap_11")]
|
||||||
|
Soap11,
|
||||||
|
#[serde(rename = "soap_12")]
|
||||||
|
Soap12,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SoapBindingStyle {
|
||||||
|
DocumentLiteral,
|
||||||
|
RpcLiteral,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SoapHeaderConfig {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub namespace_uri: Option<String>,
|
||||||
|
pub required: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub value_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SoapFaultContract {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub code_path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub message_path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub detail_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SoapOperationMetadata {
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub input_part_names: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub output_part_names: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub namespaces: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
SoapBindingStyle, SoapFaultContract, SoapHeaderConfig, SoapOperationMetadata, SoapVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn soap_metadata_serializes_compactly() {
|
||||||
|
let value = serde_json::to_value(SoapOperationMetadata {
|
||||||
|
input_part_names: vec!["LeadRequest".to_owned()],
|
||||||
|
output_part_names: vec!["LeadResponse".to_owned()],
|
||||||
|
namespaces: vec!["urn:crm".to_owned()],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["input_part_names"][0], "LeadRequest");
|
||||||
|
assert_eq!(value["output_part_names"][0], "LeadResponse");
|
||||||
|
assert_eq!(value["namespaces"][0], "urn:crm");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn soap_supporting_types_roundtrip() {
|
||||||
|
let value = json!({
|
||||||
|
"version": "soap_12",
|
||||||
|
"style": "document_literal",
|
||||||
|
"header": {
|
||||||
|
"name": "CorrelationId",
|
||||||
|
"namespace_uri": "urn:crm",
|
||||||
|
"required": true,
|
||||||
|
"value_path": "$.mcp.correlation_id"
|
||||||
|
},
|
||||||
|
"fault": {
|
||||||
|
"code_path": "$.Envelope.Body.Fault.Code.Value",
|
||||||
|
"message_path": "$.Envelope.Body.Fault.Reason.Text",
|
||||||
|
"detail_path": "$.Envelope.Body.Fault.Detail"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let version: SoapVersion = serde_json::from_value(value["version"].clone()).unwrap();
|
||||||
|
let style: SoapBindingStyle = serde_json::from_value(value["style"].clone()).unwrap();
|
||||||
|
let header: SoapHeaderConfig = serde_json::from_value(value["header"].clone()).unwrap();
|
||||||
|
let fault: SoapFaultContract = serde_json::from_value(value["fault"].clone()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(version, SoapVersion::Soap12);
|
||||||
|
assert_eq!(style, SoapBindingStyle::DocumentLiteral);
|
||||||
|
assert_eq!(header.name, "CorrelationId");
|
||||||
|
assert_eq!(
|
||||||
|
fault.detail_path.as_deref(),
|
||||||
|
Some("$.Envelope.Body.Fault.Detail")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3959,6 +3959,12 @@ fn target_summary(target: &Target) -> (String, String) {
|
|||||||
),
|
),
|
||||||
Target::Grpc(grpc) => (grpc.server_addr.clone(), grpc.method.clone()),
|
Target::Grpc(grpc) => (grpc.server_addr.clone(), grpc.method.clone()),
|
||||||
Target::Websocket(websocket) => (websocket.url.clone(), "SUBSCRIBE".to_owned()),
|
Target::Websocket(websocket) => (websocket.url.clone(), "SUBSCRIBE".to_owned()),
|
||||||
|
Target::Soap(soap) => (
|
||||||
|
soap.endpoint_override
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("wsdl://{}", soap.service_name)),
|
||||||
|
soap.operation_name.clone(),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,9 @@ impl RuntimeExecutor {
|
|||||||
data: Value::Null,
|
data: Value::Null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
Target::Soap(_) => Err(RuntimeError::UnsupportedProtocol {
|
||||||
|
protocol: crank_core::Protocol::Soap,
|
||||||
|
}),
|
||||||
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,
|
||||||
@@ -323,6 +326,9 @@ impl RuntimeExecutor {
|
|||||||
data: Value::Null,
|
data: Value::Null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
Target::Soap(_) => Err(RuntimeError::UnsupportedProtocol {
|
||||||
|
protocol: crank_core::Protocol::Soap,
|
||||||
|
}),
|
||||||
_ => self.execute_adapter(operation, prepared_request).await,
|
_ => self.execute_adapter(operation, prepared_request).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ mod normalize;
|
|||||||
mod validate;
|
mod validate;
|
||||||
|
|
||||||
pub use error::SchemaError;
|
pub use error::SchemaError;
|
||||||
pub use model::{Schema, SchemaKind};
|
pub use model::{Schema, SchemaKind, XmlNodeKind, XmlQualifiedName, XmlSchemaBinding};
|
||||||
|
|||||||
@@ -3,6 +3,33 @@ use std::collections::BTreeMap;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum XmlNodeKind {
|
||||||
|
Element,
|
||||||
|
Attribute,
|
||||||
|
Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct XmlQualifiedName {
|
||||||
|
pub local_name: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub namespace_uri: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub prefix: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct XmlSchemaBinding {
|
||||||
|
pub node_kind: XmlNodeKind,
|
||||||
|
pub name: XmlQualifiedName,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repeated: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub nillable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SchemaKind {
|
pub enum SchemaKind {
|
||||||
@@ -72,7 +99,7 @@ impl Schema {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use super::{Schema, SchemaKind};
|
use super::{Schema, SchemaKind, XmlNodeKind, XmlQualifiedName, XmlSchemaBinding};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn object_schema_exposes_fields() {
|
fn object_schema_exposes_fields() {
|
||||||
@@ -175,4 +202,25 @@ mod tests {
|
|||||||
assert!(yaml.contains("type: object"));
|
assert!(yaml.contains("type: object"));
|
||||||
assert_eq!(restored, schema);
|
assert_eq!(restored, schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xml_schema_binding_roundtrips() {
|
||||||
|
let binding = XmlSchemaBinding {
|
||||||
|
node_kind: XmlNodeKind::Element,
|
||||||
|
name: XmlQualifiedName {
|
||||||
|
local_name: "CreateLeadRequest".to_owned(),
|
||||||
|
namespace_uri: Some("urn:crm".to_owned()),
|
||||||
|
prefix: Some("crm".to_owned()),
|
||||||
|
},
|
||||||
|
repeated: false,
|
||||||
|
nillable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&binding).unwrap();
|
||||||
|
let restored: XmlSchemaBinding = serde_json::from_value(value.clone()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["node_kind"], "element");
|
||||||
|
assert_eq!(value["name"]["local_name"], "CreateLeadRequest");
|
||||||
|
assert_eq!(restored, binding);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-9
@@ -430,6 +430,16 @@
|
|||||||
|
|
||||||
- `kind`
|
- `kind`
|
||||||
- `wsdl_ref`
|
- `wsdl_ref`
|
||||||
|
- `service_name`
|
||||||
|
- `port_name`
|
||||||
|
- `operation_name`
|
||||||
|
- `endpoint_override`
|
||||||
|
- `soap_version`
|
||||||
|
- `soap_action`
|
||||||
|
- `binding_style`
|
||||||
|
- `headers`
|
||||||
|
- `fault_contract`
|
||||||
|
- `metadata`
|
||||||
|
|
||||||
## 5. `ProtocolOptions`
|
## 5. `ProtocolOptions`
|
||||||
|
|
||||||
@@ -444,15 +454,28 @@
|
|||||||
- `heartbeat_interval_ms`
|
- `heartbeat_interval_ms`
|
||||||
- `reconnect_max_attempts`
|
- `reconnect_max_attempts`
|
||||||
- `reconnect_backoff_ms`
|
- `reconnect_backoff_ms`
|
||||||
- `service_name`
|
|
||||||
- `port_name`
|
|
||||||
- `operation_name`
|
|
||||||
- `endpoint_override`
|
|
||||||
- `soap_version`
|
|
||||||
- `soap_action`
|
|
||||||
- `header_config`
|
|
||||||
|
|
||||||
## 5. `Schema`
|
### 5.3. `SoapProtocolOptions`
|
||||||
|
|
||||||
|
- `use_tls`
|
||||||
|
- `validate_certificate`
|
||||||
|
- `ws_security_profile`
|
||||||
|
|
||||||
|
## 6. XML normalization model
|
||||||
|
|
||||||
|
Для SOAP/XSD-пайплайна `crank-schema` теперь фиксирует XML-origin metadata отдельными code-level types:
|
||||||
|
|
||||||
|
- `XmlNodeKind`
|
||||||
|
- `XmlQualifiedName`
|
||||||
|
- `XmlSchemaBinding`
|
||||||
|
|
||||||
|
Они описывают:
|
||||||
|
|
||||||
|
- является ли узел element / attribute / text;
|
||||||
|
- локальное имя и namespace;
|
||||||
|
- repeated / nillable semantics.
|
||||||
|
|
||||||
|
## 7. `Schema`
|
||||||
|
|
||||||
`Schema` - нормализованное описание входа или выхода.
|
`Schema` - нормализованное описание входа или выхода.
|
||||||
|
|
||||||
@@ -465,7 +488,7 @@
|
|||||||
- nullable-поля;
|
- nullable-поля;
|
||||||
- `oneof` для protobuf.
|
- `oneof` для protobuf.
|
||||||
|
|
||||||
## 6. Принцип совместимости
|
## 8. Принцип совместимости
|
||||||
|
|
||||||
Если UI требует сущность, которой нет в текущем backend, эта сущность должна быть сначала явно добавлена в эту модель данных, а уже потом в код и БД.
|
Если UI требует сущность, которой нет в текущем backend, эта сущность должна быть сначала явно добавлена в эту модель данных, а уже потом в код и БД.
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,25 @@ SOAP operation должна включать:
|
|||||||
- `execution_config`
|
- `execution_config`
|
||||||
- `tool_description`
|
- `tool_description`
|
||||||
|
|
||||||
|
Code-level model:
|
||||||
|
|
||||||
|
- `crank_core::SoapTarget`
|
||||||
|
- `crank_core::SoapProtocolOptions`
|
||||||
|
- `crank_core::SoapVersion`
|
||||||
|
- `crank_core::SoapBindingStyle`
|
||||||
|
- `crank_core::SoapHeaderConfig`
|
||||||
|
- `crank_core::SoapFaultContract`
|
||||||
|
- `crank_core::SoapOperationMetadata`
|
||||||
|
- `crank_schema::XmlNodeKind`
|
||||||
|
- `crank_schema::XmlQualifiedName`
|
||||||
|
- `crank_schema::XmlSchemaBinding`
|
||||||
|
|
||||||
|
Execution constraints:
|
||||||
|
|
||||||
|
- `Protocol::Soap` поддерживает только `unary` и `async_job`;
|
||||||
|
- `window` и `session` для SOAP не поддерживаются;
|
||||||
|
- runtime до появления SOAP adapter обязан отклонять выполнение как `unsupported protocol`.
|
||||||
|
|
||||||
## 7. Как оператор настраивает SOAP operation
|
## 7. Как оператор настраивает SOAP operation
|
||||||
|
|
||||||
1. Загружает WSDL.
|
1. Загружает WSDL.
|
||||||
|
|||||||
Reference in New Issue
Block a user